From a1acf8f4bcc0ffb03c7bc0dc7f83b8b98426542e Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 23 Jan 2026 14:51:39 +0100 Subject: [PATCH] Add "My collections" page (#37552) --- app/javascript/mastodon/api/collections.ts | 39 ++++ .../mastodon/api_types/collections.ts | 82 ++++++++ .../mastodon/features/collections/index.tsx | 179 ++++++++++++++++++ .../mastodon/features/collections/utils.ts | 11 ++ app/javascript/mastodon/features/ui/index.jsx | 5 + .../features/ui/util/async-components.js | 6 + app/javascript/mastodon/locales/en.json | 7 + .../mastodon/reducers/slices/collections.ts | 158 ++++++++++++++++ .../mastodon/reducers/slices/index.ts | 2 + app/javascript/mastodon/utils/environment.ts | 4 +- config/routes/web_app.rb | 1 + 11 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/api/collections.ts create mode 100644 app/javascript/mastodon/api_types/collections.ts create mode 100644 app/javascript/mastodon/features/collections/index.tsx create mode 100644 app/javascript/mastodon/features/collections/utils.ts create mode 100644 app/javascript/mastodon/reducers/slices/collections.ts diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts new file mode 100644 index 0000000000..142e303422 --- /dev/null +++ b/app/javascript/mastodon/api/collections.ts @@ -0,0 +1,39 @@ +import { + apiRequestPost, + apiRequestPut, + apiRequestGet, + apiRequestDelete, +} from 'mastodon/api'; + +import type { + ApiWrappedCollectionJSON, + ApiCollectionWithAccountsJSON, + ApiCreateCollectionPayload, + ApiPatchCollectionPayload, + ApiCollectionsJSON, +} from '../api_types/collections'; + +export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => + apiRequestPost('v1_alpha/collections', collection); + +export const apiUpdateCollection = ({ + id, + ...collection +}: ApiPatchCollectionPayload) => + apiRequestPut( + `v1_alpha/collections/${id}`, + collection, + ); + +export const apiDeleteCollection = (collectionId: string) => + apiRequestDelete(`v1_alpha/collections/${collectionId}`); + +export const apiGetCollection = (collectionId: string) => + apiRequestGet( + `v1_alpha/collections/${collectionId}`, + ); + +export const apiGetAccountCollections = (accountId: string) => + apiRequestGet( + `v1_alpha/accounts/${accountId}/collections`, + ); diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts new file mode 100644 index 0000000000..954abfae5e --- /dev/null +++ b/app/javascript/mastodon/api_types/collections.ts @@ -0,0 +1,82 @@ +// See app/serializers/rest/base_collection_serializer.rb + +import type { ApiAccountJSON } from './accounts'; +import type { ApiTagJSON } from './statuses'; + +/** + * Returned when fetching all collections for an account, + * doesn't contain account and item data + */ +export interface ApiCollectionJSON { + account_id: string; + + id: string; + uri: string; + local: boolean; + item_count: number; + + name: string; + description: string; + tag?: ApiTagJSON; + language: string; + sensitive: boolean; + discoverable: boolean; + + created_at: string; + updated_at: string; + + items: CollectionAccountItem[]; +} + +/** + * Returned when fetching all collections for an account + */ +export interface ApiCollectionsJSON { + collections: ApiCollectionJSON[]; +} + +/** + * Returned when creating, updating, and adding to a collection + */ +export interface ApiWrappedCollectionJSON { + collection: ApiCollectionJSON; +} + +/** + * Returned when fetching a single collection + */ +export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON { + accounts: ApiAccountJSON[]; +} + +/** + * Nested account item + */ +interface CollectionAccountItem { + account_id?: string; // Only present when state is 'accepted' (or the collection is your own) + state: 'pending' | 'accepted' | 'rejected' | 'revoked'; + position: number; +} + +export interface WrappedCollectionAccountItem { + collection_item: CollectionAccountItem; +} + +/** + * Payload types + */ + +type CommonPayloadFields = Pick< + ApiCollectionJSON, + 'name' | 'description' | 'sensitive' | 'discoverable' +> & { + tag?: string; +}; + +export interface ApiPatchCollectionPayload extends Partial { + id: string; +} + +export interface ApiCreateCollectionPayload extends CommonPayloadFields { + account_ids?: string[]; +} diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx new file mode 100644 index 0000000000..0b4b4c8d21 --- /dev/null +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useCallback } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; + +import AddIcon from '@/material-icons/400-24px/add.svg?react'; +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { Dropdown } from 'mastodon/components/dropdown_menu'; +import { Icon } from 'mastodon/components/icon'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import { + createCollection, + fetchAccountCollections, + selectMyCollections, +} from 'mastodon/reducers/slices/collections'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + heading: { id: 'column.collections', defaultMessage: 'My collections' }, + create: { + id: 'collections.create_collection', + defaultMessage: 'Create collection', + }, + view: { + id: 'collections.view_collection', + defaultMessage: 'View collection', + }, + delete: { + id: 'collections.delete_collection', + defaultMessage: 'Delete collection', + }, + more: { id: 'status.more', defaultMessage: 'More' }, +}); + +const ListItem: React.FC<{ + id: string; + name: string; +}> = ({ id, name }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const handleDeleteClick = useCallback(() => { + dispatch( + openModal({ + modalType: 'CONFIRM_DELETE_LIST', + modalProps: { + listId: id, + }, + }), + ); + }, [dispatch, id]); + + const menu = useMemo( + () => [ + { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, + { text: intl.formatMessage(messages.delete), action: handleDeleteClick }, + ], + [intl, id, handleDeleteClick], + ); + + return ( +
+ + {name} + + + +
+ ); +}; + +export const Collections: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const me = useAppSelector((state) => state.meta.get('me') as string); + const { collections, status } = useAppSelector(selectMyCollections); + + useEffect(() => { + void dispatch(fetchAccountCollections({ accountId: me })); + }, [dispatch, me]); + + const addDummyCollection = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + + void dispatch( + createCollection({ + payload: { + name: 'Test Collection', + description: 'A useful test collection', + discoverable: true, + sensitive: false, + }, + }), + ); + }, + [dispatch], + ); + + const emptyMessage = + status === 'error' ? ( + + ) : ( + <> + + +
+ +
+ + + + ); + + return ( + + + + + } + /> + + + {collections.map((item) => ( + + ))} + + + + {intl.formatMessage(messages.heading)} + + + + ); +}; diff --git a/app/javascript/mastodon/features/collections/utils.ts b/app/javascript/mastodon/features/collections/utils.ts new file mode 100644 index 0000000000..616d0297a7 --- /dev/null +++ b/app/javascript/mastodon/features/collections/utils.ts @@ -0,0 +1,11 @@ +import { + isClientFeatureEnabled, + isServerFeatureEnabled, +} from '@/mastodon/utils/environment'; + +export function areCollectionsEnabled() { + return ( + isClientFeatureEnabled('collections') && + isServerFeatureEnabled('collections') + ); +} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 61317fff5b..c1a3fa895a 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -63,6 +63,7 @@ import { Lists, ListEdit, ListMembers, + Collections, Blocks, DomainBlocks, Mutes, @@ -85,6 +86,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; +import { areCollectionsEnabled } from '../collections/utils'; const messages = defineMessages({ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, @@ -227,6 +229,9 @@ class SwitchingColumnsArea extends PureComponent { + {areCollectionsEnabled() && + + } diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index a2a5fd8c1b..976b1fb13f 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -42,6 +42,12 @@ export function Lists () { return import('../../lists'); } +export function Collections () { + return import('../../collections').then( + module => ({default: module.Collections}) + ); +} + export function Status () { return import('../../status'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 06548fa5f2..3020e0d34e 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -194,9 +194,16 @@ "closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!", "closed_registrations_modal.title": "Signing up on Mastodon", + "collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.", + "collections.create_collection": "Create collection", + "collections.delete_collection": "Delete collection", + "collections.error_loading_collections": "There was an error when trying to load your collections.", + "collections.no_collections_yet": "No collections yet.", + "collections.view_collection": "View collection", "column.about": "About", "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", + "column.collections": "My collections", "column.community": "Local timeline", "column.create_list": "Create list", "column.direct": "Private mentions", diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts new file mode 100644 index 0000000000..0eb7bfbbcf --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -0,0 +1,158 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { + apiCreateCollection, + apiGetAccountCollections, + // apiGetCollection, +} from '@/mastodon/api/collections'; +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, +} from '@/mastodon/api_types/collections'; +import { + createAppSelector, + createDataLoadingThunk, +} from '@/mastodon/store/typed_functions'; + +type QueryStatus = 'idle' | 'loading' | 'error'; + +interface CollectionState { + // Collections mapped by collection id + collections: Record; + // Lists of collection ids mapped by account id + accountCollections: Record< + string, + { + collectionIds: string[]; + status: QueryStatus; + } + >; +} + +const initialState: CollectionState = { + collections: {}, + accountCollections: {}, +}; + +const collectionSlice = createSlice({ + name: 'collections', + initialState, + reducers: {}, + extraReducers(builder) { + /** + * Fetching account collections + */ + builder.addCase(fetchAccountCollections.pending, (state, action) => { + const { accountId } = action.meta.arg; + state.accountCollections[accountId] ??= { + status: 'loading', + collectionIds: [], + }; + state.accountCollections[accountId].status = 'loading'; + }); + + builder.addCase(fetchAccountCollections.rejected, (state, action) => { + const { accountId } = action.meta.arg; + state.accountCollections[accountId] = { + status: 'error', + collectionIds: [], + }; + }); + + builder.addCase(fetchAccountCollections.fulfilled, (state, actions) => { + const { collections } = actions.payload; + + const collectionsMap: Record = {}; + const collectionIds: string[] = []; + + collections.forEach((collection) => { + const { id } = collection; + collectionsMap[id] = collection; + collectionIds.push(id); + }); + + state.collections = collectionsMap; + state.accountCollections[actions.meta.arg.accountId] = { + collectionIds, + status: 'idle', + }; + }); + + /** + * Creating a collection + */ + + builder.addCase(createCollection.fulfilled, (state, actions) => { + const { collection } = actions.payload; + + state.collections[collection.id] = collection; + if (state.accountCollections[collection.account_id]) { + state.accountCollections[collection.account_id]?.collectionIds.unshift( + collection.id, + ); + } else { + state.accountCollections[collection.account_id] = { + collectionIds: [collection.id], + status: 'idle', + }; + } + }); + }, +}); + +export const fetchAccountCollections = createDataLoadingThunk( + `${collectionSlice.name}/fetchAccountCollections`, + ({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId), +); + +// To be added soon… +// +// export const fetchCollection = createDataLoadingThunk( +// `${collectionSlice.name}/fetchCollection`, +// ({ collectionId }: { collectionId: string }) => +// apiGetCollection(collectionId), +// ); + +export const createCollection = createDataLoadingThunk( + `${collectionSlice.name}/createCollection`, + ({ payload }: { payload: ApiCreateCollectionPayload }) => + apiCreateCollection(payload), +); + +export const collections = collectionSlice.reducer; + +/** + * Selectors + */ + +interface AccountCollectionQuery { + status: QueryStatus; + collections: ApiCollectionJSON[]; +} + +export const selectMyCollections = createAppSelector( + [ + (state) => state.meta.get('me') as string, + (state) => state.collections.accountCollections, + (state) => state.collections.collections, + ], + (me, collectionsByAccountId, collectionsById) => { + const myCollectionsQuery = collectionsByAccountId[me]; + + if (!myCollectionsQuery) { + return { + status: 'error', + collections: [] as ApiCollectionJSON[], + } satisfies AccountCollectionQuery; + } + + const { status, collectionIds } = myCollectionsQuery; + + return { + status, + collections: collectionIds + .map((id) => collectionsById[id]) + .filter((c) => !!c), + } satisfies AccountCollectionQuery; + }, +); diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts index dfea395127..06a384d562 100644 --- a/app/javascript/mastodon/reducers/slices/index.ts +++ b/app/javascript/mastodon/reducers/slices/index.ts @@ -1,5 +1,7 @@ import { annualReport } from './annual_report'; +import { collections } from './collections'; export const sliceReducers = { annualReport, + collections, }; diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index 84767322b0..c7fecee022 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -12,13 +12,13 @@ export function isProduction() { else return import.meta.env.PROD; } -export type ServerFeatures = 'fasp'; +export type ServerFeatures = 'fasp' | 'collections'; export function isServerFeatureEnabled(feature: ServerFeatures) { return initialState?.features.includes(feature) ?? false; } -type ClientFeatures = 'profile_redesign'; +type ClientFeatures = 'profile_redesign' | 'collections'; export function isClientFeatureEnabled(feature: ClientFeatures) { try { diff --git a/config/routes/web_app.rb b/config/routes/web_app.rb index c09232d1f2..2901e22715 100644 --- a/config/routes/web_app.rb +++ b/config/routes/web_app.rb @@ -7,6 +7,7 @@ %w( /blocks /bookmarks + /collections/(*any) /conversations /deck/(*any) /directory