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

This commit is contained in:
marcin mikołajczak 2022-04-14 00:05:21 +02:00
commit 44b64d51c4
26 changed files with 462 additions and 180 deletions

View File

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

View File

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

View File

@ -1 +1 @@
nodejs 14.17.6
nodejs 16.14.2

View File

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

View File

@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps';
import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import snackbar from 'soapbox/actions/snackbar';
import { custom } from 'soapbox/custom';
import KVStore from 'soapbox/storage/kv_store';
import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth';
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_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL';
const customApp = custom('app');
export const messages = defineMessages({
loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' },
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 instance = state.get('instance');
@ -54,12 +57,23 @@ const getScopes = state => {
function createAppAndToken() {
return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => {
return dispatch(getAuthApp()).then(() => {
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() {
return (dispatch, getState) => {
const params = {
@ -117,7 +131,7 @@ export function refreshUserToken() {
const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']);
const app = getState().getIn(['auth', 'app']);
if (!refreshToken) return dispatch(noOp());
if (!refreshToken) return dispatch(noOp);
const params = {
client_id: app.get('client_id'),
@ -200,7 +214,7 @@ export function loadCredentials(token, accountUrl) {
export function logIn(intl, username, password) {
return (dispatch, getState) => {
return dispatch(createAuthApp()).then(() => {
return dispatch(getAuthApp()).then(() => {
return dispatch(createUserToken(username, password));
}).catch(error => {
if (error.response.data.error === 'mfa_required') {

View File

@ -1,7 +1,8 @@
export const MODAL_OPEN = 'MODAL_OPEN';
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 {
type: MODAL_OPEN,
modalType: type,
@ -9,7 +10,8 @@ export function openModal(type, props) {
};
}
export function closeModal(type) {
/** Close the modal */
export function closeModal(type: string) {
return {
type: MODAL_CLOSE,
modalType: type,

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -1,23 +1,24 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import { logOut, switchAccount } from 'soapbox/actions/auth';
import { fetchOwnAccounts } from 'soapbox/actions/auth';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import Account from 'soapbox/components/account';
import { Stack } from 'soapbox/components/ui';
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 { makeGetAccount, makeGetOtherAccounts } from '../selectors';
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({
followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' },
@ -33,7 +34,14 @@ const messages = defineMessages({
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}>
<HStack space={2} alignItems='center'>
<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>
);
SidebarLink.propTypes = {
to: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const getOtherAccounts = makeGetOtherAccounts();
const SidebarMenu = () => {
const SidebarMenu: React.FC = (): JSX.Element | null => {
const intl = useIntl();
const dispatch = useDispatch();
const logo = useSelector((state) => getSoapboxConfig(state).get('logo'));
const features = useSelector((state) => getFeatures(state.get('instance')));
const { logo } = useSoapboxConfig();
const features = useFeatures();
const getAccount = makeGetAccount();
const getOtherAccounts = makeGetOtherAccounts();
const me = useSelector((state) => state.get('me'));
const account = useSelector((state) => getAccount(state, me));
const otherAccounts = useSelector((state) => getOtherAccounts(state));
const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen);
const instance = useAppSelector((state) => state.instance);
const me = useAppSelector((state) => state.me);
const account = useAppSelector((state) => me ? getAccount(state, me) : null);
const otherAccounts: ImmutableList<AccountEntity> = useAppSelector((state) => getOtherAccounts(state));
const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen);
const closeButtonRef = React.useRef(null);
@ -76,25 +79,27 @@ const SidebarMenu = () => {
onClose();
};
const handleSwitchAccount = (event, account) => {
event.preventDefault();
switchAccount(account);
dispatch(switchAccount(account.get('id')));
const handleSwitchAccount = (account: AccountEntity): React.EventHandler<React.MouseEvent> => {
return (e) => {
e.preventDefault();
switchAccount(account);
dispatch(switchAccount(account.id));
};
};
const onClickLogOut = (event) => {
event.preventDefault();
const onClickLogOut: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
dispatch(logOut(intl));
};
const handleSwitcherClick = (e) => {
const handleSwitcherClick: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
setSwitcher((prevState) => (!prevState));
};
const renderAccount = (account) => (
<a href='/' className='block py-2' onClick={(event) => handleSwitchAccount(event, account)} key={account.get('id')}>
const renderAccount = (account: AccountEntity) => (
<a href='/' className='block py-2' onClick={handleSwitchAccount(account)} key={account.id}>
<Account account={account} showProfileHoverCard={false} />
</a>
);
@ -103,17 +108,13 @@ const SidebarMenu = () => {
dispatch(fetchOwnAccounts());
}, []);
if (!account) {
return null;
}
const acct = account.get('acct');
const classes = classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
});
if (!account) return null;
return (
<div className={classes}>
<div className={classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
})}
>
<div
className={classNames({
'fixed inset-0 bg-gray-600 bg-opacity-90 z-1000': true,
@ -130,7 +131,7 @@ const SidebarMenu = () => {
<HStack alignItems='center' justifyContent='between'>
<Link to='/' onClick={onClose}>
{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
alt='Logo'
@ -150,10 +151,11 @@ const SidebarMenu = () => {
</HStack>
<Stack space={1}>
<Link to={`/@${acct}`} onClick={onClose}>
<Link to={`/@${account.acct}`} onClick={onClose}>
<Account account={account} showProfileHoverCard={false} />
</Link>
{/* TODO: make this available to everyone */}
{account.staff && (
<Stack>
<button type='button' onClick={handleSwitcherClick} className='py-1'>
@ -184,12 +186,34 @@ const SidebarMenu = () => {
<hr />
<SidebarLink
to={`/@${acct}`}
to={`/@${account.acct}`}
icon={require('@tabler/icons/icons/user.svg')}
text={intl.formatMessage(messages.profile)}
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 />
<SidebarLink

View File

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

View File

@ -19,7 +19,7 @@ const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabInd
};
interface IEmojiSelector {
emojis: string[],
emojis: Iterable<string>,
onReact: (emoji: string) => void,
visible?: boolean,
focused?: boolean,
@ -40,7 +40,7 @@ const EmojiSelector: React.FC<IEmojiSelector> = ({ emojis, onReact, visible = fa
space={2}
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
key={i}
emoji={emoji}

View File

@ -30,7 +30,7 @@ const SvgIcon: React.FC<ISvgIcon> = ({ src, alt, size = 24, className }): JSX.El
loader={loader}
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}
</InlineSVG>
);

View File

@ -1,12 +1,13 @@
/**
* 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 */
export const custom = (filename, fallback = {}) => {
if (NODE_ENV === 'test') return fallback;
export const custom = (filename: string, fallback: any = {}): any => {
if (BuildConfig.NODE_ENV === 'test') return fallback;
// @ts-ignore: yes it does
const context = require.context('custom', false, /\.json$/);
const path = `./${filename}.json`;

View File

@ -4,6 +4,7 @@ import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps
import { connect } from 'react-redux';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
import { isUserTouching } from 'soapbox/is_mobile';
import { getReactForStatus } from 'soapbox/utils/emoji_reacts';
import { getFeatures } from 'soapbox/utils/features';
@ -574,19 +575,36 @@ class ActionBar extends React.PureComponent<IActionBar, IActionBarState> {
{reblogButton}
<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}
/>
{features.emojiReacts ? (
<EmojiButtonWrapper statusId={status.id}>
<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}
/>
</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 && (
<IconButton

View File

@ -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);
});
});

View File

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { Avatar, Button, Icon } from 'soapbox/components/ui';
import Search from 'soapbox/features/compose/components/search';
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';
@ -19,6 +19,7 @@ const Navbar = () => {
const account = useOwnAccount();
const settings = useSettings();
const features = useFeatures();
const soapboxConfig = useSoapboxConfig();
const singleUserMode = soapboxConfig.get('singleUserMode');
@ -68,7 +69,8 @@ const Navbar = () => {
</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'>
{settings.get('isDeveloper') && (
{/* TODO: make this available for everyone when it's ready (possibly in a different place) */}
{(features.darkMode || settings.get('isDeveloper')) && (
<ThemeToggle />
)}

View File

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

View File

@ -229,8 +229,10 @@ class SwitchingColumnsArea extends React.PureComponent {
<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/fediverse' exact page={HomePage} component={PublicTimeline} content={children} publicRoute />}
{features.federating && <WrappedRoute path='/timeline/:instance' exact page={RemoteInstancePage} component={RemoteTimeline} content={children} />}

View File

@ -2,7 +2,8 @@ import { Map as ImmutableMap } from 'immutable';
import React from 'react';
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 Verification from '../index';

View File

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

View File

@ -1,6 +1,16 @@
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) {
case SIDEBAR_OPEN:
return { sidebarOpen: true };

View File

@ -68,6 +68,14 @@ const getInstanceFeatures = (instance: Instance) => {
// Even though Pleroma supports these endpoints, it has disadvantages
// 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([
v.software === MASTODON && lt(v.compatVersion, '3.0.0'),
v.software === PLEROMA && gte(v.version, '0.9.9'),
@ -134,6 +142,10 @@ const getInstanceFeatures = (instance: Instance) => {
trendingTruths: v.software === TRUTHSOCIAL,
trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
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,
};
};

View File

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

View File

@ -38,6 +38,34 @@ For example:
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/*`)
You can place arbitrary files of any type in the `custom/instance/` directory.

View File

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