From c19fe9f1672d3f7021b54298794f1e5fd9786cff Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 10:42:05 -0500 Subject: [PATCH 01/23] Strip leading @ from password reset input --- app/soapbox/actions/auth.ts | 11 +---------- app/soapbox/actions/security.ts | 8 +++++--- app/soapbox/utils/__tests__/input.test.ts | 7 +++++++ app/soapbox/utils/input.ts | 13 +++++++++++++ 4 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 app/soapbox/utils/__tests__/input.test.ts create mode 100644 app/soapbox/utils/input.ts diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index d3252e7fb..5a686d045 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -20,6 +20,7 @@ import KVStore from 'soapbox/storage/kv_store'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; import { getFeatures } from 'soapbox/utils/features'; +import { normalizeUsername } from 'soapbox/utils/input'; import { isStandalone } from 'soapbox/utils/state'; import api, { baseClient } from '../api'; @@ -207,16 +208,6 @@ export const loadCredentials = (token: string, accountUrl: string) => }) .catch(() => dispatch(verifyCredentials(token, accountUrl))); -/** Trim the username and strip the leading @. */ -const normalizeUsername = (username: string): string => { - const trimmed = username.trim(); - if (trimmed[0] === '@') { - return trimmed.slice(1); - } else { - return trimmed; - } -}; - export const logIn = (username: string, password: string) => (dispatch: AppDispatch) => dispatch(getAuthApp()).then(() => { return dispatch(createUserToken(normalizeUsername(username), password)); diff --git a/app/soapbox/actions/security.ts b/app/soapbox/actions/security.ts index 430691a06..196e54dcb 100644 --- a/app/soapbox/actions/security.ts +++ b/app/soapbox/actions/security.ts @@ -7,6 +7,7 @@ import snackbar from 'soapbox/actions/snackbar'; import { getLoggedInAccount } from 'soapbox/utils/auth'; import { parseVersion, TRUTHSOCIAL } from 'soapbox/utils/features'; +import { normalizeUsername } from 'soapbox/utils/input'; import api from '../api'; @@ -84,15 +85,16 @@ const changePassword = (oldPassword: string, newPassword: string, confirmation: const resetPassword = (usernameOrEmail: string) => (dispatch: AppDispatch, getState: () => RootState) => { + const input = normalizeUsername(usernameOrEmail); const state = getState(); const v = parseVersion(state.instance.version); dispatch({ type: RESET_PASSWORD_REQUEST }); const params = - usernameOrEmail.includes('@') - ? { email: usernameOrEmail } - : { nickname: usernameOrEmail, username: usernameOrEmail }; + input.includes('@') + ? { email: input } + : { nickname: input, username: input }; const endpoint = v.software === TRUTHSOCIAL diff --git a/app/soapbox/utils/__tests__/input.test.ts b/app/soapbox/utils/__tests__/input.test.ts new file mode 100644 index 000000000..819a8fd82 --- /dev/null +++ b/app/soapbox/utils/__tests__/input.test.ts @@ -0,0 +1,7 @@ +import { normalizeUsername } from '../input'; + +test('normalizeUsername', () => { + expect(normalizeUsername('@alex')).toBe('alex'); + expect(normalizeUsername('alex@alexgleason.me')).toBe('alex@alexgleason.me'); + expect(normalizeUsername('@alex@gleasonator.com')).toBe('alex@gleasonator.com'); +}); \ No newline at end of file diff --git a/app/soapbox/utils/input.ts b/app/soapbox/utils/input.ts new file mode 100644 index 000000000..e9d8c2d85 --- /dev/null +++ b/app/soapbox/utils/input.ts @@ -0,0 +1,13 @@ +/** Trim the username and strip the leading @. */ +const normalizeUsername = (username: string): string => { + const trimmed = username.trim(); + if (trimmed[0] === '@') { + return trimmed.slice(1); + } else { + return trimmed; + } +}; + +export { + normalizeUsername, +}; \ No newline at end of file From 2eb08aced9a4314e7b3cf7cbc6cb8c2833c7c641 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 12:24:33 -0500 Subject: [PATCH 02/23] Create OutlineBox component Visual container for QuotedStatus and Accounts in some scenarios --- app/soapbox/components/outline-box.tsx | 18 +++++ app/soapbox/components/quoted-status.tsx | 66 ++++++++++--------- .../account-moderation-modal.tsx | 5 +- 3 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 app/soapbox/components/outline-box.tsx diff --git a/app/soapbox/components/outline-box.tsx b/app/soapbox/components/outline-box.tsx new file mode 100644 index 000000000..91ad1c57d --- /dev/null +++ b/app/soapbox/components/outline-box.tsx @@ -0,0 +1,18 @@ +import classNames from 'clsx'; +import React from 'react'; + +interface IOutlineBox { + children: React.ReactNode, + className?: string, +} + +/** Wraps children in a container with an outline. */ +const OutlineBox: React.FC = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +export default OutlineBox; \ No newline at end of file diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index a8809bedf..9a758225b 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -9,6 +9,8 @@ import AccountContainer from 'soapbox/containers/account_container'; import { useSettings } from 'soapbox/hooks'; import { defaultMediaVisibility } from 'soapbox/utils/status'; +import OutlineBox from './outline-box'; + import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ @@ -123,38 +125,40 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => } return ( - - - - {renderReplyMentions()} - - - - - + + + + {renderReplyMentions()} + + + + + + ); }; diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx index 8f91e1d3c..8c9f3df06 100644 --- a/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx @@ -13,6 +13,7 @@ import snackbar from 'soapbox/actions/snackbar'; import Account from 'soapbox/components/account'; import List, { ListItem } from 'soapbox/components/list'; import MissingIndicator from 'soapbox/components/missing_indicator'; +import OutlineBox from 'soapbox/components/outline-box'; import { Button, Text, HStack, Modal, Stack, Toggle } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; @@ -109,14 +110,14 @@ const AccountModerationModal: React.FC = ({ onClose, ac onClose={handleClose} > -
+ -
+ {(ownAccount.admin && isLocal(account)) && ( From 06a0ca704ccd53ca03c4537e37953e26b8c11e33 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 12:37:28 -0500 Subject: [PATCH 03/23] Improve styles of ConfirmationModal, fix account deletion modal --- app/soapbox/actions/moderation.tsx | 39 ++++++++++++------- .../ui/components/confirmation_modal.tsx | 32 +++++++-------- app/styles/components/modal.scss | 8 ---- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index ea5861eca..08f8fb9a2 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -9,6 +9,8 @@ import AccountContainer from 'soapbox/containers/account_container'; import { isLocal } from 'soapbox/utils/accounts'; import type { AppDispatch, RootState } from 'soapbox/store'; +import { Stack, Text } from 'soapbox/components/ui'; +import OutlineBox from 'soapbox/components/outline-box'; const messages = defineMessages({ deactivateUserHeading: { id: 'confirmations.admin.deactivate_user.heading', defaultMessage: 'Deactivate @{acct}' }, @@ -43,10 +45,22 @@ const deactivateUserModal = (intl: IntlShape, accountId: string, afterConfirm = const acct = state.accounts.get(accountId)!.acct; const name = state.accounts.get(accountId)!.username; + const message = ( + + + + + + + {intl.formatMessage(messages.deactivateUserPrompt, { acct })} + + + ); + dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/user-off.svg'), heading: intl.formatMessage(messages.deactivateUserHeading, { acct }), - message: intl.formatMessage(messages.deactivateUserPrompt, { acct }), + message, confirm: intl.formatMessage(messages.deactivateUserConfirm, { name }), onConfirm: () => { dispatch(deactivateUsers([accountId])).then(() => { @@ -64,22 +78,21 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () = const account = state.accounts.get(accountId)!; const acct = account.acct; const name = account.username; - const favicon = account.pleroma.get('favicon'); const local = isLocal(account); - const message = (<> - - {intl.formatMessage(messages.deleteUserPrompt, { acct })} - ); + const message = ( + + + + - const confirm = (<> - {favicon && -
- -
} - {intl.formatMessage(messages.deleteUserConfirm, { name })} - ); + + {intl.formatMessage(messages.deleteUserPrompt, { acct })} + +
+ ); + const confirm = intl.formatMessage(messages.deleteUserConfirm, { name }); const checkbox = local ? intl.formatMessage(messages.deleteLocalUserCheckbox) : false; dispatch(openModal('CONFIRM', { diff --git a/app/soapbox/features/ui/components/confirmation_modal.tsx b/app/soapbox/features/ui/components/confirmation_modal.tsx index fefe57396..f21478d9e 100644 --- a/app/soapbox/features/ui/components/confirmation_modal.tsx +++ b/app/soapbox/features/ui/components/confirmation_modal.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Modal, Text } from 'soapbox/components/ui'; -import { SimpleForm, FieldsGroup, Checkbox } from 'soapbox/features/forms'; +import List, { ListItem } from 'soapbox/components/list'; +import { Modal, Stack, Text, Toggle } from 'soapbox/components/ui'; interface IConfirmationModal { heading: React.ReactNode, @@ -60,23 +60,23 @@ const ConfirmationModal: React.FC = ({ secondaryText={secondary} secondaryAction={onSecondary && handleSecondary} > - - {message} - + + + {message} + -
- {checkbox &&
- - - + + - - -
} -
+ +
+ )} +
); }; diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss index 6d4e3d611..435c07c65 100644 --- a/app/styles/components/modal.scss +++ b/app/styles/components/modal.scss @@ -331,14 +331,6 @@ } } -.confirmation-modal__checkbox { - padding: 0 30px; - - .simple_form { - margin-top: -14px; - } -} - .reply-mentions-modal__accounts { display: block; flex-direction: row; From 7fe97e807599a8059ecfbca58acae1f34be4eed0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 12:44:11 -0500 Subject: [PATCH 04/23] QuotedStatus: move testId to highest component --- app/soapbox/components/quoted-status.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index 9a758225b..d7ecfaf05 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -125,12 +125,13 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => } return ( - From d3693fe89a164852989f08f2fdc21d4f91631db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 16 Sep 2022 20:18:12 +0200 Subject: [PATCH 05/23] Don't accept 'default' as id for ComposeForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/compose/components/compose-form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index d4b6a4406..35470c4cf 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -56,14 +56,14 @@ const messages = defineMessages({ saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, }); -interface IComposeForm { - id: string, +interface IComposeForm { + id: ID extends 'default' ? never : ID, shouldCondense?: boolean, autoFocus?: boolean, clickableAreaRef?: React.RefObject, } -const ComposeForm: React.FC = ({ id, shouldCondense, autoFocus, clickableAreaRef }) => { +const ComposeForm = ({ id, shouldCondense, autoFocus, clickableAreaRef }: IComposeForm) => { const history = useHistory(); const intl = useIntl(); const dispatch = useAppDispatch(); From 5297227501afe141a16eb1df39d719b043ca9fde Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 13:30:55 -0500 Subject: [PATCH 06/23] My god is the everlasting sky! Your god lives beneath him. --- app/soapbox/actions/moderation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/actions/moderation.tsx b/app/soapbox/actions/moderation.tsx index 08f8fb9a2..bf0ccf332 100644 --- a/app/soapbox/actions/moderation.tsx +++ b/app/soapbox/actions/moderation.tsx @@ -5,12 +5,12 @@ import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { openModal } from 'soapbox/actions/modals'; import snackbar from 'soapbox/actions/snackbar'; +import OutlineBox from 'soapbox/components/outline-box'; +import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import { isLocal } from 'soapbox/utils/accounts'; import type { AppDispatch, RootState } from 'soapbox/store'; -import { Stack, Text } from 'soapbox/components/ui'; -import OutlineBox from 'soapbox/components/outline-box'; const messages = defineMessages({ deactivateUserHeading: { id: 'confirmations.admin.deactivate_user.heading', defaultMessage: 'Deactivate @{acct}' }, From ca4a5370c15c5a475b09c60289bec96ae8bde755 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 16 Sep 2022 13:34:53 -0500 Subject: [PATCH 07/23] OutlineBox: allow passing div props through --- app/soapbox/components/outline-box.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/outline-box.tsx b/app/soapbox/components/outline-box.tsx index 91ad1c57d..41a13bf49 100644 --- a/app/soapbox/components/outline-box.tsx +++ b/app/soapbox/components/outline-box.tsx @@ -1,15 +1,18 @@ import classNames from 'clsx'; import React from 'react'; -interface IOutlineBox { +interface IOutlineBox extends React.HTMLAttributes { children: React.ReactNode, className?: string, } /** Wraps children in a container with an outline. */ -const OutlineBox: React.FC = ({ children, className }) => { +const OutlineBox: React.FC = ({ children, className, ...rest }) => { return ( -
+
{children}
); From 42d68bddf9cb65ede4af3e860039e023f82b4644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Sep 2022 09:29:36 +0200 Subject: [PATCH 08/23] handleDrop changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/ui/index.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index def4186f6..08d96e920 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -4,7 +4,6 @@ import debounce from 'lodash/debounce'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; import { defineMessages, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; @@ -26,7 +25,7 @@ import Icon from 'soapbox/components/icon'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import ThumbNavigation from 'soapbox/components/thumb_navigation'; import { Layout } from 'soapbox/components/ui'; -import { useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; import AdminPage from 'soapbox/pages/admin_page'; import DefaultPage from 'soapbox/pages/default_page'; // import GroupsPage from 'soapbox/pages/groups_page'; @@ -329,7 +328,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { const UI: React.FC = ({ children }) => { const intl = useIntl(); const history = useHistory(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const [draggingOver, setDraggingOver] = useState(false); const [mobile, setMobile] = useState(isMobile(window.innerWidth)); @@ -386,9 +385,13 @@ const UI: React.FC = ({ children }) => { setDraggingOver(false); dragTargets.current = []; - if (e.dataTransfer && e.dataTransfer.files.length >= 1) { - dispatch(uploadCompose('home', e.dataTransfer.files, intl)); - } + dispatch((_, getState) => { + if (e.dataTransfer && e.dataTransfer.files.length >= 1) { + const modals = getState().modals; + const isModalOpen = modals.last()?.modalType === 'COMPOSE'; + dispatch(uploadCompose(isModalOpen ? 'compose-modal' : 'home', e.dataTransfer.files, intl)); + } + }); }; const handleDragLeave = (e: DragEvent) => { From 5e7b78bbace8353f6a09f2981493187933d7d62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Sep 2022 21:13:02 +0200 Subject: [PATCH 09/23] Fix media upload with uninitialized compose forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/compose.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index ab4d47b97..d397f4026 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -323,11 +323,13 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; - const media = getState().compose.get(composeId)!.media_attachments; + const media = getState().compose.get(composeId)?.media_attachments; const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > attachmentLimit) { + const mediaCount = media ? media.size : 0; + + if (files.length + mediaCount > attachmentLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); return; } @@ -335,7 +337,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => dispatch(uploadComposeRequest(composeId)); Array.from(files).forEach(async(f, i) => { - if (media.size + i > attachmentLimit - 1) return; + if (mediaCount + i > attachmentLimit - 1) return; const isImage = f.type.match(/image.*/); const isVideo = f.type.match(/video.*/); From 6b57b62b3849df48bfb83552b34b216915175b0d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 19 Sep 2022 13:55:08 -0500 Subject: [PATCH 10/23] ComposeForm: don't focus the input constantly, add usePrevious hook --- .../features/compose/components/compose-form.tsx | 10 ++++++---- app/soapbox/hooks/index.ts | 1 + app/soapbox/hooks/usePrevious.ts | 13 +++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 app/soapbox/hooks/usePrevious.ts diff --git a/app/soapbox/features/compose/components/compose-form.tsx b/app/soapbox/features/compose/components/compose-form.tsx index 35470c4cf..b9968ad3e 100644 --- a/app/soapbox/features/compose/components/compose-form.tsx +++ b/app/soapbox/features/compose/components/compose-form.tsx @@ -18,7 +18,7 @@ import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea'; import Icon from 'soapbox/components/icon'; import { Button, Stack } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useCompose, useFeatures, usePrevious } from 'soapbox/hooks'; import { isMobile } from 'soapbox/is_mobile'; import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown'; @@ -76,6 +76,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab const features = useFeatures(); const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose; + const prevSpoiler = usePrevious(spoiler); const hasPoll = !!compose.poll; const isEditing = compose.id !== null; @@ -206,9 +207,10 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab }, []); useEffect(() => { - switch (spoiler) { - case true: focusSpoilerInput(); break; - case false: focusTextarea(); break; + if (spoiler && !prevSpoiler) { + focusSpoilerInput(); + } else if (!spoiler && prevSpoiler) { + focusTextarea(); } }, [spoiler]); diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index 82ce41321..32413dd8f 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -8,6 +8,7 @@ export { useFeatures } from './useFeatures'; export { useLocale } from './useLocale'; export { useOnScreen } from './useOnScreen'; export { useOwnAccount } from './useOwnAccount'; +export { usePrevious } from './usePrevious'; export { useRefEventHandler } from './useRefEventHandler'; export { useSettings } from './useSettings'; export { useSoapboxConfig } from './useSoapboxConfig'; diff --git a/app/soapbox/hooks/usePrevious.ts b/app/soapbox/hooks/usePrevious.ts new file mode 100644 index 000000000..89282d6f7 --- /dev/null +++ b/app/soapbox/hooks/usePrevious.ts @@ -0,0 +1,13 @@ +import { useRef, useEffect } from 'react'; + +/** Get the last version of this value. */ +// https://usehooks.com/usePrevious/ +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; \ No newline at end of file From 39f682a4727b26c6b84367033a5937ab750f5dc2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 19 Sep 2022 14:04:51 -0500 Subject: [PATCH 11/23] Self status typofix --- app/soapbox/components/media_gallery.js | 2 +- app/soapbox/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js index f8e9dc51e..22ed2473d 100644 --- a/app/soapbox/components/media_gallery.js +++ b/app/soapbox/components/media_gallery.js @@ -605,7 +605,7 @@ class MediaGallery extends React.PureComponent { } if (inReview) { - summary = ; + summary = ; } else { summary = ; } diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 7ce350976..4a9d1318f 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -990,7 +990,7 @@ "status.favourite": "Like", "status.filtered": "Filtered", "status.in_review_warning": "Content Under Review", - "status.in_review_summary.summary": "This Truth has been sent to Moderation for review and is only visible to you.", + "status.in_review_summary.summary": "This post has been sent to Moderation for review and is only visible to you.", "status.in_review_summary.contact": "If you believe this is in error please {link}.", "status.in_review_summary.link": "Contact Support", "status.load_more": "Load more", From 904e93bd8e1088449d48885d9f302eba109c78c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 22 Sep 2022 22:00:32 +0200 Subject: [PATCH 12/23] Fix ReplyMentionsModal crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/compose/components/reply_mentions.tsx | 4 +++- app/soapbox/features/ui/components/reply_mentions_modal.tsx | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/compose/components/reply_mentions.tsx b/app/soapbox/features/compose/components/reply_mentions.tsx index 577334281..865f6539d 100644 --- a/app/soapbox/features/compose/components/reply_mentions.tsx +++ b/app/soapbox/features/compose/components/reply_mentions.tsx @@ -36,7 +36,9 @@ const ReplyMentions: React.FC = ({ composeId }) => { const handleClick = (e: React.MouseEvent) => { e.preventDefault(); - dispatch(openModal('REPLY_MENTIONS')); + dispatch(openModal('REPLY_MENTIONS', { + composeId, + })); }; if (!parentTo || (parentTo.size === 0)) { diff --git a/app/soapbox/features/ui/components/reply_mentions_modal.tsx b/app/soapbox/features/ui/components/reply_mentions_modal.tsx index 43aaa24c4..2c8fc73b1 100644 --- a/app/soapbox/features/ui/components/reply_mentions_modal.tsx +++ b/app/soapbox/features/ui/components/reply_mentions_modal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import { Modal } from 'soapbox/components/ui'; @@ -18,7 +18,8 @@ interface IReplyMentionsModal { const ReplyMentionsModal: React.FC = ({ composeId, onClose }) => { const compose = useCompose(composeId); - const status = useAppSelector(state => makeGetStatus()(state, { id: compose.in_reply_to! })); + const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector(state => getStatus(state, { id: compose.in_reply_to! })); const account = useAppSelector((state) => state.accounts.get(state.me)); const mentions = statusToMentionsAccountIdsArray(status!, account!); From c1618026a79e5b7b1a447c4a9e9c254ca8bbef19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 23 Sep 2022 23:12:44 +0200 Subject: [PATCH 13/23] Fix share route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/share/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/share/index.tsx b/app/soapbox/features/share/index.tsx index 562f23689..fdb5caa3d 100644 --- a/app/soapbox/features/share/index.tsx +++ b/app/soapbox/features/share/index.tsx @@ -19,7 +19,7 @@ const Share = () => { .join('\n\n'); if (text) { - dispatch(openComposeWithText(text)); + dispatch(openComposeWithText('compose-modal', text)); } return ( @@ -27,4 +27,4 @@ const Share = () => { ); }; -export default Share; \ No newline at end of file +export default Share; From 1d69b66e4bdd12f6a00c411f2a41b200c931925e Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 26 Sep 2022 15:22:00 -0400 Subject: [PATCH 14/23] Use React Query for suggestions --- app/soapbox/components/ui/widget/widget.tsx | 2 +- .../placeholder-sidebar-suggestions.tsx | 29 +++++ .../ui/components/who-to-follow-panel.tsx | 58 ++++----- app/soapbox/pages/default_page.tsx | 2 +- app/soapbox/pages/home_page.tsx | 2 +- app/soapbox/pages/profile_page.tsx | 2 +- app/soapbox/pages/status_page.tsx | 2 +- app/soapbox/queries/suggestions.ts | 122 +++++++++++++++++- app/soapbox/utils/queries.ts | 61 +++++++++ 9 files changed, 241 insertions(+), 39 deletions(-) create mode 100644 app/soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx create mode 100644 app/soapbox/utils/queries.ts diff --git a/app/soapbox/components/ui/widget/widget.tsx b/app/soapbox/components/ui/widget/widget.tsx index 7b966a6ef..c4be654b3 100644 --- a/app/soapbox/components/ui/widget/widget.tsx +++ b/app/soapbox/components/ui/widget/widget.tsx @@ -42,7 +42,7 @@ const Widget: React.FC = ({ }): JSX.Element => { return ( - + {action || (onActionClick && ( { + const length = randomIntFromInterval(15, 3); + const acctLength = randomIntFromInterval(15, 3); + + return ( + <> + {new Array(limit).fill(undefined).map((_, idx) => ( + + +
+ + + +

{generateText(length)}

+

{generateText(acctLength)}

+
+ + ))} + + ); +}; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/who-to-follow-panel.tsx b/app/soapbox/features/ui/components/who-to-follow-panel.tsx index 2458997f4..418d2143c 100644 --- a/app/soapbox/features/ui/components/who-to-follow-panel.tsx +++ b/app/soapbox/features/ui/components/who-to-follow-panel.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { Link } from 'react-router-dom'; -import { fetchSuggestions, dismissSuggestion } from 'soapbox/actions/suggestions'; -import { Widget } from 'soapbox/components/ui'; +import { Text, Widget } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; -import { useAppSelector } from 'soapbox/hooks'; +import PlaceholderSidebarSuggestions from 'soapbox/features/placeholder/components/placeholder-sidebar-suggestions'; +import { useDismissSuggestion, useSuggestions } from 'soapbox/queries/suggestions'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -18,44 +18,40 @@ interface IWhoToFollowPanel { } const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => { - const dispatch = useDispatch(); const intl = useIntl(); - const suggestions = useAppSelector((state) => state.suggestions.items); + const { data: suggestions, isFetching } = useSuggestions(); + const dismissSuggestion = useDismissSuggestion(); + const suggestionsToRender = suggestions.slice(0, limit); const handleDismiss = (account: AccountEntity) => { - dispatch(dismissSuggestion(account.id)); + dismissSuggestion.mutate(account.id); }; - React.useEffect(() => { - dispatch(fetchSuggestions()); - }, []); - - if (suggestionsToRender.isEmpty()) { - return null; - } - - // FIXME: This page actually doesn't look good right now - // const handleAction = () => { - // history.push('/suggestions'); - // }; - return ( } - // onAction={handleAction} + action={ + + View all + + } > - {suggestionsToRender.map((suggestion) => ( - , but it isn't - id={suggestion.account} - actionIcon={require('@tabler/icons/x.svg')} - actionTitle={intl.formatMessage(messages.dismissSuggestion)} - onActionClick={handleDismiss} - /> - ))} + {isFetching ? ( + + ) : ( + suggestionsToRender.map((suggestion: any) => ( + , but it isn't + id={suggestion.account} + actionIcon={require('@tabler/icons/x.svg')} + actionTitle={intl.formatMessage(messages.dismissSuggestion)} + onActionClick={handleDismiss} + /> + )) + )} ); }; diff --git a/app/soapbox/pages/default_page.tsx b/app/soapbox/pages/default_page.tsx index b64cf452d..c013eb63d 100644 --- a/app/soapbox/pages/default_page.tsx +++ b/app/soapbox/pages/default_page.tsx @@ -41,7 +41,7 @@ const DefaultPage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx index 23bd3e3c7..dc8c2c9f4 100644 --- a/app/soapbox/pages/home_page.tsx +++ b/app/soapbox/pages/home_page.tsx @@ -105,7 +105,7 @@ const HomePage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/profile_page.tsx b/app/soapbox/pages/profile_page.tsx index f4ff974e0..9f57e23ab 100644 --- a/app/soapbox/pages/profile_page.tsx +++ b/app/soapbox/pages/profile_page.tsx @@ -139,7 +139,7 @@ const ProfilePage: React.FC = ({ params, children }) => { ) : features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/status_page.tsx b/app/soapbox/pages/status_page.tsx index 2c35947ad..414df6783 100644 --- a/app/soapbox/pages/status_page.tsx +++ b/app/soapbox/pages/status_page.tsx @@ -45,7 +45,7 @@ const StatusPage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts index 433a0c940..8c9e2ec20 100644 --- a/app/soapbox/queries/suggestions.ts +++ b/app/soapbox/queries/suggestions.ts @@ -1,9 +1,12 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation } from '@tanstack/react-query'; import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccounts } from 'soapbox/actions/importer'; +import { SuggestedProfile } from 'soapbox/actions/suggestions'; import { getLinks } from 'soapbox/api'; -import { useApi, useAppDispatch } from 'soapbox/hooks'; +import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; + +import { PaginatedResult, removePageItem } from '../utils/queries'; type Account = { acct: string @@ -35,7 +38,118 @@ type Suggestion = { account: Account } -export default function useOnboardingSuggestions() { +type TruthSuggestion = { + account_avatar: string + account_id: string + acct: string + display_name: string + note: string + verified: boolean +} + +type PageParam = { + link?: string +} + +const suggestionKeys = { + suggestions: ['suggestions'] as const, +}; + +const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({ + id: suggestedProfile.account_id, + avatar: suggestedProfile.account_avatar, + avatar_static: suggestedProfile.account_avatar, + acct: suggestedProfile.acct, + display_name: suggestedProfile.display_name, + note: suggestedProfile.note, + verified: suggestedProfile.verified, +}); + +const useSuggestions = () => { + const account = useOwnAccount(); + const api = useApi(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const getV2Suggestions = async(pageParam: PageParam): Promise> => { + const endpoint = pageParam?.link || '/api/v2/suggestions'; + const response = await api.get(endpoint); + const hasMore = !!response.headers.link; + const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = response.data.map(({ account }) => account); + const accountIds = accounts.map((account) => account.id); + dispatch(importFetchedAccounts(accounts)); + dispatch(fetchRelationships(accountIds)); + + return { + result: response.data, + link: nextLink, + hasMore, + }; + }; + + const getTruthSuggestions = async(pageParam: PageParam): Promise> => { + const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions'; + const response = await api.get(endpoint); + const hasMore = !!response.headers.link; + const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = response.data.map(mapSuggestedProfileToAccount); + dispatch(importFetchedAccounts(accounts, { should_refetch: true })); + + return { + result: response.data.map((x) => ({ ...x, account: x.account_id })), + link: nextLink, + hasMore, + }; + }; + + const getSuggestions = (pageParam: PageParam) => { + if (features.truthSuggestions) { + return getTruthSuggestions(pageParam); + } else { + return getV2Suggestions(pageParam); + } + }; + + const result = useInfiniteQuery( + suggestionKeys.suggestions, + ({ pageParam }: any) => getSuggestions(pageParam), + { + keepPreviousData: true, + enabled: !!account, + getNextPageParam: (config) => { + if (config?.hasMore) { + return { nextLink: config?.link }; + } + + return undefined; + }, + }); + + const data: any = result.data?.pages.reduce( + (prev: any, curr: any) => [...prev, ...curr.result], + [], + ); + + return { + ...result, + data: data || [], + }; +}; + +const useDismissSuggestion = () => { + const api = useApi(); + + return useMutation((accountId: string) => api.delete(`/api/v1/suggestions/${accountId}`), { + onMutate(accountId: string) { + removePageItem(suggestionKeys.suggestions, accountId, (o: any, n: any) => o.account_id === n); + }, + }); +}; + +function useOnboardingSuggestions() { const api = useApi(); const dispatch = useAppDispatch(); @@ -78,3 +192,5 @@ export default function useOnboardingSuggestions() { data, }; } + +export { useOnboardingSuggestions as default, useSuggestions, useDismissSuggestion }; \ No newline at end of file diff --git a/app/soapbox/utils/queries.ts b/app/soapbox/utils/queries.ts new file mode 100644 index 000000000..d066f0855 --- /dev/null +++ b/app/soapbox/utils/queries.ts @@ -0,0 +1,61 @@ +import { queryClient } from 'soapbox/queries/client'; + +import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query'; + +export interface PaginatedResult { + result: T[], + hasMore: boolean, + link?: string, +} + +/** Flatten paginated results into a single array. */ +const flattenPages = (queryInfo: UseInfiniteQueryResult>) => { + return queryInfo.data?.pages.reduce( + (prev: T[], curr) => [...prev, ...curr.result], + [], + ); +}; + +/** Traverse pages and update the item inside if found. */ +const updatePageItem = (queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(queryKey, (data) => { + if (data) { + const pages = data.pages.map(page => { + const result = page.result.map(item => isItem(item, newItem) ? newItem : item); + return { ...page, result }; + }); + return { ...data, pages }; + } + }); +}; + +/** Insert the new item at the beginning of the first page. */ +const appendPageItem = (queryKey: QueryKey, newItem: T) => { + queryClient.setQueryData>>(queryKey, (data) => { + if (data) { + const pages = [...data.pages]; + pages[0] = { ...pages[0], result: [...pages[0].result, newItem] }; + return { ...data, pages }; + } + }); +}; + +/** Remove an item inside if found. */ +const removePageItem = (queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(queryKey, (data) => { + if (data) { + const pages = data.pages.map(page => { + const result = page.result.filter(item => !isItem(item, itemToRemove)); + return { ...page, result }; + }); + return { ...data, pages }; + } + }); +}; + +export { + flattenPages, + updatePageItem, + appendPageItem, + removePageItem, +}; From 63bd9a21fc93db585c19619b4f5edc0363fc6c23 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 26 Sep 2022 15:23:51 -0400 Subject: [PATCH 15/23] Update spacing on Widgets --- app/soapbox/components/ui/widget/widget.tsx | 2 +- app/soapbox/features/feed-suggestions/feed-suggestions.tsx | 4 ++-- app/soapbox/features/follow-recommendations/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/ui/widget/widget.tsx b/app/soapbox/components/ui/widget/widget.tsx index c4be654b3..3bced193f 100644 --- a/app/soapbox/components/ui/widget/widget.tsx +++ b/app/soapbox/components/ui/widget/widget.tsx @@ -41,7 +41,7 @@ const Widget: React.FC = ({ action, }): JSX.Element => { return ( - + {action || (onActionClick && ( diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx index d946f5e8c..2545c72e5 100644 --- a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx +++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx @@ -11,7 +11,7 @@ import ActionButton from '../ui/components/action-button'; import type { Account } from 'soapbox/types/entities'; const messages = defineMessages({ - heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' }, + heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested Profiles' }, viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' }, }); @@ -65,7 +65,7 @@ const FeedSuggestions = () => { if (!isLoading && suggestedProfiles.size === 0) return null; return ( - + diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx index 7fda03c7a..221ca1491 100644 --- a/app/soapbox/features/follow-recommendations/index.tsx +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -10,7 +10,7 @@ import Column from 'soapbox/features/ui/components/column'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; const messages = defineMessages({ - heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' }, + heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' }, }); const FollowRecommendations: React.FC = () => { From a0d595c7df8f056921cd1ec017f62dcff2d2cba0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 26 Sep 2022 15:00:01 -0500 Subject: [PATCH 16/23] CountryCodeDropdown: support +55, +351 --- app/soapbox/utils/phone.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/utils/phone.ts b/app/soapbox/utils/phone.ts index 9cc175f5d..8fde39f12 100644 --- a/app/soapbox/utils/phone.ts +++ b/app/soapbox/utils/phone.ts @@ -1,7 +1,9 @@ /** List of supported E164 country codes. */ const COUNTRY_CODES = [ '1', + '351', '44', + '55', ] as const; /** Supported E164 country code. */ From facd4e95f53b83d0d3f6feef3e456e2d75d73a07 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 27 Sep 2022 09:58:49 -0400 Subject: [PATCH 17/23] Add tests for new Who To Follow panel --- .../steps/suggested-accounts-step.tsx | 2 +- .../placeholder-sidebar-suggestions.tsx | 2 +- .../__tests__/who-to-follow-panel.test.tsx | 302 +++++++++++------- .../queries/__tests__/suggestions.test.ts | 6 +- app/soapbox/queries/suggestions.ts | 18 +- 5 files changed, 205 insertions(+), 125 deletions(-) diff --git a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx index 8db08d1a4..a05202ff9 100644 --- a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx +++ b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl'; import ScrollableList from 'soapbox/components/scrollable_list'; import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; -import useOnboardingSuggestions from 'soapbox/queries/suggestions'; +import { useOnboardingSuggestions } from 'soapbox/queries/suggestions'; const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions(); diff --git a/app/soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx b/app/soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx index 757c47cc7..e96268cb2 100644 --- a/app/soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx +++ b/app/soapbox/features/placeholder/components/placeholder-sidebar-suggestions.tsx @@ -11,7 +11,7 @@ export default ({ limit }: { limit: number }) => { return ( <> {new Array(limit).fill(undefined).map((_, idx) => ( - +
({ + account_avatar: 'avatar', + account_id: id, + acct: 'acct', + display_name: 'my name', + note: 'hello', + verified: true, +}); + +const buildSuggestion = (id: string) => ({ + source: 'staff', + account: { + username: 'username', + verified: true, + id, + acct: 'acct', + avatar: 'avatar', + avatar_static: 'avatar', + display_name: 'my name', + }, +}); + describe('', () => { - it('renders suggested accounts', () => { - const store = { - accounts: ImmutableMap({ - '1': normalizeAccount({ - id: '1', - acct: 'username', - display_name: 'My name', - avatar: 'test.jpg', - }), - }), - suggestions: { - items: ImmutableOrderedSet([{ - source: 'staff', - account: '1', - }]), - }, - }; - - render(, undefined, store); - expect(screen.getByTestId('account')).toHaveTextContent(/my name/i); - }); + let store: any; - it('renders multiple accounts', () => { - const store = { - accounts: ImmutableMap({ - '1': normalizeAccount({ - id: '1', - acct: 'username', - display_name: 'My name', - avatar: 'test.jpg', - }), - '2': normalizeAccount({ - id: '1', - acct: 'username2', - display_name: 'My other name', - avatar: 'test.jpg', - }), - }), - suggestions: { - items: ImmutableOrderedSet([ - { - source: 'staff', - account: '1', - }, - { - source: 'staff', - account: '2', - }, - ]), - }, - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('account')).toHaveLength(2); - }); + describe('using Truth Social software', () => { + beforeEach(() => { + store = rootState + .set('me', '1234') + .set('instance', normalizeInstance({ + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + })); + }); + + describe('with a single suggestion', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions') + .reply(200, [buildTruthSuggestion('1')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('renders suggested accounts', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.getByTestId('account')).toHaveTextContent(/my name/i); + }); + }); + }); + + describe('with a multiple suggestion', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions') + .reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('renders suggested accounts', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(2); + }); + }); + }); + + describe('with a set limit', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions') + .reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('respects the limit prop', async () => { + render(, undefined, store); - it('respects the limit prop', () => { - const store = { - accounts: ImmutableMap({ - '1': normalizeAccount({ - id: '1', - acct: 'username', - display_name: 'My name', - avatar: 'test.jpg', - }), - '2': normalizeAccount({ - id: '1', - acct: 'username2', - display_name: 'My other name', - avatar: 'test.jpg', - }), - }), - suggestions: { - items: ImmutableOrderedSet([ - { - source: 'staff', - account: '1', - }, - { - source: 'staff', - account: '2', - }, - ]), - }, - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('account')).toHaveLength(1); + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(1); + }); + }); + }); + + describe('when the API returns an empty list', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions') + .reply(200, [], { + link: '', + }); + }); + }); + + it('renders empty', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(0); + }); + }); + }); }); - it('renders empty', () => { - const store = { - accounts: ImmutableMap({ - '1': normalizeAccount({ - id: '1', - acct: 'username', - display_name: 'My name', - avatar: 'test.jpg', - }), - '2': normalizeAccount({ - id: '1', - acct: 'username2', - display_name: 'My other name', - avatar: 'test.jpg', - }), - }), - suggestions: { - items: ImmutableOrderedSet([]), - }, - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('account')).toHaveLength(0); + describe('using Pleroma software', () => { + beforeEach(() => { + store = rootState.set('me', '1234'); + }); + + describe('with a single suggestion', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [buildSuggestion('1')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('renders suggested accounts', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.getByTestId('account')).toHaveTextContent(/my name/i); + }); + }); + }); + + describe('with a multiple suggestion', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [buildSuggestion('1'), buildSuggestion('2')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('renders suggested accounts', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(2); + }); + }); + }); + + describe('with a set limit', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [buildSuggestion('1'), buildSuggestion('2')], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('respects the limit prop', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(1); + }); + }); + }); + + describe('when the API returns an empty list', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [], { + link: '', + }); + }); + }); + + it('renders empty', async () => { + render(, undefined, store); + + await waitFor(() => { + expect(screen.queryAllByTestId('account')).toHaveLength(0); + }); + }); + }); }); }); diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts index f38bf0dbc..aa352abe9 100644 --- a/app/soapbox/queries/__tests__/suggestions.test.ts +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -1,7 +1,7 @@ import { __stub } from 'soapbox/api'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import useOnboardingSuggestions from '../suggestions'; +import { useOnboardingSuggestions } from '../suggestions'; describe('useCarouselAvatars', () => { describe('with a successful query', () => { @@ -17,7 +17,7 @@ describe('useCarouselAvatars', () => { }); }); - it('is successful', async() => { + it('is successful', async () => { const { result } = renderHook(() => useOnboardingSuggestions()); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -33,7 +33,7 @@ describe('useCarouselAvatars', () => { }); }); - it('is successful', async() => { + it('is successful', async () => { const { result } = renderHook(() => useOnboardingSuggestions()); await waitFor(() => expect(result.current.isFetching).toBe(false)); diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts index 8c9e2ec20..50b624831 100644 --- a/app/soapbox/queries/suggestions.ts +++ b/app/soapbox/queries/suggestions.ts @@ -4,7 +4,7 @@ import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccounts } from 'soapbox/actions/importer'; import { SuggestedProfile } from 'soapbox/actions/suggestions'; import { getLinks } from 'soapbox/api'; -import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks'; import { PaginatedResult, removePageItem } from '../utils/queries'; @@ -47,6 +47,10 @@ type TruthSuggestion = { verified: boolean } +type Result = TruthSuggestion | { + account: string +} + type PageParam = { link?: string } @@ -66,12 +70,11 @@ const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({ }); const useSuggestions = () => { - const account = useOwnAccount(); const api = useApi(); const dispatch = useAppDispatch(); const features = useFeatures(); - const getV2Suggestions = async(pageParam: PageParam): Promise> => { + const getV2Suggestions = async (pageParam: PageParam): Promise> => { const endpoint = pageParam?.link || '/api/v2/suggestions'; const response = await api.get(endpoint); const hasMore = !!response.headers.link; @@ -83,13 +86,13 @@ const useSuggestions = () => { dispatch(fetchRelationships(accountIds)); return { - result: response.data, + result: response.data.map(x => ({ ...x, account: x.account.id })), link: nextLink, hasMore, }; }; - const getTruthSuggestions = async(pageParam: PageParam): Promise> => { + const getTruthSuggestions = async (pageParam: PageParam): Promise> => { const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions'; const response = await api.get(endpoint); const hasMore = !!response.headers.link; @@ -118,7 +121,6 @@ const useSuggestions = () => { ({ pageParam }: any) => getSuggestions(pageParam), { keepPreviousData: true, - enabled: !!account, getNextPageParam: (config) => { if (config?.hasMore) { return { nextLink: config?.link }; @@ -153,7 +155,7 @@ function useOnboardingSuggestions() { const api = useApi(); const dispatch = useAppDispatch(); - const getV2Suggestions = async(pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => { + const getV2Suggestions = async (pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => { const link = pageParam?.link || '/api/v2/suggestions'; const response = await api.get(link); const hasMore = !!response.headers.link; @@ -193,4 +195,4 @@ function useOnboardingSuggestions() { }; } -export { useOnboardingSuggestions as default, useSuggestions, useDismissSuggestion }; \ No newline at end of file +export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion }; \ No newline at end of file From b34b65cb90cf2267ef0a167150bcd3cb7aeb85dd Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 27 Sep 2022 09:59:01 -0400 Subject: [PATCH 18/23] Fix conflict between eslint / typescript --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index 164949e65..0ecb15a5b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -277,6 +277,7 @@ module.exports = { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 + 'space-before-function-paren': 'off', }, parser: '@typescript-eslint/parser', }, From d4cf5dc2b9d90618cb2dab77fffa689e5bb42cb7 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 27 Sep 2022 10:35:35 -0400 Subject: [PATCH 19/23] Remove unneeded disabled button on Bio step We don't require users to have bios, so we shouldn't force them to add one during the onboarding steps in order to proceed to the next step without "skipping" it. --- app/soapbox/features/onboarding/steps/bio-step.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/soapbox/features/onboarding/steps/bio-step.tsx b/app/soapbox/features/onboarding/steps/bio-step.tsx index 895fd08e8..31c7bc6c9 100644 --- a/app/soapbox/features/onboarding/steps/bio-step.tsx +++ b/app/soapbox/features/onboarding/steps/bio-step.tsx @@ -17,10 +17,6 @@ const BioStep = ({ onNext }: { onNext: () => void }) => { const [isSubmitting, setSubmitting] = React.useState(false); const [errors, setErrors] = React.useState([]); - const trimmedValue = value.trim(); - const isValid = trimmedValue.length > 0; - const isDisabled = !isValid; - const handleSubmit = () => { setSubmitting(true); @@ -79,7 +75,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => { block theme='primary' type='submit' - disabled={isDisabled || isSubmitting} + disabled={isSubmitting} onClick={handleSubmit} > {isSubmitting ? ( From 5a703bbf5028c5e5a7ba3a3be531a90bd5f3bf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 28 Sep 2022 22:57:25 +0200 Subject: [PATCH 20/23] TS/FC: Migrations page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/migration/index.js | 119 --------------------- app/soapbox/features/migration/index.tsx | 125 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 119 deletions(-) delete mode 100644 app/soapbox/features/migration/index.js create mode 100644 app/soapbox/features/migration/index.tsx diff --git a/app/soapbox/features/migration/index.js b/app/soapbox/features/migration/index.js deleted file mode 100644 index b323c833c..000000000 --- a/app/soapbox/features/migration/index.js +++ /dev/null @@ -1,119 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { moveAccount } from 'soapbox/actions/security'; -import snackbar from 'soapbox/actions/snackbar'; -// import Column from 'soapbox/features/ui/components/column'; -import { Button, Column, Form, FormActions, FormGroup, Input, Text } from 'soapbox/components/ui'; - -const messages = defineMessages({ - heading: { id: 'column.migration', defaultMessage: 'Account migration' }, - submit: { id: 'migration.submit', defaultMessage: 'Move followers' }, - moveAccountSuccess: { id: 'migration.move_account.success', defaultMessage: 'Account successfully moved.' }, - moveAccountFail: { id: 'migration.move_account.fail', defaultMessage: 'Account migration failed.' }, - acctFieldLabel: { id: 'migration.fields.acct.label', defaultMessage: 'Handle of the new account' }, - acctFieldPlaceholder: { id: 'migration.fields.acct.placeholder', defaultMessage: 'username@domain' }, - currentPasswordFieldLabel: { id: 'migration.fields.confirm_password.label', defaultMessage: 'Current password' }, -}); - -export default @connect() -@injectIntl -class Migration extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - targetAccount: '', - password: '', - isLoading: false, - } - - handleInputChange = e => { - this.setState({ [e.target.name]: e.target.value }); - } - - clearForm = () => { - this.setState({ targetAccount: '', password: '' }); - } - - handleSubmit = e => { - const { targetAccount, password } = this.state; - const { dispatch, intl } = this.props; - this.setState({ isLoading: true }); - return dispatch(moveAccount(targetAccount, password)).then(() => { - this.clearForm(); - dispatch(snackbar.success(intl.formatMessage(messages.moveAccountSuccess))); - }).catch(error => { - dispatch(snackbar.error(intl.formatMessage(messages.moveAccountFail))); - }).then(() => { - this.setState({ isLoading: false }); - }); - } - - render() { - const { intl } = this.props; - - return ( - -
- - - - - ), - }} - /> - - - - - - - - - + + )} + + + +
+ )} +
+ ); +}; + +export default ModerationOverlay; \ No newline at end of file diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 81cb0a152..c5130b759 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -29,6 +29,7 @@ import MissingIndicator from 'soapbox/components/missing_indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import ScrollableList from 'soapbox/components/scrollable_list'; import StatusActionBar from 'soapbox/components/status-action-bar'; +import ModerationOverlay from 'soapbox/components/statuses/moderation-overlay'; import SubNavigation from 'soapbox/components/sub_navigation'; import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; @@ -134,6 +135,7 @@ const Thread: React.FC = (props) => { const me = useAppSelector(state => state.me); const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); const displayMedia = settings.get('displayMedia') as DisplayMedia; + const inReview = status?.visibility === 'self'; const { ancestorsIds, descendantsIds } = useAppSelector(state => { let ancestorsIds = ImmutableOrderedSet(); @@ -459,11 +461,19 @@ const Thread: React.FC = (props) => {
+ {inReview ? ( + + ) : null} +