[Glitch] Require following accounts before being able to add them to a collection

Port 68a7cd404d to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-02-20 14:14:36 +01:00
committed by Claire
parent 85f72a3940
commit c16cbb98e5
6 changed files with 138 additions and 21 deletions

View File

@@ -6,6 +6,9 @@ import { useHistory, useLocation } from 'react-router-dom';
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { showAlertForError } from 'flavours/glitch/actions/alerts';
import { openModal } from 'flavours/glitch/actions/modal';
import { apiFollowAccount } from 'flavours/glitch/api/accounts';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account'; import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar'; import { Avatar } from 'flavours/glitch/components/avatar';
@@ -21,11 +24,13 @@ import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { useSearchAccounts } from 'flavours/glitch/features/lists/use_search_accounts'; import { useSearchAccounts } from 'flavours/glitch/features/lists/use_search_accounts';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/initial_state';
import { import {
addCollectionItem, addCollectionItem,
removeCollectionItem, removeCollectionItem,
} from 'flavours/glitch/reducers/slices/collections'; } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { store, useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
import { getCollectionEditorState } from './state'; import { getCollectionEditorState } from './state';
@@ -69,7 +74,7 @@ interface SuggestionItem {
} }
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => { const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
const account = useAppSelector((state) => state.accounts.get(id)); const account = useAccount(id);
if (!account) return null; if (!account) return null;
@@ -133,6 +138,7 @@ export const CollectionAccounts: React.FC<{
isLoading: isLoadingSuggestions, isLoading: isLoadingSuggestions,
searchAccounts, searchAccounts,
} = useSearchAccounts({ } = useSearchAccounts({
withRelationships: true,
filterResults: (account) => filterResults: (account) =>
// Only suggest accounts who allow being featured/recommended // Only suggest accounts who allow being featured/recommended
account.feature_approval.current_user === 'automatic', account.feature_approval.current_user === 'automatic',
@@ -160,14 +166,67 @@ export const CollectionAccounts: React.FC<{
[], [],
); );
const toggleAccountItem = useCallback((item: SuggestionItem) => { const relationships = useAppSelector((state) => state.relationships);
setAccountIds((ids) =>
ids.includes(item.id) const confirmFollowStatus = useCallback(
? ids.filter((id) => id !== item.id) (accountId: string, onFollowing: () => void) => {
: [...ids, item.id], 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( const instantRemoveAccountItem = useCallback(
(accountId: string) => { (accountId: string) => {
const itemId = collectionItems?.find( const itemId = collectionItems?.find(
@@ -190,19 +249,24 @@ export const CollectionAccounts: React.FC<{
[collectionItems, dispatch, id, intl], [collectionItems, dispatch, id, intl],
); );
const instantAddAccountItem = useCallback(
(collectionId: string, accountId: string) => {
confirmFollowStatus(accountId, () => {
void dispatch(addCollectionItem({ collectionId, accountId }));
});
},
[confirmFollowStatus, dispatch],
);
const instantToggleAccountItem = useCallback( const instantToggleAccountItem = useCallback(
(item: SuggestionItem) => { (item: SuggestionItem) => {
if (accountIds.includes(item.id)) { if (accountIds.includes(item.id)) {
instantRemoveAccountItem(item.id); instantRemoveAccountItem(item.id);
} else { } else if (id) {
if (id) { instantAddAccountItem(id, item.id);
void dispatch(
addCollectionItem({ collectionId: id, accountId: item.id }),
);
}
} }
}, },
[accountIds, dispatch, id, instantRemoveAccountItem], [accountIds, id, instantAddAccountItem, instantRemoveAccountItem],
); );
const handleRemoveAccountItem = useCallback( const handleRemoveAccountItem = useCallback(
@@ -210,10 +274,10 @@ export const CollectionAccounts: React.FC<{
if (isEditMode) { if (isEditMode) {
instantRemoveAccountItem(accountId); instantRemoveAccountItem(accountId);
} else { } else {
setAccountIds((ids) => ids.filter((id) => id !== accountId)); removeAccountItem(accountId);
} }
}, },
[isEditMode, instantRemoveAccountItem], [isEditMode, instantRemoveAccountItem, removeAccountItem],
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(

View File

@@ -2,19 +2,22 @@ import { useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { fetchRelationships } from 'flavours/glitch/actions/accounts';
import { importFetchedAccounts } from 'flavours/glitch/actions/importer'; import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
import { apiRequest } from 'flavours/glitch/api'; import { apiRequest } from 'flavours/glitch/api';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts'; import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import { useAppDispatch } from 'flavours/glitch/store'; import { useAppDispatch } from 'flavours/glitch/store';
export function useSearchAccounts({ export function useSearchAccounts({
resetOnInputClear = true,
onSettled, onSettled,
filterResults, filterResults,
resetOnInputClear = true,
withRelationships = false,
}: { }: {
onSettled?: (value: string) => void; onSettled?: (value: string) => void;
filterResults?: (account: ApiAccountJSON) => boolean; filterResults?: (account: ApiAccountJSON) => boolean;
resetOnInputClear?: boolean; resetOnInputClear?: boolean;
withRelationships?: boolean;
} = {}) { } = {}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -52,8 +55,12 @@ export function useSearchAccounts({
}) })
.then((data) => { .then((data) => {
const accounts = filterResults ? data.filter(filterResults) : data; const accounts = filterResults ? data.filter(filterResults) : data;
const accountIds = accounts.map((a) => a.id);
dispatch(importFetchedAccounts(accounts)); dispatch(importFetchedAccounts(accounts));
setAccountIds(accounts.map((a) => a.id)); if (withRelationships) {
dispatch(fetchRelationships(accountIds));
}
setAccountIds(accountIds);
setLoadingState('idle'); setLoadingState('idle');
onSettled?.(value); onSettled?.(value);
}) })

View File

@@ -0,0 +1,43 @@
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useAccount } from 'flavours/glitch/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 (
<ConfirmationModal
title={intl.formatMessage(messages.title)}
message={
<FormattedMessage
id='confirmations.follow_to_collection.message'
defaultMessage='You need to be following {name} to add them to a collection.'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
}
confirm={intl.formatMessage(messages.confirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useAppSelector } from 'flavours/glitch/store'; import { useAccount } from 'flavours/glitch/hooks/useAccount';
import type { BaseConfirmationModalProps } from './confirmation_modal'; import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal'; import { ConfirmationModal } from './confirmation_modal';
@@ -23,7 +23,7 @@ export const ConfirmFollowToListModal: React.FC<
} & BaseConfirmationModalProps } & BaseConfirmationModalProps
> = ({ accountId, onConfirm, onClose }) => { > = ({ accountId, onConfirm, onClose }) => {
const intl = useIntl(); const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId)); const account = useAccount(accountId);
return ( return (
<ConfirmationModal <ConfirmationModal

View File

@@ -13,6 +13,7 @@ export { ConfirmUnblockModal } from './unblock';
export { ConfirmClearNotificationsModal } from './clear_notifications'; export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out'; export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list'; export { ConfirmFollowToListModal } from './follow_to_list';
export { ConfirmFollowToCollectionModal } from './follow_to_collection';
export { ConfirmMissingAltTextModal } from './missing_alt_text'; export { ConfirmMissingAltTextModal } from './missing_alt_text';
export { ConfirmRevokeQuoteModal } from './revoke_quote'; export { ConfirmRevokeQuoteModal } from './revoke_quote';
export { QuietPostQuoteInfoModal } from './quiet_post_quote_info'; export { QuietPostQuoteInfoModal } from './quiet_post_quote_info';

View File

@@ -39,6 +39,7 @@ import {
ConfirmClearNotificationsModal, ConfirmClearNotificationsModal,
ConfirmLogOutModal, ConfirmLogOutModal,
ConfirmFollowToListModal, ConfirmFollowToListModal,
ConfirmFollowToCollectionModal,
ConfirmMissingAltTextModal, ConfirmMissingAltTextModal,
ConfirmRevokeQuoteModal, ConfirmRevokeQuoteModal,
QuietPostQuoteInfoModal, QuietPostQuoteInfoModal,
@@ -73,6 +74,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }), 'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), '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_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }), 'CONFIRM_PRIVATE_QUOTE_NOTIFY': () => Promise.resolve({ default: PrivateQuoteNotify }),
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }), 'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),