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-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',

Wyświetl plik

@ -1,4 +1,4 @@
image: node:14
image: node:16
variables:
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 = [];
const setupMock = (axios: AxiosInstance) => {
const mock = new MockAdapter(axios);
const mock = new MockAdapter(axios, { onNoMatch: 'throwException' });
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 { 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') {

Wyświetl plik

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

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
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 && (

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

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 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 />
)}

Wyświetl plik

@ -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}
/>

Wyświetl plik

@ -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} />}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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',