[Glitch] Allow removing yourself from a collection

Port 3a796544e3 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-03-09 10:54:17 +01:00
committed by Claire
parent 4bcd0caa79
commit e616200e59
10 changed files with 254 additions and 36 deletions

View File

@@ -49,3 +49,9 @@ export const apiRemoveCollectionItem = (collectionId: string, itemId: string) =>
apiRequestDelete<WrappedCollectionAccountItem>(
`v1_alpha/collections/${collectionId}/items/${itemId}`,
);
export const apiRevokeCollectionInclusion = (
collectionId: string,
itemId: string,
) =>
apiRequestPost(`v1_alpha/collections/${collectionId}/items/${itemId}/revoke`);

View File

@@ -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';

View File

@@ -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 (
<div className={classes.revokeControlWrapper}>
<Button secondary onClick={dismiss}>
<FormattedMessage
id='collections.detail.accept_inclusion'
defaultMessage='Okay'
tagName={Fragment}
/>
</Button>
<Button secondary onClick={confirmRevoke}>
<FormattedMessage
id='collections.detail.revoke_inclusion'
defaultMessage='Remove me'
tagName={Fragment}
/>
</Button>
</div>
);
};
const SensitiveScreen: React.FC<{
sensitive: boolean | undefined;
focusTargetRef: React.RefObject<HTMLHeadingElement>;
@@ -166,6 +218,10 @@ export const CollectionAccountsList: React.FC<{
accountId={currentUserInCollection.account_id}
collectionOwnerId={collection.account_id}
/>
<RevokeControls
collectionId={collection.id}
collectionItem={currentUserInCollection}
/>
</Article>
<h3
className={classes.columnSubheading}

View File

@@ -33,6 +33,10 @@ const messages = defineMessages({
id: 'collections.report_collection',
defaultMessage: 'Report this collection',
},
revoke: {
id: 'collections.revoke_collection_inclusion',
defaultMessage: 'Remove myself from this collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
@@ -71,6 +75,22 @@ export const CollectionMenu: React.FC<{
);
}, [collection, dispatch]);
const currentAccountInCollection = collection.items.find(
(item) => 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,
]);

View File

@@ -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';

View File

@@ -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 (
<ConfirmationModal
title={intl.formatMessage(messages.revokeCollectionInclusionTitle)}
message={intl.formatMessage(messages.revokeCollectionInclusionMessage)}
confirm={intl.formatMessage(messages.revokeCollectionInclusionConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -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;
}
}

View File

@@ -29,7 +29,7 @@ interface ConfirmationModalProps {
cancel?: React.ReactNode;
secondary?: React.ReactNode;
onSecondary?: () => void;
onConfirm: () => void;
onConfirm: () => void | Promise<void>;
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(() => {

View File

@@ -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 }),

View File

@@ -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;
/**