diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts index 8e35be41c1..862f98b60a 100644 --- a/app/javascript/mastodon/api/collections.ts +++ b/app/javascript/mastodon/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/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 23f835f5fc..fae95875d1 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/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/mastodon/features/collections/detail/collection_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx similarity index 77% rename from app/javascript/mastodon/features/collections/detail/collection_list.tsx rename to app/javascript/mastodon/features/collections/detail/accounts_list.tsx index f66fd855cf..e458dd27f0 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list.tsx +++ b/app/javascript/mastodon/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 '@/mastodon/components/button'; -import { useRelationship } from '@/mastodon/hooks/useRelationship'; -import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import { openModal } from 'mastodon/actions/modal'; +import type { + ApiCollectionJSON, + CollectionAccountItem, +} from 'mastodon/api_types/collections'; import { Account } from 'mastodon/components/account'; +import { Button } from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; import { Article, ItemList, } from 'mastodon/components/scrollable_list/components'; import { useAccount } from 'mastodon/hooks/useAccount'; +import { useDismissible } from 'mastodon/hooks/useDismissible'; +import { useRelationship } from 'mastodon/hooks/useRelationship'; import { me } from 'mastodon/initial_state'; +import { useAppDispatch } from 'mastodon/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/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 4f21e7d267..9870e44bc6 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -24,7 +24,7 @@ import { me } from 'mastodon/initial_state'; import { fetchCollection } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/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/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx b/app/javascript/mastodon/features/collections/detail/revoke_collection_inclusion_modal.tsx new file mode 100644 index 0000000000..c2c2bafe9d --- /dev/null +++ b/app/javascript/mastodon/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 'mastodon/actions/alerts'; +import type { BaseConfirmationModalProps } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal'; +import { ConfirmationModal } from 'mastodon/features/ui/components/confirmation_modals/confirmation_modal'; +import { revokeCollectionInclusion } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'mastodon/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/mastodon/features/collections/detail/styles.module.scss b/app/javascript/mastodon/features/collections/detail/styles.module.scss index ad084eaed6..786c0e7000 100644 --- a/app/javascript/mastodon/features/collections/detail/styles.module.scss +++ b/app/javascript/mastodon/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/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 19898fb57d..b0397f4d7b 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -21,7 +21,7 @@ interface ConfirmationModalProps { cancel?: React.ReactNode; secondary?: React.ReactNode; onSecondary?: () => void; - onConfirm: () => void; + onConfirm: () => void | Promise; noCloseOnConfirm?: boolean; extraContent?: React.ReactNode; children?: React.ReactNode; @@ -56,7 +56,7 @@ export const ConfirmationModal: React.FC< onClose(); } - onConfirm(); + void onConfirm(); }, [onClose, onConfirm, noCloseOnConfirm]); const handleSecondary = useCallback(() => { diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 3493deb0eb..7bd1aa1872 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -80,6 +80,7 @@ export const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'REPORT_COLLECTION': ReportCollectionModal, 'SHARE_COLLECTION': () => import('@/mastodon/features/collections/detail/share_modal').then(module => ({ default: module.CollectionShareModal })), + 'REVOKE_COLLECTION_INCLUSION': () => import('@/mastodon/features/collections/detail/revoke_collection_inclusion_modal').then(module => ({ default: module.RevokeCollectionInclusionModal })), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, 'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }), diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 649849d528..57b868c848 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -338,12 +338,14 @@ "collections.create_collection": "Create collection", "collections.delete_collection": "Delete collection", "collections.description_length_hint": "100 characters limit", + "collections.detail.accept_inclusion": "Okay", "collections.detail.accounts_heading": "Accounts", "collections.detail.author_added_you": "{author} added you to this collection", "collections.detail.curated_by_author": "Curated by {author}", "collections.detail.curated_by_you": "Curated by you", "collections.detail.loading": "Loading collection…", "collections.detail.other_accounts_in_collection": "Others in this collection:", + "collections.detail.revoke_inclusion": "Remove me", "collections.detail.sensitive_note": "This collection contains accounts and content that may be sensitive to some users.", "collections.detail.share": "Share this collection", "collections.edit_details": "Edit details", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Last posted over a week ago", "collections.remove_account": "Remove this account", "collections.report_collection": "Report this collection", + "collections.revoke_collection_inclusion": "Remove myself from this collection", + "collections.revoke_inclusion.confirmation": "You've been removed from \"{collection}\"", + "collections.revoke_inclusion.error": "There was an error, please try again later.", "collections.search_accounts_label": "Search for accounts to add…", "collections.search_accounts_max_reached": "You have added the maximum number of accounts", "collections.sensitive": "Sensitive", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Remove follower", "confirmations.remove_from_followers.message": "{name} will stop following you. Are you sure you want to proceed?", "confirmations.remove_from_followers.title": "Remove follower?", + "confirmations.revoke_collection_inclusion.confirm": "Remove me", + "confirmations.revoke_collection_inclusion.message": "This action is permanent, and the curator won't be able to re-add you to the collection later on.", + "confirmations.revoke_collection_inclusion.title": "Remove yourself from this collection?", "confirmations.revoke_quote.confirm": "Remove post", "confirmations.revoke_quote.message": "This action cannot be undone.", "confirmations.revoke_quote.title": "Remove post?", diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index c3bec4b1c6..127794b478 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -9,6 +9,7 @@ import { apiDeleteCollection, apiAddCollectionItem, apiRemoveCollectionItem, + apiRevokeCollectionInclusion, } from '@/mastodon/api/collections'; import type { ApiCollectionJSON, @@ -17,6 +18,7 @@ import type { } from '@/mastodon/api_types/collections'; import { me } from '@/mastodon/initial_state'; import { + createAppAsyncThunk, createAppSelector, createDataLoadingThunk, } from '@/mastodon/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; /**