diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index f20c9eb2e8..a09a21e6a8 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -6,6 +6,9 @@ import { useHistory, useLocation } from 'react-router-dom'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { showAlertForError } from 'mastodon/actions/alerts'; +import { openModal } from 'mastodon/actions/modal'; +import { apiFollowAccount } from 'mastodon/api/accounts'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Account } from 'mastodon/components/account'; import { Avatar } from 'mastodon/components/avatar'; @@ -18,11 +21,13 @@ import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; import ScrollableList from 'mastodon/components/scrollable_list'; import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts'; +import { useAccount } from 'mastodon/hooks/useAccount'; +import { me } from 'mastodon/initial_state'; import { addCollectionItem, removeCollectionItem, } from 'mastodon/reducers/slices/collections'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { store, useAppDispatch, useAppSelector } from 'mastodon/store'; import type { TempCollectionState } from './state'; import { getCollectionEditorState } from './state'; @@ -66,7 +71,7 @@ interface SuggestionItem { } const SuggestedAccountItem: React.FC = ({ id, isSelected }) => { - const account = useAppSelector((state) => state.accounts.get(id)); + const account = useAccount(id); if (!account) return null; @@ -130,6 +135,7 @@ export const CollectionAccounts: React.FC<{ isLoading: isLoadingSuggestions, searchAccounts, } = useSearchAccounts({ + withRelationships: true, filterResults: (account) => // Only suggest accounts who allow being featured/recommended account.feature_approval.current_user === 'automatic', @@ -157,14 +163,67 @@ export const CollectionAccounts: React.FC<{ [], ); - const toggleAccountItem = useCallback((item: SuggestionItem) => { - setAccountIds((ids) => - ids.includes(item.id) - ? ids.filter((id) => id !== item.id) - : [...ids, item.id], - ); + const relationships = useAppSelector((state) => state.relationships); + + const confirmFollowStatus = useCallback( + (accountId: string, onFollowing: () => void) => { + const relationship = relationships.get(accountId); + + if (!relationship) { + return; + } + + if ( + accountId === me || + relationship.following || + relationship.requested + ) { + onFollowing(); + } else { + dispatch( + openModal({ + modalType: 'CONFIRM_FOLLOW_TO_COLLECTION', + modalProps: { + accountId, + onConfirm: () => { + apiFollowAccount(accountId) + .then(onFollowing) + .catch((err: unknown) => { + store.dispatch(showAlertForError(err)); + }); + }, + }, + }), + ); + } + }, + [dispatch, relationships], + ); + + const removeAccountItem = useCallback((accountId: string) => { + setAccountIds((ids) => ids.filter((id) => id !== accountId)); }, []); + const addAccountItem = useCallback( + (accountId: string) => { + confirmFollowStatus(accountId, () => { + setAccountIds((ids) => [...ids, accountId]); + }); + }, + [confirmFollowStatus], + ); + + const toggleAccountItem = useCallback( + (item: SuggestionItem) => { + if (addedAccountIds.includes(item.id)) { + removeAccountItem(item.id); + } else { + addAccountItem(item.id); + } + }, + [addAccountItem, addedAccountIds, removeAccountItem], + ); + const instantRemoveAccountItem = useCallback( (accountId: string) => { const itemId = collectionItems?.find( @@ -187,19 +246,24 @@ export const CollectionAccounts: React.FC<{ [collectionItems, dispatch, id, intl], ); + const instantAddAccountItem = useCallback( + (collectionId: string, accountId: string) => { + confirmFollowStatus(accountId, () => { + void dispatch(addCollectionItem({ collectionId, accountId })); + }); + }, + [confirmFollowStatus, dispatch], + ); + const instantToggleAccountItem = useCallback( (item: SuggestionItem) => { if (accountIds.includes(item.id)) { instantRemoveAccountItem(item.id); - } else { - if (id) { - void dispatch( - addCollectionItem({ collectionId: id, accountId: item.id }), - ); - } + } else if (id) { + instantAddAccountItem(id, item.id); } }, - [accountIds, dispatch, id, instantRemoveAccountItem], + [accountIds, id, instantAddAccountItem, instantRemoveAccountItem], ); const handleRemoveAccountItem = useCallback( @@ -207,10 +271,10 @@ export const CollectionAccounts: React.FC<{ if (isEditMode) { instantRemoveAccountItem(accountId); } else { - setAccountIds((ids) => ids.filter((id) => id !== accountId)); + removeAccountItem(accountId); } }, - [isEditMode, instantRemoveAccountItem], + [isEditMode, instantRemoveAccountItem, removeAccountItem], ); const handleSubmit = useCallback( diff --git a/app/javascript/mastodon/features/lists/use_search_accounts.ts b/app/javascript/mastodon/features/lists/use_search_accounts.ts index 30d3d26ce1..1fc71f54ef 100644 --- a/app/javascript/mastodon/features/lists/use_search_accounts.ts +++ b/app/javascript/mastodon/features/lists/use_search_accounts.ts @@ -2,19 +2,22 @@ import { useRef, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; +import { fetchRelationships } from 'mastodon/actions/accounts'; import { importFetchedAccounts } from 'mastodon/actions/importer'; import { apiRequest } from 'mastodon/api'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; import { useAppDispatch } from 'mastodon/store'; export function useSearchAccounts({ - resetOnInputClear = true, onSettled, filterResults, + resetOnInputClear = true, + withRelationships = false, }: { onSettled?: (value: string) => void; filterResults?: (account: ApiAccountJSON) => boolean; resetOnInputClear?: boolean; + withRelationships?: boolean; } = {}) { const dispatch = useAppDispatch(); @@ -52,8 +55,12 @@ export function useSearchAccounts({ }) .then((data) => { const accounts = filterResults ? data.filter(filterResults) : data; + const accountIds = accounts.map((a) => a.id); dispatch(importFetchedAccounts(accounts)); - setAccountIds(accounts.map((a) => a.id)); + if (withRelationships) { + dispatch(fetchRelationships(accountIds)); + } + setAccountIds(accountIds); setLoadingState('idle'); onSettled?.(value); }) diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_collection.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_collection.tsx new file mode 100644 index 0000000000..c4700a1938 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_collection.tsx @@ -0,0 +1,43 @@ +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { useAccount } from 'mastodon/hooks/useAccount'; + +import type { BaseConfirmationModalProps } from './confirmation_modal'; +import { ConfirmationModal } from './confirmation_modal'; + +const messages = defineMessages({ + title: { + id: 'confirmations.follow_to_collection.title', + defaultMessage: 'Follow account?', + }, + confirm: { + id: 'confirmations.follow_to_collection.confirm', + defaultMessage: 'Follow and add to collection', + }, +}); + +export const ConfirmFollowToCollectionModal: React.FC< + { + accountId: string; + onConfirm: () => void; + } & BaseConfirmationModalProps +> = ({ accountId, onConfirm, onClose }) => { + const intl = useIntl(); + const account = useAccount(accountId); + + return ( + @{account?.acct} }} + /> + } + confirm={intl.formatMessage(messages.confirm)} + onConfirm={onConfirm} + onClose={onClose} + /> + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_list.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_list.tsx index b862a29827..d24f7fe2e2 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_list.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/follow_to_list.tsx @@ -1,6 +1,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useAppSelector } from 'mastodon/store'; +import { useAccount } from 'mastodon/hooks/useAccount'; import type { BaseConfirmationModalProps } from './confirmation_modal'; import { ConfirmationModal } from './confirmation_modal'; @@ -23,7 +23,7 @@ export const ConfirmFollowToListModal: React.FC< } & BaseConfirmationModalProps > = ({ accountId, onConfirm, onClose }) => { const intl = useIntl(); - const account = useAppSelector((state) => state.accounts.get(accountId)); + const account = useAccount(accountId); return ( Promise.resolve({ default: ConfirmClearNotificationsModal }), 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), + 'CONFIRM_FOLLOW_TO_COLLECTION': () => Promise.resolve({ default: ConfirmFollowToCollectionModal }), 'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }), 'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }), 'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3e7bd4eacf..8b9a63e295 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -390,6 +390,9 @@ "confirmations.discard_draft.post.title": "Discard your draft post?", "confirmations.discard_edit_media.confirm": "Discard", "confirmations.discard_edit_media.message": "You have unsaved changes to the media description or preview, discard them anyway?", + "confirmations.follow_to_collection.confirm": "Follow and add to collection", + "confirmations.follow_to_collection.message": "You need to be following {name} to add them to a collection.", + "confirmations.follow_to_collection.title": "Follow account?", "confirmations.follow_to_list.confirm": "Follow and add to list", "confirmations.follow_to_list.message": "You need to be following {name} to add them to a list.", "confirmations.follow_to_list.title": "Follow user?",