diff --git a/.eslintrc.js b/.eslintrc.js index 9a92e50a8..d885cbeea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 546e3810e..0d140029f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: node:14 +image: node:16 variables: NODE_ENV: test diff --git a/.tool-versions b/.tool-versions index 2d8169e51..009455657 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 14.17.6 +nodejs 16.14.2 diff --git a/app/soapbox/__mocks__/api.ts b/app/soapbox/__mocks__/api.ts index 2f3a0d7b8..99797009e 100644 --- a/app/soapbox/__mocks__/api.ts +++ b/app/soapbox/__mocks__/api.ts @@ -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)); }; diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 1a700b9ec..5c0fc467a 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -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') { diff --git a/app/soapbox/actions/modals.js b/app/soapbox/actions/modals.ts similarity index 59% rename from app/soapbox/actions/modals.js rename to app/soapbox/actions/modals.ts index 72604ecc6..9d6e85139 100644 --- a/app/soapbox/actions/modals.js +++ b/app/soapbox/actions/modals.ts @@ -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, diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx new file mode 100644 index 000000000..32159b329 --- /dev/null +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -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 = ({ 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(null); + const popperRef = useRef(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 = 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 = () => { + // setFocused(false); + // }; + + const selector = ( +
+ +
+ ); + + return ( +
+ {React.cloneElement(children, { + onClick: handleClick, + ref, + })} + + {selector} +
+ ); +}; + +export default EmojiButtonWrapper; diff --git a/app/soapbox/components/hoverable.tsx b/app/soapbox/components/hoverable.tsx deleted file mode 100644 index 751c413c1..000000000 --- a/app/soapbox/components/hoverable.tsx +++ /dev/null @@ -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 = ({ - component, - children, -}): JSX.Element => { - - const [portalActive, setPortalActive] = useState(false); - - const ref = useRef(null); - const popperRef = useRef(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 ( -
- {children} - -
- {component} -
-
- ); -}; - -export default Hoverable; diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index 0898279f9..b15b2d72a 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -27,7 +27,7 @@ const SidebarNavigation = () => { } + text={} /> {account && ( @@ -42,7 +42,7 @@ const SidebarNavigation = () => { to='/notifications' icon={require('icons/alert.svg')} count={notificationCount} - text={} + text={} /> { /> )} - {/* {features.federating ? ( - - - {instance.title} - - ) : ( - - - - + {(features.localTimeline || features.publicTimeline) && ( +
)} - {features.federating && ( - - - - - )} */} + {features.localTimeline && ( + } + /> + )} + + {(features.publicTimeline && features.federating) && ( + } + /> + )} {account && ( diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.tsx similarity index 73% rename from app/soapbox/components/sidebar_menu.js rename to app/soapbox/components/sidebar_menu.tsx index bba7d06ce..c31ec29db 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.tsx @@ -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, +} + +const SidebarLink: React.FC = ({ to, icon, text, onClick }) => (
@@ -45,25 +53,20 @@ const SidebarLink = ({ to, icon, text, onClick }) => ( ); -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 = 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 => { + return (e) => { + e.preventDefault(); + switchAccount(account); + dispatch(switchAccount(account.id)); + }; }; - const onClickLogOut = (event) => { - event.preventDefault(); + const onClickLogOut: React.EventHandler = (e) => { + e.preventDefault(); dispatch(logOut(intl)); }; - const handleSwitcherClick = (e) => { + const handleSwitcherClick: React.EventHandler = (e) => { e.preventDefault(); setSwitcher((prevState) => (!prevState)); }; - const renderAccount = (account) => ( - handleSwitchAccount(event, account)} key={account.get('id')}> + const renderAccount = (account: AccountEntity) => ( + ); @@ -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 ( -
+
{ {logo ? ( - Logo + Logo ): ( { - + + {/* TODO: make this available to everyone */} {account.staff && (
- {settings.get('isDeveloper') && ( + {/* TODO: make this available for everyone when it's ready (possibly in a different place) */} + {(features.darkMode || settings.get('isDeveloper')) && ( )} diff --git a/app/soapbox/features/ui/components/theme_toggle.tsx b/app/soapbox/features/ui/components/theme_toggle.tsx index 5f04be848..e8c63c60f 100644 --- a/app/soapbox/features/ui/components/theme_toggle.tsx +++ b/app/soapbox/features/ui/components/theme_toggle.tsx @@ -37,8 +37,8 @@ function ThemeToggle({ showLabel }: IThemeToggle) { id={id} checked={themeMode === 'light'} icons={{ - checked: , - unchecked: , + checked: , + unchecked: , }} onChange={onToggle} /> diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 465502846..7a35dc488 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -229,8 +229,10 @@ class SwitchingColumnsArea extends React.PureComponent { - // 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 && } {features.federating && } {features.federating && } diff --git a/app/soapbox/features/verification/__tests__/index.test.tsx b/app/soapbox/features/verification/__tests__/index.test.tsx index b4c28509e..27a88957e 100644 --- a/app/soapbox/features/verification/__tests__/index.test.tsx +++ b/app/soapbox/features/verification/__tests__/index.test.tsx @@ -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'; diff --git a/app/soapbox/reducers/__tests__/sidebar-test.js b/app/soapbox/reducers/__tests__/sidebar-test.js index 09cbc8d21..511325488 100644 --- a/app/soapbox/reducers/__tests__/sidebar-test.js +++ b/app/soapbox/reducers/__tests__/sidebar-test.js @@ -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 }); }); }); diff --git a/app/soapbox/reducers/sidebar.js b/app/soapbox/reducers/sidebar.ts similarity index 50% rename from app/soapbox/reducers/sidebar.js rename to app/soapbox/reducers/sidebar.ts index 131411c2e..7b05e9c76 100644 --- a/app/soapbox/reducers/sidebar.js +++ b/app/soapbox/reducers/sidebar.ts @@ -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 }; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index e1c5d12e0..94c0da574 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -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, }; }; diff --git a/app/styles/components/theme-toggle.scss b/app/styles/components/theme-toggle.scss index 5b59d80b1..112c14972 100644 --- a/app/styles/components/theme-toggle.scss +++ b/app/styles/components/theme-toggle.scss @@ -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; } } diff --git a/docs/development/build-config.md b/docs/development/build-config.md index 0d7f44b99..4467dbfe0 100644 --- a/docs/development/build-config.md +++ b/docs/development/build-config.md @@ -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. diff --git a/webpack/shared.js b/webpack/shared.js index 7a2822b7e..0ccd628cb 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -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',