From e616200e599bd48672dc1f4fadef5cfeb847ca20 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 9 Mar 2026 10:54:17 +0100 Subject: [PATCH] [Glitch] Allow removing yourself from a collection Port 3a796544e3c20687c14632e87c5d099154a0b8e5 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/api/collections.ts | 6 ++ .../flavours/glitch/api_types/collections.ts | 2 +- ...{collection_list.tsx => accounts_list.tsx} | 62 +++++++++++++- .../collections/detail/collection_menu.tsx | 85 +++++++++++++------ .../features/collections/detail/index.tsx | 2 +- .../revoke_collection_inclusion_modal.tsx | 82 ++++++++++++++++++ .../collections/detail/styles.module.scss | 21 +++++ .../confirmation_modal.tsx | 4 +- .../features/ui/components/modal_root.jsx | 1 + .../glitch/reducers/slices/collections.ts | 25 +++++- 10 files changed, 254 insertions(+), 36 deletions(-) rename app/javascript/flavours/glitch/features/collections/detail/{collection_list.tsx => accounts_list.tsx} (77%) create mode 100644 app/javascript/flavours/glitch/features/collections/detail/revoke_collection_inclusion_modal.tsx diff --git a/app/javascript/flavours/glitch/api/collections.ts b/app/javascript/flavours/glitch/api/collections.ts index 50d0ffa516..1aace93d2d 100644 --- a/app/javascript/flavours/glitch/api/collections.ts +++ b/app/javascript/flavours/glitch/api/collections.ts @@ -49,3 +49,9 @@ export const apiRemoveCollectionItem = (collectionId: string, itemId: string) => apiRequestDelete( `v1_alpha/collections/${collectionId}/items/${itemId}`, ); + +export const apiRevokeCollectionInclusion = ( + collectionId: string, + itemId: string, +) => + apiRequestPost(`v1_alpha/collections/${collectionId}/items/${itemId}/revoke`); diff --git a/app/javascript/flavours/glitch/api_types/collections.ts b/app/javascript/flavours/glitch/api_types/collections.ts index 23f835f5fc..fae95875d1 100644 --- a/app/javascript/flavours/glitch/api_types/collections.ts +++ b/app/javascript/flavours/glitch/api_types/collections.ts @@ -52,7 +52,7 @@ export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON /** * Nested account item */ -interface CollectionAccountItem { +export interface CollectionAccountItem { id: string; account_id?: string; // Only present when state is 'accepted' (or the collection is your own) state: 'pending' | 'accepted' | 'rejected' | 'revoked'; diff --git a/app/javascript/flavours/glitch/features/collections/detail/collection_list.tsx b/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx similarity index 77% rename from app/javascript/flavours/glitch/features/collections/detail/collection_list.tsx rename to app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx index 22ca50944f..dd495654e8 100644 --- a/app/javascript/flavours/glitch/features/collections/detail/collection_list.tsx +++ b/app/javascript/flavours/glitch/features/collections/detail/accounts_list.tsx @@ -2,17 +2,23 @@ import { Fragment, useCallback, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { Button } from '@/flavours/glitch/components/button'; -import { useRelationship } from '@/flavours/glitch/hooks/useRelationship'; -import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections'; +import { openModal } from 'flavours/glitch/actions/modal'; +import type { + ApiCollectionJSON, + CollectionAccountItem, +} from 'flavours/glitch/api_types/collections'; import { Account } from 'flavours/glitch/components/account'; +import { Button } from 'flavours/glitch/components/button'; import { DisplayName } from 'flavours/glitch/components/display_name'; import { Article, ItemList, } from 'flavours/glitch/components/scrollable_list/components'; import { useAccount } from 'flavours/glitch/hooks/useAccount'; +import { useDismissible } from 'flavours/glitch/hooks/useDismissible'; +import { useRelationship } from 'flavours/glitch/hooks/useRelationship'; import { me } from 'flavours/glitch/initial_state'; +import { useAppDispatch } from 'flavours/glitch/store'; import classes from './styles.module.scss'; @@ -62,6 +68,52 @@ const AccountItem: React.FC<{ ); }; +const RevokeControls: React.FC<{ + collectionId: string; + collectionItem: CollectionAccountItem; +}> = ({ collectionId, collectionItem }) => { + const dispatch = useAppDispatch(); + + const confirmRevoke = useCallback(() => { + void dispatch( + openModal({ + modalType: 'REVOKE_COLLECTION_INCLUSION', + modalProps: { + collectionId, + collectionItemId: collectionItem.id, + }, + }), + ); + }, [collectionId, collectionItem.id, dispatch]); + + const { wasDismissed, dismiss } = useDismissible( + `collection-revoke-hint-${collectionItem.id}`, + ); + + if (wasDismissed) { + return null; + } + + return ( +
+ + +
+ ); +}; + const SensitiveScreen: React.FC<{ sensitive: boolean | undefined; focusTargetRef: React.RefObject; @@ -166,6 +218,10 @@ export const CollectionAccountsList: React.FC<{ accountId={currentUserInCollection.account_id} collectionOwnerId={collection.account_id} /> +

item.account_id === me, + ); + + const openRevokeConfirmation = useCallback(() => { + void dispatch( + openModal({ + modalType: 'REVOKE_COLLECTION_INCLUSION', + modalProps: { + collectionId: collection.id, + collectionItemId: currentAccountInCollection?.id, + }, + }), + ); + }, [collection.id, currentAccountInCollection?.id, dispatch]); + const menu = useMemo(() => { if (isOwnCollection) { const commonItems: MenuItem[] = [ @@ -99,34 +119,43 @@ export const CollectionMenu: React.FC<{ } else { return commonItems; } - } else if (ownerAccount) { - const items: MenuItem[] = [ - { - text: intl.formatMessage(messages.report), - action: openReportModal, - }, - ]; - const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`; - // Don't show menu link to featured collections while on that very page - if ( - !matchPath(location.pathname, { - path: featuredCollectionsPath, - exact: true, - }) - ) { - items.unshift( - ...[ - { - text: intl.formatMessage(messages.viewOtherCollections), - to: featuredCollectionsPath, - }, - null, - ], - ); - } - return items; } else { - return []; + const items: MenuItem[] = []; + + if (ownerAccount) { + const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`; + // Don't show menu link to featured collections while on that very page + if ( + !matchPath(location.pathname, { + path: featuredCollectionsPath, + exact: true, + }) + ) { + items.push( + ...[ + { + text: intl.formatMessage(messages.viewOtherCollections), + to: featuredCollectionsPath, + }, + null, + ], + ); + } + } + + if (currentAccountInCollection) { + items.push({ + text: intl.formatMessage(messages.revoke), + action: openRevokeConfirmation, + }); + } + + items.push({ + text: intl.formatMessage(messages.report), + action: openReportModal, + }); + + return items; } }, [ isOwnCollection, @@ -134,6 +163,8 @@ export const CollectionMenu: React.FC<{ id, openDeleteConfirmation, context, + currentAccountInCollection, + openRevokeConfirmation, ownerAccount, openReportModal, ]); diff --git a/app/javascript/flavours/glitch/features/collections/detail/index.tsx b/app/javascript/flavours/glitch/features/collections/detail/index.tsx index 826984502c..79f61963de 100644 --- a/app/javascript/flavours/glitch/features/collections/detail/index.tsx +++ b/app/javascript/flavours/glitch/features/collections/detail/index.tsx @@ -24,7 +24,7 @@ import { me } from 'flavours/glitch/initial_state'; import { fetchCollection } from 'flavours/glitch/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; -import { CollectionAccountsList } from './collection_list'; +import { CollectionAccountsList } from './accounts_list'; import { CollectionMetaData } from './collection_list_item'; import { CollectionMenu } from './collection_menu'; import classes from './styles.module.scss'; diff --git a/app/javascript/flavours/glitch/features/collections/detail/revoke_collection_inclusion_modal.tsx b/app/javascript/flavours/glitch/features/collections/detail/revoke_collection_inclusion_modal.tsx new file mode 100644 index 0000000000..cf7eac6a0c --- /dev/null +++ b/app/javascript/flavours/glitch/features/collections/detail/revoke_collection_inclusion_modal.tsx @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { showAlert } from 'flavours/glitch/actions/alerts'; +import type { BaseConfirmationModalProps } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal'; +import { ConfirmationModal } from 'flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal'; +import { revokeCollectionInclusion } from 'flavours/glitch/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +const messages = defineMessages({ + revokeCollectionInclusionTitle: { + id: 'confirmations.revoke_collection_inclusion.title', + defaultMessage: 'Remove yourself from this collection?', + }, + revokeCollectionInclusionMessage: { + id: 'confirmations.revoke_collection_inclusion.message', + defaultMessage: + "This action is permanent, and the curator won't be able to re-add you to the collection later on.", + }, + revokeCollectionInclusionConfirm: { + id: 'confirmations.revoke_collection_inclusion.confirm', + defaultMessage: 'Remove me', + }, +}); + +export const RevokeCollectionInclusionModal: React.FC< + { + collectionId: string; + collectionItemId: string; + } & BaseConfirmationModalProps +> = ({ collectionId, collectionItemId, onClose }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const collectionName = useAppSelector( + (state) => state.collections.collections[collectionId]?.name, + ); + + const onConfirm = useCallback(async () => { + try { + await dispatch( + revokeCollectionInclusion({ + collectionId, + itemId: collectionItemId, + }), + ).unwrap(); + + dispatch( + showAlert({ + message: intl.formatMessage( + { + id: 'collections.revoke_inclusion.confirmation', + defaultMessage: 'You\'ve been removed from "{collection}"', + }, + { + collection: collectionName, + }, + ), + }), + ); + } catch { + dispatch( + showAlert({ + message: intl.formatMessage({ + id: 'collections.revoke_inclusion.error', + defaultMessage: 'There was an error, please try again later.', + }), + }), + ); + } + }, [dispatch, collectionId, collectionName, collectionItemId, intl]); + + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/features/collections/detail/styles.module.scss b/app/javascript/flavours/glitch/features/collections/detail/styles.module.scss index ad084eaed6..786c0e7000 100644 --- a/app/javascript/flavours/glitch/features/collections/detail/styles.module.scss +++ b/app/javascript/flavours/glitch/features/collections/detail/styles.module.scss @@ -103,3 +103,24 @@ line-height: 1.5; cursor: default; } + +.revokeControlWrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: -10px; + padding-bottom: 16px; + padding-inline: calc(26px + var(--avatar-width)) 16px; + + :global(.button) { + min-width: 30%; + white-space: normal; + } + + --avatar-width: 46px; + + @container (width < 360px) { + --avatar-width: 35px; + } +} diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx index 04af80ec13..e643d45aad 100644 --- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -29,7 +29,7 @@ interface ConfirmationModalProps { cancel?: React.ReactNode; secondary?: React.ReactNode; onSecondary?: () => void; - onConfirm: () => void; + onConfirm: () => void | Promise; noCloseOnConfirm?: boolean; extraContent?: React.ReactNode; children?: React.ReactNode; @@ -64,7 +64,7 @@ export const ConfirmationModal: React.FC< onClose(); } - onConfirm(); + void onConfirm(); }, [onClose, onConfirm, noCloseOnConfirm]); const handleSecondary = useCallback(() => { diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index 106be8be4f..859cc46ea5 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -86,6 +86,7 @@ export const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'REPORT_COLLECTION': ReportCollectionModal, 'SHARE_COLLECTION': () => import('@/flavours/glitch/features/collections/detail/share_modal').then(module => ({ default: module.CollectionShareModal })), + 'REVOKE_COLLECTION_INCLUSION': () => import('@/flavours/glitch/features/collections/detail/revoke_collection_inclusion_modal').then(module => ({ default: module.RevokeCollectionInclusionModal })), 'SETTINGS': SettingsModal, 'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), diff --git a/app/javascript/flavours/glitch/reducers/slices/collections.ts b/app/javascript/flavours/glitch/reducers/slices/collections.ts index b9890f6e0c..21609d1786 100644 --- a/app/javascript/flavours/glitch/reducers/slices/collections.ts +++ b/app/javascript/flavours/glitch/reducers/slices/collections.ts @@ -9,6 +9,7 @@ import { apiDeleteCollection, apiAddCollectionItem, apiRemoveCollectionItem, + apiRevokeCollectionInclusion, } from '@/flavours/glitch/api/collections'; import type { ApiCollectionJSON, @@ -17,6 +18,7 @@ import type { } from '@/flavours/glitch/api_types/collections'; import { me } from '@/flavours/glitch/initial_state'; import { + createAppAsyncThunk, createAppSelector, createDataLoadingThunk, } from '@/flavours/glitch/store/typed_functions'; @@ -158,7 +160,10 @@ const collectionSlice = createSlice({ * Removing an account from a collection */ - builder.addCase(removeCollectionItem.fulfilled, (state, action) => { + const removeAccountFromCollection = ( + state: CollectionState, + action: { meta: { arg: { itemId: string; collectionId: string } } }, + ) => { const { itemId, collectionId } = action.meta.arg; const collection = state.collections[collectionId]; @@ -167,7 +172,17 @@ const collectionSlice = createSlice({ (item) => item.id !== itemId, ); } - }); + }; + + builder.addCase( + removeCollectionItem.fulfilled, + removeAccountFromCollection, + ); + + builder.addCase( + revokeCollectionInclusion.fulfilled, + removeAccountFromCollection, + ); }, }); @@ -218,6 +233,12 @@ export const removeCollectionItem = createDataLoadingThunk( apiRemoveCollectionItem(collectionId, itemId), ); +export const revokeCollectionInclusion = createAppAsyncThunk( + `${collectionSlice.name}/revokeCollectionInclusion`, + ({ collectionId, itemId }: { collectionId: string; itemId: string }) => + apiRevokeCollectionInclusion(collectionId, itemId), +); + export const collections = collectionSlice.reducer; /**