diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 9870e44bc6..8db00e73d3 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { useLocation, useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import { openModal } from '@/mastodon/actions/modal'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; @@ -84,6 +84,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const intl = useIntl(); const { name, description, tag, account_id } = collection; const dispatch = useAppDispatch(); + const history = useHistory(); const handleShare = useCallback(() => { dispatch( @@ -97,12 +98,14 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ }, [collection, dispatch]); const location = useLocation<{ newCollection?: boolean } | undefined>(); - const wasJustCreated = location.state?.newCollection; + const isNewCollection = location.state?.newCollection; useEffect(() => { - if (wasJustCreated) { + if (isNewCollection) { + // Replace with current pathname to clear `newCollection` state + history.replace(location.pathname); handleShare(); } - }, [handleShare, wasJustCreated]); + }, [history, handleShare, isNewCollection, location.pathname]); return (
diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 0f4681d077..26bab6abe0 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -64,7 +64,7 @@ export const CollectionShareModal: React.FC<{ onClose(); dispatch(changeCompose(shareMessage)); dispatch(focusCompose()); - }, [collectionLink, dispatch, intl, isOwnCollection, onClose]); + }, [onClose, collectionLink, dispatch, intl, isOwnCollection]); return ( diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 47af9e211c..423b72e628 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; @@ -30,12 +30,12 @@ import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; import { addCollectionItem, + getCollectionItemIds, removeCollectionItem, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; import { store, useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; @@ -52,9 +52,8 @@ function isOlderThanAWeek(date?: string): boolean { const AddedAccountItem: React.FC<{ accountId: string; - isRemovable: boolean; onRemove: (id: string) => void; -}> = ({ accountId, isRemovable, onRemove }) => { +}> = ({ accountId, onRemove }) => { const intl = useIntl(); const account = useAccount(accountId); @@ -86,17 +85,15 @@ const AddedAccountItem: React.FC<{ id={accountId} extraAccountInfo={lastPostHint} > - {isRemovable && ( - - )} + ); }; @@ -139,28 +136,25 @@ export const CollectionAccounts: React.FC<{ const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - const { id, initialItemIds } = getCollectionEditorState( - collection, - location.state, - ); - const isEditMode = !!id; - const collectionItems = collection?.items; - const [searchValue, setSearchValue] = useState(''); - // This state is only used when creating a new collection. - // In edit mode, the collection will be updated instantly - const [addedAccountIds, setAccountIds] = useState(initialItemIds); + const { id, items } = collection ?? {}; + const isEditMode = !!id; + const collectionItems = items; + + const addedAccountIds = useAppSelector( + (state) => state.collections.editor.accountIds, + ); + + // In edit mode, we're bypassing state and just return collection items directly, + // since they're edited "live", saving after each addition/deletion const accountIds = useMemo( () => - isEditMode - ? (collectionItems - ?.map((item) => item.account_id) - .filter((id): id is string => !!id) ?? []) - : addedAccountIds, + isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds, [isEditMode, collectionItems, addedAccountIds], ); + const [searchValue, setSearchValue] = useState(''); + const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const { @@ -233,28 +227,41 @@ export const CollectionAccounts: React.FC<{ [dispatch, relationships], ); - const removeAccountItem = useCallback((accountId: string) => { - setAccountIds((ids) => ids.filter((id) => id !== accountId)); - }, []); + const removeAccountItem = useCallback( + (accountId: string) => { + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: accountIds.filter((id) => id !== accountId), + }), + ); + }, + [accountIds, dispatch], + ); const addAccountItem = useCallback( (accountId: string) => { confirmFollowStatus(accountId, () => { - setAccountIds((ids) => [...ids, accountId]); + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: [...accountIds, accountId], + }), + ); }); }, - [confirmFollowStatus], + [accountIds, confirmFollowStatus, dispatch], ); const toggleAccountItem = useCallback( (item: SuggestionItem) => { - if (addedAccountIds.includes(item.id)) { + if (accountIds.includes(item.id)) { removeAccountItem(item.id); } else { addAccountItem(item.id); } }, - [addAccountItem, addedAccountIds, removeAccountItem], + [accountIds, addAccountItem, removeAccountItem], ); const instantRemoveAccountItem = useCallback( @@ -406,7 +413,6 @@ export const CollectionAccounts: React.FC<{ > diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 6234bca514..875d09c9eb 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -1,13 +1,12 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; import type { - ApiCollectionJSON, ApiCreateCollectionPayload, ApiUpdateCollectionPayload, } from 'mastodon/api_types/collections'; @@ -23,70 +22,77 @@ import { TextInputField } from 'mastodon/components/form_fields/text_input_field import { createCollection, updateCollection, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; -import { useAppDispatch } from 'mastodon/store'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; -export const CollectionDetails: React.FC<{ - collection?: ApiCollectionJSON | null; -}> = ({ collection }) => { +export const CollectionDetails: React.FC = () => { const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - - const { - id, - initialName, - initialDescription, - initialTopic, - initialItemIds, - initialDiscoverable, - initialSensitive, - } = getCollectionEditorState(collection, location.state); - - const [name, setName] = useState(initialName); - const [description, setDescription] = useState(initialDescription); - const [topic, setTopic] = useState(initialTopic); - const [discoverable, setDiscoverable] = useState(initialDiscoverable); - const [sensitive, setSensitive] = useState(initialSensitive); + const { id, name, description, topic, discoverable, sensitive, accountIds } = + useAppSelector((state) => state.collections.editor); const handleNameChange = useCallback( (event: React.ChangeEvent) => { - setName(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'name', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleDescriptionChange = useCallback( (event: React.ChangeEvent) => { - setDescription(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'description', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleTopicChange = useCallback( (event: React.ChangeEvent) => { - setTopic(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'topic', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleDiscoverableChange = useCallback( (event: React.ChangeEvent) => { - setDiscoverable(event.target.value === 'public'); + dispatch( + updateCollectionEditorField({ + field: 'discoverable', + value: event.target.value === 'public', + }), + ); }, - [], + [dispatch], ); const handleSensitiveChange = useCallback( (event: React.ChangeEvent) => { - setSensitive(event.target.checked); + dispatch( + updateCollectionEditorField({ + field: 'sensitive', + value: event.target.checked, + }), + ); }, - [], + [dispatch], ); const handleSubmit = useCallback( @@ -112,7 +118,7 @@ export const CollectionDetails: React.FC<{ description, discoverable, sensitive, - account_ids: initialItemIds, + account_ids: accountIds, }; if (topic) { payload.tag_name = topic; @@ -124,9 +130,7 @@ export const CollectionDetails: React.FC<{ }), ).then((result) => { if (isFulfilled(result)) { - history.replace( - `/collections/${result.payload.collection.id}/edit/details`, - ); + history.replace(`/collections`); history.push(`/collections/${result.payload.collection.id}`, { newCollection: true, }); @@ -143,7 +147,7 @@ export const CollectionDetails: React.FC<{ sensitive, dispatch, history, - initialItemIds, + accountIds, ], ); diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx index 2200bccb17..ff1549b942 100644 --- a/app/javascript/mastodon/features/collections/editor/index.tsx +++ b/app/javascript/mastodon/features/collections/editor/index.tsx @@ -16,7 +16,10 @@ import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { fetchCollection } from 'mastodon/reducers/slices/collections'; +import { + collectionEditorActions, + fetchCollection, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollectionAccounts } from './accounts'; @@ -68,6 +71,7 @@ export const CollectionEditorPage: React.FC<{ const collection = useAppSelector((state) => id ? state.collections.collections[id] : undefined, ); + const editorStateId = useAppSelector((state) => state.collections.editor.id); const isEditMode = !!id; const isLoading = isEditMode && !collection; @@ -77,6 +81,18 @@ export const CollectionEditorPage: React.FC<{ } }, [dispatch, id]); + useEffect(() => { + if (id !== editorStateId) { + void dispatch(collectionEditorActions.reset()); + } + }, [dispatch, editorStateId, id]); + + useEffect(() => { + if (collection) { + void dispatch(collectionEditorActions.init(collection)); + } + }, [dispatch, collection]); + const pageTitle = intl.formatMessage(usePageTitle(id)); return ( @@ -104,7 +120,7 @@ export const CollectionEditorPage: React.FC<{ exact path={`${path}/details`} // eslint-disable-next-line react/jsx-no-bind - render={() => } + render={() => } /> )} diff --git a/app/javascript/mastodon/features/collections/editor/state.ts b/app/javascript/mastodon/features/collections/editor/state.ts deleted file mode 100644 index abac0b94b5..0000000000 --- a/app/javascript/mastodon/features/collections/editor/state.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { - ApiCollectionJSON, - ApiCreateCollectionPayload, -} from '@/mastodon/api_types/collections'; - -/** - * Temporary editor state across creation steps, - * kept in location state - */ -export type TempCollectionState = - | Partial - | undefined; - -/** - * Resolve initial editor state. Temporary location state - * trumps stored data, otherwise initial values are returned. - */ -export function getCollectionEditorState( - collection: ApiCollectionJSON | null | undefined, - locationState: TempCollectionState, -) { - const { - id, - name = '', - description = '', - tag, - language = '', - discoverable = true, - sensitive = false, - items, - } = collection ?? {}; - - const collectionItemIds = - items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; - - const initialItemIds = ( - locationState?.account_ids ?? collectionItemIds - ).filter(onlyExistingIds); - - return { - id, - initialItemIds, - initialName: locationState?.name ?? name, - initialDescription: locationState?.description ?? description, - initialTopic: locationState?.tag_name ?? tag?.name ?? '', - initialLanguage: locationState?.language ?? language, - initialDiscoverable: locationState?.discoverable ?? discoverable, - initialSensitive: locationState?.sensitive ?? sensitive, - }; -} - -const onlyExistingIds = (id?: string): id is string => !!id; diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index 127794b478..dc20b98732 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -1,3 +1,4 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import { importFetchedAccounts } from '@/mastodon/actions/importer'; @@ -36,17 +37,69 @@ interface CollectionState { status: QueryStatus; } >; + editor: { + id: string | undefined; + name: string; + description: string; + topic: string; + language: string | null; + discoverable: boolean; + sensitive: boolean; + accountIds: string[]; + }; +} + +type EditorField = CollectionState['editor']; + +interface UpdateEditorFieldPayload { + field: K; + value: EditorField[K]; } const initialState: CollectionState = { collections: {}, accountCollections: {}, + editor: { + id: undefined, + name: '', + description: '', + topic: '', + language: null, + discoverable: true, + sensitive: false, + accountIds: [], + }, }; const collectionSlice = createSlice({ name: 'collections', initialState, - reducers: {}, + reducers: { + init(state, action: PayloadAction) { + const collection = action.payload; + + state.editor = { + id: collection?.id, + name: collection?.name ?? '', + description: collection?.description ?? '', + topic: collection?.tag?.name ?? '', + language: collection?.language ?? '', + discoverable: collection?.discoverable ?? true, + sensitive: collection?.sensitive ?? false, + accountIds: getCollectionItemIds(collection?.items ?? []), + }; + }, + reset(state) { + state.editor = initialState.editor; + }, + updateEditorField( + state: CollectionState, + action: PayloadAction>, + ) { + const { field, value } = action.payload; + state.editor[field] = value; + }, + }, extraReducers(builder) { /** * Fetching account collections @@ -104,6 +157,7 @@ const collectionSlice = createSlice({ builder.addCase(updateCollection.fulfilled, (state, action) => { const { collection } = action.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; }); /** @@ -132,6 +186,7 @@ const collectionSlice = createSlice({ const { collection } = actions.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; if (state.accountCollections[collection.account_id]) { state.accountCollections[collection.account_id]?.collectionIds.unshift( @@ -240,6 +295,9 @@ export const revokeCollectionInclusion = createAppAsyncThunk( ); export const collections = collectionSlice.reducer; +export const collectionEditorActions = collectionSlice.actions; +export const updateCollectionEditorField = + collectionSlice.actions.updateEditorField; /** * Selectors @@ -278,3 +336,8 @@ export const selectAccountCollections = createAppSelector( } satisfies AccountCollectionQuery; }, ); + +const onlyExistingIds = (id?: string): id is string => !!id; + +export const getCollectionItemIds = (items?: ApiCollectionJSON['items']) => + items?.map((item) => item.account_id).filter(onlyExistingIds) ?? [];