Merge remote-tracking branch 'soapbox/next' into next_

next-virtuoso-proof
marcin mikołajczak 2022-04-14 00:05:21 +02:00
commit 44b64d51c4
26 zmienionych plików z 462 dodań i 180 usunięć

Wyświetl plik

@ -141,6 +141,7 @@ module.exports = {
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
'react/jsx-indent': ['error', 2], 'react/jsx-indent': ['error', 2],
// 'react/jsx-no-bind': ['error'], // 'react/jsx-no-bind': ['error'],
'react/jsx-no-comment-textnodes': 'error',
'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-duplicate-props': 'error',
'react/jsx-no-undef': 'error', 'react/jsx-no-undef': 'error',
'react/jsx-tag-spacing': 'error', 'react/jsx-tag-spacing': 'error',

Wyświetl plik

@ -1,4 +1,4 @@
image: node:14 image: node:16
variables: variables:
NODE_ENV: test NODE_ENV: test

Wyświetl plik

@ -1 +1 @@
nodejs 14.17.6 nodejs 16.14.2

Wyświetl plik

@ -9,7 +9,7 @@ export const __stub = (func: Function) => mocks.push(func);
export const __clear = (): Function[] => mocks = []; export const __clear = (): Function[] => mocks = [];
const setupMock = (axios: AxiosInstance) => { const setupMock = (axios: AxiosInstance) => {
const mock = new MockAdapter(axios); const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
mocks.map(func => func(mock)); mocks.map(func => func(mock));
}; };

Wyświetl plik

@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import KVStore from 'soapbox/storage/kv_store'; import KVStore from 'soapbox/storage/kv_store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
@ -39,12 +40,14 @@ export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST';
export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS';
export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL';
const customApp = custom('app');
export const messages = defineMessages({ export const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' },
}); });
const noOp = () => () => new Promise(f => f()); const noOp = () => new Promise(f => f());
const getScopes = state => { const getScopes = state => {
const instance = state.get('instance'); const instance = state.get('instance');
@ -54,12 +57,23 @@ const getScopes = state => {
function createAppAndToken() { function createAppAndToken() {
return (dispatch, getState) => { return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => { return dispatch(getAuthApp()).then(() => {
return dispatch(createAppToken()); return dispatch(createAppToken());
}); });
}; };
} }
/** Create an auth app, or use it from build config */
function getAuthApp() {
return (dispatch, getState) => {
if (customApp?.client_secret) {
return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp }));
} else {
return dispatch(createAuthApp());
}
};
}
function createAuthApp() { function createAuthApp() {
return (dispatch, getState) => { return (dispatch, getState) => {
const params = { const params = {
@ -117,7 +131,7 @@ export function refreshUserToken() {
const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']); const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']);
const app = getState().getIn(['auth', 'app']); const app = getState().getIn(['auth', 'app']);
if (!refreshToken) return dispatch(noOp()); if (!refreshToken) return dispatch(noOp);
const params = { const params = {
client_id: app.get('client_id'), client_id: app.get('client_id'),
@ -200,7 +214,7 @@ export function loadCredentials(token, accountUrl) {
export function logIn(intl, username, password) { export function logIn(intl, username, password) {
return (dispatch, getState) => { return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => { return dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(username, password)); return dispatch(createUserToken(username, password));
}).catch(error => { }).catch(error => {
if (error.response.data.error === 'mfa_required') { if (error.response.data.error === 'mfa_required') {

Wyświetl plik

@ -1,7 +1,8 @@
export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE'; export const MODAL_CLOSE = 'MODAL_CLOSE';
export function openModal(type, props) { /** Open a modal of the given type */
export function openModal(type: string, props?: any) {
return { return {
type: MODAL_OPEN, type: MODAL_OPEN,
modalType: type, modalType: type,
@ -9,7 +10,8 @@ export function openModal(type, props) {
}; };
} }
export function closeModal(type) { /** Close the modal */
export function closeModal(type: string) {
return { return {
type: MODAL_CLOSE, type: MODAL_CLOSE,
modalType: type, modalType: type,

Wyświetl plik

@ -0,0 +1,117 @@
import classNames from 'classnames';
import React, { useState, useRef } from 'react';
import { usePopper } from 'react-popper';
import { useDispatch } from 'react-redux';
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
import { openModal } from 'soapbox/actions/modals';
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is_mobile';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
interface IEmojiButtonWrapper {
statusId: string,
children: JSX.Element,
}
/** Provides emoji reaction functionality to the underlying button component */
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useDispatch();
const ownAccount = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const soapboxConfig = useSoapboxConfig();
const [visible, setVisible] = useState(false);
// const [focused, setFocused] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const popperRef = useRef<HTMLDivElement>(null);
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
placement: 'top-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
if (!status) return null;
const handleMouseEnter = () => {
setVisible(true);
};
const handleMouseLeave = () => {
setVisible(false);
};
const handleReact = (emoji: string): void => {
if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji));
} else {
dispatch(openModal('UNAUTHORIZED', {
action: 'FAVOURITE',
ap_id: status.url,
}));
}
setVisible(false);
};
const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍';
if (isUserTouching()) {
if (visible) {
handleReact(meEmojiReact);
} else {
setVisible(true);
}
} else {
handleReact(meEmojiReact);
}
e.stopPropagation();
};
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
// setFocused(false);
// };
const selector = (
<div
className={classNames('fixed z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={popperRef}
style={styles.popper}
{...attributes.popper}
>
<EmojiSelector
emojis={soapboxConfig.allowedEmoji}
onReact={handleReact}
// focused={focused}
// onUnfocus={handleUnfocus}
/>
</div>
);
return (
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
onClick: handleClick,
ref,
})}
{selector}
</div>
);
};
export default EmojiButtonWrapper;

Wyświetl plik

@ -1,63 +0,0 @@
import classNames from 'classnames';
import React, { useState, useRef } from 'react';
import { usePopper } from 'react-popper';
interface IHoverable {
component: JSX.Element,
}
/** Wrapper to render a given component when hovered */
const Hoverable: React.FC<IHoverable> = ({
component,
children,
}): JSX.Element => {
const [portalActive, setPortalActive] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const popperRef = useRef<HTMLDivElement>(null);
const handleMouseEnter = () => {
setPortalActive(true);
};
const handleMouseLeave = () => {
setPortalActive(false);
};
const { styles, attributes } = usePopper(ref.current, popperRef.current, {
placement: 'top-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [-10, 0],
},
},
],
});
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref}
>
{children}
<div
className={classNames('fixed z-50 transition-opacity duration-100', {
'opacity-0 pointer-events-none': !portalActive,
})}
ref={popperRef}
style={styles.popper}
{...attributes.popper}
>
{component}
</div>
</div>
);
};
export default Hoverable;

Wyświetl plik

@ -27,7 +27,7 @@ const SidebarNavigation = () => {
<SidebarNavigationLink <SidebarNavigationLink
to='/' to='/'
icon={require('icons/feed.svg')} icon={require('icons/feed.svg')}
text={<FormattedMessage id='tabs_bar.home' defaultMessage='Feed' />} text={<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />}
/> />
{account && ( {account && (
@ -42,7 +42,7 @@ const SidebarNavigation = () => {
to='/notifications' to='/notifications'
icon={require('icons/alert.svg')} icon={require('icons/alert.svg')}
count={notificationCount} count={notificationCount}
text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Alerts' />} text={<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />}
/> />
<SidebarNavigationLink <SidebarNavigationLink
@ -95,27 +95,25 @@ const SidebarNavigation = () => {
/> />
)} )}
{/* {features.federating ? ( {(features.localTimeline || features.publicTimeline) && (
<NavLink to='/timeline/local' className='btn grouped'> <hr className='dark:border-slate-700' />
<Icon
src={require('@tabler/icons/icons/users.svg')}
className={classNames('primary-navigation__icon', { 'svg-icon--active': location.pathname === '/timeline/local' })}
/>
{instance.title}
</NavLink>
) : (
<NavLink to='/timeline/local' className='btn grouped'>
<Icon src={require('@tabler/icons/icons/world.svg')} className='primary-navigation__icon' />
<FormattedMessage id='tabs_bar.all' defaultMessage='All' />
</NavLink>
)} )}
{features.federating && ( {features.localTimeline && (
<NavLink to='/timeline/fediverse' className='btn grouped'> <SidebarNavigationLink
<Icon src={require('icons/fediverse.svg')} className='column-header__icon' /> to='/timeline/local'
<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' /> icon={features.federating ? require('@tabler/icons/icons/users.svg') : require('@tabler/icons/icons/world.svg')}
</NavLink> text={features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
)} */} />
)}
{(features.publicTimeline && features.federating) && (
<SidebarNavigationLink
to='/timeline/fediverse'
icon={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
/>
)}
</div> </div>
{account && ( {account && (

Wyświetl plik

@ -1,23 +1,24 @@
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { logOut, switchAccount } from 'soapbox/actions/auth'; import { logOut, switchAccount } from 'soapbox/actions/auth';
import { fetchOwnAccounts } from 'soapbox/actions/auth'; import { fetchOwnAccounts } from 'soapbox/actions/auth';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui'; import { Stack } from 'soapbox/components/ui';
import ProfileStats from 'soapbox/features/ui/components/profile_stats'; import ProfileStats from 'soapbox/features/ui/components/profile_stats';
import { getFeatures } from 'soapbox/utils/features'; import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks';
import { closeSidebar } from '../actions/sidebar'; import { closeSidebar } from '../actions/sidebar';
import { makeGetAccount, makeGetOtherAccounts } from '../selectors'; import { makeGetAccount, makeGetOtherAccounts } from '../selectors';
import { HStack, Icon, IconButton, Text } from './ui'; import { HStack, Icon, IconButton, Text } from './ui';
import type { List as ImmutableList } from 'immutable';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const messages = defineMessages({ const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' }, followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' }, follows: { id: 'account.follows', defaultMessage: 'Follows' },
@ -33,7 +34,14 @@ const messages = defineMessages({
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
}); });
const SidebarLink = ({ to, icon, text, onClick }) => ( interface ISidebarLink {
to: string,
icon: string,
text: string | JSX.Element,
onClick: React.EventHandler<React.MouseEvent>,
}
const SidebarLink: React.FC<ISidebarLink> = ({ to, icon, text, onClick }) => (
<NavLink className='group py-1 rounded-md' to={to} onClick={onClick}> <NavLink className='group py-1 rounded-md' to={to} onClick={onClick}>
<HStack space={2} alignItems='center'> <HStack space={2} alignItems='center'>
<div className='bg-primary-50 dark:bg-slate-700 relative rounded inline-flex p-2'> <div className='bg-primary-50 dark:bg-slate-700 relative rounded inline-flex p-2'>
@ -45,25 +53,20 @@ const SidebarLink = ({ to, icon, text, onClick }) => (
</NavLink> </NavLink>
); );
SidebarLink.propTypes = { const getOtherAccounts = makeGetOtherAccounts();
to: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const SidebarMenu = () => { const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const logo = useSelector((state) => getSoapboxConfig(state).get('logo')); const { logo } = useSoapboxConfig();
const features = useSelector((state) => getFeatures(state.get('instance'))); const features = useFeatures();
const getAccount = makeGetAccount(); const getAccount = makeGetAccount();
const getOtherAccounts = makeGetOtherAccounts(); const instance = useAppSelector((state) => state.instance);
const me = useSelector((state) => state.get('me')); const me = useAppSelector((state) => state.me);
const account = useSelector((state) => getAccount(state, me)); const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const otherAccounts = useSelector((state) => getOtherAccounts(state)); const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const closeButtonRef = React.useRef(null); const closeButtonRef = React.useRef(null);
@ -76,25 +79,27 @@ const SidebarMenu = () => {
onClose(); onClose();
}; };
const handleSwitchAccount = (event, account) => { const handleSwitchAccount = (account: AccountEntity): React.EventHandler<React.MouseEvent> => {
event.preventDefault(); return (e) => {
switchAccount(account); e.preventDefault();
dispatch(switchAccount(account.get('id'))); switchAccount(account);
dispatch(switchAccount(account.id));
};
}; };
const onClickLogOut = (event) => { const onClickLogOut: React.EventHandler<React.MouseEvent> = (e) => {
event.preventDefault(); e.preventDefault();
dispatch(logOut(intl)); dispatch(logOut(intl));
}; };
const handleSwitcherClick = (e) => { const handleSwitcherClick: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault(); e.preventDefault();
setSwitcher((prevState) => (!prevState)); setSwitcher((prevState) => (!prevState));
}; };
const renderAccount = (account) => ( const renderAccount = (account: AccountEntity) => (
<a href='/' className='block py-2' onClick={(event) => handleSwitchAccount(event, account)} key={account.get('id')}> <a href='/' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
<Account account={account} showProfileHoverCard={false} /> <Account account={account} showProfileHoverCard={false} />
</a> </a>
); );
@ -103,17 +108,13 @@ const SidebarMenu = () => {
dispatch(fetchOwnAccounts()); dispatch(fetchOwnAccounts());
}, []); }, []);
if (!account) { if (!account) return null;
return null;
}
const acct = account.get('acct');
const classes = classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
});
return ( return (
<div className={classes}> <div className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
>
<div <div
className={classNames({ className={classNames({
'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true, 'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true,
@ -130,7 +131,7 @@ const SidebarMenu = () => {
<HStack alignItems='center' justifyContent='between'> <HStack alignItems='center' justifyContent='between'>
<Link to='/' onClick={onClose}> <Link to='/' onClick={onClose}>
{logo ? ( {logo ? (
<img alt='Logo' src={logo} className='h-5 w-auto min-w-[140px] cursor-pointer' /> <img alt='Logo' src={logo} className='h-5 w-auto cursor-pointer' />
): ( ): (
<Icon <Icon
alt='Logo' alt='Logo'
@ -150,10 +151,11 @@ const SidebarMenu = () => {
</HStack> </HStack>
<Stack space={1}> <Stack space={1}>
<Link to={`/@${acct}`} onClick={onClose}> <Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} /> <Account account={account} showProfileHoverCard={false} />
</Link> </Link>
{/* TODO: make this available to everyone */}
{account.staff && ( {account.staff && (
<Stack> <Stack>
<button type='button' onClick={handleSwitcherClick} className='py-1'> <button type='button' onClick={handleSwitcherClick} className='py-1'>
@ -184,12 +186,34 @@ const SidebarMenu = () => {
<hr /> <hr />
<SidebarLink <SidebarLink
to={`/@${acct}`} to={`/@${account.acct}`}
icon={require('@tabler/icons/icons/user.svg')} icon={require('@tabler/icons/icons/user.svg')}
text={intl.formatMessage(messages.profile)} text={intl.formatMessage(messages.profile)}
onClick={onClose} onClick={onClose}
/> />
{(features.localTimeline || features.publicTimeline) && (
<hr className='dark:border-slate-700' />
)}
{features.localTimeline && (
<SidebarLink
to='/timeline/local'
icon={features.federating ? require('@tabler/icons/icons/users.svg') : require('@tabler/icons/icons/world.svg')}
text={features.federating ? instance.title : <FormattedMessage id='tabs_bar.all' defaultMessage='All' />}
onClick={onClose}
/>
)}
{(features.publicTimeline && features.federating) && (
<SidebarLink
to='/timeline/fediverse'
icon={require('icons/fediverse.svg')}
text={<FormattedMessage id='tabs_bar.fediverse' defaultMessage='Fediverse' />}
onClick={onClose}
/>
)}
<hr /> <hr />
<SidebarLink <SidebarLink

Wyświetl plik

@ -6,8 +6,7 @@ import { connect } from 'react-redux';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts';
import EmojiSelector from 'soapbox/components/emoji_selector'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import Hoverable from 'soapbox/components/hoverable';
import StatusActionButton from 'soapbox/components/status-action-button'; import StatusActionButton from 'soapbox/components/status-action-button';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { isUserTouching } from 'soapbox/is_mobile'; import { isUserTouching } from 'soapbox/is_mobile';
@ -554,7 +553,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
} }
render() { render() {
const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props; const { status, intl, allowedEmoji, features, me } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.visibility); const publicStatus = ['public', 'unlisted'].includes(status.visibility);
@ -641,24 +640,15 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
)} )}
{features.emojiReacts ? ( {features.emojiReacts ? (
<Hoverable <EmojiButtonWrapper statusId={status.id}>
component={(
<EmojiSelector
onReact={this.handleReact}
focused={emojiSelectorFocused}
onUnfocus={handleEmojiSelectorUnfocus}
/>
)}
>
<StatusActionButton <StatusActionButton
title={meEmojiTitle} title={meEmojiTitle}
icon={require('@tabler/icons/icons/thumb-up.svg')} icon={require('@tabler/icons/icons/thumb-up.svg')}
color='accent' color='accent'
onClick={this.handleLikeButtonClick}
active={Boolean(meEmojiReact)} active={Boolean(meEmojiReact)}
count={emojiReactCount} count={emojiReactCount}
/> />
</Hoverable> </EmojiButtonWrapper>
): ( ): (
<StatusActionButton <StatusActionButton
title={intl.formatMessage(messages.favourite)} title={intl.formatMessage(messages.favourite)}

Wyświetl plik

@ -19,7 +19,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
}; };
interface IEmojiSelector { interface IEmojiSelector {
emojis: string[], emojis: Iterable<string>,
onReact: (emoji: string) => void, onReact: (emoji: string) => void,
visible?: boolean, visible?: boolean,
focused?: boolean, focused?: boolean,
@ -40,7 +40,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = fa
space={2} space={2}
className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')} className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')}
> >
{emojis.map((emoji, i) => ( {Array.from(emojis).map((emoji, i) => (
<EmojiButton <EmojiButton
key={i} key={i}
emoji={emoji} emoji={emoji}

Wyświetl plik

@ -30,7 +30,7 @@ const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, size = 24, className }): JSX.El
loader={loader} loader={loader}
data-testid='svg-icon' data-testid='svg-icon'
> >
/* If the fetch fails, fall back to displaying the loader */ {/* If the fetch fails, fall back to displaying the loader */}
{loader} {loader}
</InlineSVG> </InlineSVG>
); );

Wyświetl plik

@ -1,12 +1,13 @@
/** /**
* Functions for dealing with custom build configuration. * Functions for dealing with custom build configuration.
*/ */
import { NODE_ENV } from 'soapbox/build_config'; import * as BuildConfig from 'soapbox/build_config';
/** Require a custom JSON file if it exists */ /** Require a custom JSON file if it exists */
export const custom = (filename, fallback = {}) => { export const custom = (filename: string, fallback: any = {}): any => {
if (NODE_ENV === 'test') return fallback; if (BuildConfig.NODE_ENV === 'test') return fallback;
// @ts-ignore: yes it does
const context = require.context('custom', false, /\.json$/); const context = require.context('custom', false, /\.json$/);
const path = `./${filename}.json`; const path = `./${filename}.json`;

Wyświetl plik

@ -4,6 +4,7 @@ import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import { isUserTouching } from 'soapbox/is_mobile'; import { isUserTouching } from 'soapbox/is_mobile';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
@ -574,19 +575,36 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
{reblogButton} {reblogButton}
<IconButton {features.emojiReacts ? (
className={classNames({ <EmojiButtonWrapper statusId={status.id}>
'text-gray-400 hover:text-gray-600': !meEmojiReact, <IconButton
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact), className={classNames({
})} 'text-gray-400 hover:text-gray-600': !meEmojiReact,
title={meEmojiTitle} 'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
src={require('@tabler/icons/icons/heart.svg')} })}
iconClassName={classNames({ title={meEmojiTitle}
'fill-accent-300': Boolean(meEmojiReact), src={require('@tabler/icons/icons/heart.svg')}
})} iconClassName={classNames({
text={meEmojiTitle} 'fill-accent-300': Boolean(meEmojiReact),
onClick={this.handleLikeButtonClick} })}
/> text={meEmojiTitle}
/>
</EmojiButtonWrapper>
) : (
<IconButton
className={classNames({
'text-gray-400 hover:text-gray-600': !meEmojiReact,
'text-accent-300 hover:text-accent-300': Boolean(meEmojiReact),
})}
title={meEmojiTitle}
src={require('@tabler/icons/icons/heart.svg')}
iconClassName={classNames({
'fill-accent-300': Boolean(meEmojiReact),
})}
text={meEmojiTitle}
onClick={this.handleLikeButtonClick}
/>
)}
{canShare && ( {canShare && (
<IconButton <IconButton

Wyświetl plik

@ -0,0 +1,122 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import WhoToFollowPanel from '../who-to-follow-panel';
describe('<WhoToFollow />', () => {
it('renders suggested accounts', () => {
const store = {
accounts: ImmutableMap({
'1': ImmutableMap({
id: '1',
acct: 'username',
display_name_html: 'My name',
avatar: 'test.jpg',
}),
}),
suggestions: ImmutableMap({
items: fromJS([{
source: 'staff',
account: '1',
}]),
}),
};
render(<WhoToFollowPanel limit={1} />, null, store);
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
it('renders multiple accounts', () => {
const store = {
accounts: ImmutableMap({
'1': ImmutableMap({
id: '1',
acct: 'username',
display_name_html: 'My name',
avatar: 'test.jpg',
}),
'2': ImmutableMap({
id: '1',
acct: 'username2',
display_name_html: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: ImmutableMap({
items: fromJS([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
}),
};
render(<WhoToFollowPanel limit={3} />, null, store);
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
it('respects the limit prop', () => {
const store = {
accounts: ImmutableMap({
'1': ImmutableMap({
id: '1',
acct: 'username',
display_name_html: 'My name',
avatar: 'test.jpg',
}),
'2': ImmutableMap({
id: '1',
acct: 'username2',
display_name_html: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: ImmutableMap({
items: fromJS([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
}),
};
render(<WhoToFollowPanel limit={1} />, null, store);
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
it('renders empty', () => {
const store = {
accounts: ImmutableMap({
'1': ImmutableMap({
id: '1',
acct: 'username',
display_name_html: 'My name',
avatar: 'test.jpg',
}),
'2': ImmutableMap({
id: '1',
acct: 'username2',
display_name_html: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: ImmutableMap({
items: fromJS([]),
}),
};
render(<WhoToFollowPanel limit={1} />, null, store);
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});

Wyświetl plik

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { Avatar, Button, Icon } from 'soapbox/components/ui'; import { Avatar, Button, Icon } from 'soapbox/components/ui';
import Search from 'soapbox/features/compose/components/search'; import Search from 'soapbox/features/compose/components/search';
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle'; import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
import { useOwnAccount, useSoapboxConfig, useSettings } from 'soapbox/hooks'; import { useOwnAccount, useSoapboxConfig, useSettings, useFeatures } from 'soapbox/hooks';
import { openSidebar } from '../../../actions/sidebar'; import { openSidebar } from '../../../actions/sidebar';
@ -19,6 +19,7 @@ const Navbar = () => {
const account = useOwnAccount(); const account = useOwnAccount();
const settings = useSettings(); const settings = useSettings();
const features = useFeatures();
const soapboxConfig = useSoapboxConfig(); const soapboxConfig = useSoapboxConfig();
const singleUserMode = soapboxConfig.get('singleUserMode'); const singleUserMode = soapboxConfig.get('singleUserMode');
@ -68,7 +69,8 @@ const Navbar = () => {
</div> </div>
<div className='absolute inset-y-0 right-0 flex items-center pr-2 lg:static lg:inset-auto lg:ml-6 lg:pr-0 space-x-3'> <div className='absolute inset-y-0 right-0 flex items-center pr-2 lg:static lg:inset-auto lg:ml-6 lg:pr-0 space-x-3'>
{settings.get('isDeveloper') && ( {/* TODO: make this available for everyone when it's ready (possibly in a different place) */}
{(features.darkMode || settings.get('isDeveloper')) && (
<ThemeToggle /> <ThemeToggle />
)} )}

Wyświetl plik

@ -37,8 +37,8 @@ function ThemeToggle({ showLabel }: IThemeToggle) {
id={id} id={id}
checked={themeMode === 'light'} checked={themeMode === 'light'}
icons={{ icons={{
checked: <Icon src={require('@tabler/icons/icons/sun.svg')} />, checked: <Icon className='w-4 h-4' src={require('@tabler/icons/icons/sun.svg')} />,
unchecked: <Icon src={require('@tabler/icons/icons/moon.svg')} />, unchecked: <Icon className='w-4 h-4' src={require('@tabler/icons/icons/moon.svg')} />,
}} }}
onChange={onToggle} onChange={onToggle}
/> />

Wyświetl plik

@ -229,8 +229,10 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} /> <WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} />
// NOTE: we cannot nest routes in a fragment {/*
// https://stackoverflow.com/a/68637108 NOTE: we cannot nest routes in a fragment
https://stackoverflow.com/a/68637108
*/}
{features.federating && <WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} publicRoute />} {features.federating && <WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} publicRoute />}
{features.federating && <WrappedRoute path='/timeline/fediverse' exact page={HomePage} component={PublicTimeline} content={children} publicRoute />} {features.federating && <WrappedRoute path='/timeline/fediverse' exact page={HomePage} component={PublicTimeline} content={children} publicRoute />}
{features.federating && <WrappedRoute path='/timeline/:instance' exact page={RemoteInstancePage} component={RemoteTimeline} content={children} />} {features.federating && <WrappedRoute path='/timeline/:instance' exact page={RemoteInstancePage} component={RemoteTimeline} content={children} />}

Wyświetl plik

@ -2,7 +2,8 @@ import { Map as ImmutableMap } from 'immutable';
import React from 'react'; import React from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { __stub } from '../../../__mocks__/api'; import { __stub } from 'soapbox/api';
import { render, screen } from '../../../jest/test-helpers'; import { render, screen } from '../../../jest/test-helpers';
import Verification from '../index'; import Verification from '../index';

Wyświetl plik

@ -2,6 +2,6 @@ import reducer from '../sidebar';
describe('sidebar reducer', () => { describe('sidebar reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual({}); expect(reducer(undefined, {})).toEqual({ sidebarOpen: false });
}); });
}); });

Wyświetl plik

@ -1,6 +1,16 @@
import { SIDEBAR_OPEN, SIDEBAR_CLOSE } from '../actions/sidebar'; import { SIDEBAR_OPEN, SIDEBAR_CLOSE } from '../actions/sidebar';
export default function sidebar(state={}, action) { import type { AnyAction } from 'redux';
type State = {
sidebarOpen: boolean,
};
const initialState: State = {
sidebarOpen: false,
};
export default function sidebar(state: State = initialState, action: AnyAction): State {
switch(action.type) { switch(action.type) {
case SIDEBAR_OPEN: case SIDEBAR_OPEN:
return { sidebarOpen: true }; return { sidebarOpen: true };

Wyświetl plik

@ -68,6 +68,14 @@ const getInstanceFeatures = (instance: Instance) => {
// Even though Pleroma supports these endpoints, it has disadvantages // Even though Pleroma supports these endpoints, it has disadvantages
// v.software === PLEROMA && gte(v.version, '2.1.0'), // v.software === PLEROMA && gte(v.version, '2.1.0'),
]), ]),
localTimeline: any([
v.software === MASTODON,
v.software === PLEROMA,
]),
publicTimeline: any([
v.software === MASTODON,
v.software === PLEROMA,
]),
directTimeline: any([ directTimeline: any([
v.software === MASTODON && lt(v.compatVersion, '3.0.0'), v.software === MASTODON && lt(v.compatVersion, '3.0.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'), v.software === PLEROMA && gte(v.version, '0.9.9'),
@ -134,6 +142,10 @@ const getInstanceFeatures = (instance: Instance) => {
trendingTruths: v.software === TRUTHSOCIAL, trendingTruths: v.software === TRUTHSOCIAL,
trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'), trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
pepe: v.software === TRUTHSOCIAL, pepe: v.software === TRUTHSOCIAL,
// FIXME: long-term this shouldn't be a feature,
// but for now we want it to be overrideable in the build
darkMode: true,
}; };
}; };

Wyświetl plik

@ -27,8 +27,11 @@
} }
} }
.svg-icon { .react-toggle-track {
width: 18px; @apply dark:bg-slate-600;
height: 18px; }
.react-toggle-thumb {
@apply dark:bg-slate-900 dark:border-slate-800;
} }
} }

Wyświetl plik

@ -38,6 +38,34 @@ For example:
See `app/soapbox/utils/features.js` for the full list of features. See `app/soapbox/utils/features.js` for the full list of features.
### Embedded app (`custom/app.json`)
By default, Soapbox will create a new OAuth app every time a user tries to register or log in.
This is usually the desired behavior, as it works "out of the box" without any additional configuration, and it is resistant to tampering and subtle client bugs.
However, some larger servers may wish to skip this step for performance reasons.
If an app is supplied in `custom/app.json`, it will be used for authorization.
The full app entity must be provided, for example:
```json
{
"client_id": "cf5yI6ffXH1UcDkEApEIrtHpwCi5Tv9xmju8IKdMAkE",
"client_secret": "vHmSDpm6BJGUvR4_qWzmqWjfHcSYlZumxpFfohRwNNQ",
"id": "7132",
"name": "Soapbox FE",
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"website": "https://soapbox.pub/",
"vapid_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4"
}
```
It is crucial that the app has the expected scopes.
You can obtain one with the following curl command (replace `MY_DOMAIN`):
```sh
curl -X POST -H "Content-Type: application/json" -d '{"client_name": "Soapbox FE", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow push admin", "website": "https://soapbox.pub/"}' "https://MY_DOMAIN.com/api/v1/apps"
```
### Custom files (`custom/instance/*`) ### Custom files (`custom/instance/*`)
You can place arbitrary files of any type in the `custom/instance/` directory. You can place arbitrary files of any type in the `custom/instance/` directory.

Wyświetl plik

@ -75,7 +75,7 @@ module.exports = {
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({
process: 'process/browser', process: 'process/browser',
}), }),
new ForkTsCheckerWebpackPlugin(), new ForkTsCheckerWebpackPlugin({ typescript: { memoryLimit: 8192 } }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'packs/css/[name]-[contenthash:8].css', filename: 'packs/css/[name]-[contenthash:8].css',
chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css', chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css',