mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Add "My collections" page (#37552)
This commit is contained in:
39
app/javascript/mastodon/api/collections.ts
Normal file
39
app/javascript/mastodon/api/collections.ts
Normal file
@@ -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<ApiWrappedCollectionJSON>('v1_alpha/collections', collection);
|
||||
|
||||
export const apiUpdateCollection = ({
|
||||
id,
|
||||
...collection
|
||||
}: ApiPatchCollectionPayload) =>
|
||||
apiRequestPut<ApiWrappedCollectionJSON>(
|
||||
`v1_alpha/collections/${id}`,
|
||||
collection,
|
||||
);
|
||||
|
||||
export const apiDeleteCollection = (collectionId: string) =>
|
||||
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
||||
|
||||
export const apiGetCollection = (collectionId: string) =>
|
||||
apiRequestGet<ApiCollectionWithAccountsJSON[]>(
|
||||
`v1_alpha/collections/${collectionId}`,
|
||||
);
|
||||
|
||||
export const apiGetAccountCollections = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/collections`,
|
||||
);
|
||||
82
app/javascript/mastodon/api_types/collections.ts
Normal file
82
app/javascript/mastodon/api_types/collections.ts
Normal file
@@ -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<CommonPayloadFields> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ApiCreateCollectionPayload extends CommonPayloadFields {
|
||||
account_ids?: string[];
|
||||
}
|
||||
179
app/javascript/mastodon/features/collections/index.tsx
Normal file
179
app/javascript/mastodon/features/collections/index.tsx
Normal file
@@ -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 (
|
||||
<div className='lists__item'>
|
||||
<Link to={`/collections/${id}`} className='lists__item__title'>
|
||||
<span>{name}</span>
|
||||
</Link>
|
||||
|
||||
<Dropdown
|
||||
scrollKey='collections'
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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' ? (
|
||||
<FormattedMessage
|
||||
id='collections.error_loading_collections'
|
||||
defaultMessage='There was an error when trying to load your collections.'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='collections.no_collections_yet'
|
||||
defaultMessage='No collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='collections.create_a_collection_hint'
|
||||
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<SquigglyArrow className='empty-column-indicator__arrow' />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
<Link
|
||||
to='/collections/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.create)}
|
||||
aria-label={intl.formatMessage(messages.create)}
|
||||
onClick={addDummyCollection}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='collections'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={status === 'loading'}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{collections.map((item) => (
|
||||
<ListItem key={item.id} id={item.id} name={item.name} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
11
app/javascript/mastodon/features/collections/utils.ts
Normal file
11
app/javascript/mastodon/features/collections/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
isClientFeatureEnabled,
|
||||
isServerFeatureEnabled,
|
||||
} from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
return (
|
||||
isClientFeatureEnabled('collections') &&
|
||||
isServerFeatureEnabled('collections')
|
||||
);
|
||||
}
|
||||
@@ -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 <Status /> 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 {
|
||||
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
{areCollectionsEnabled() &&
|
||||
<WrappedRoute path='/collections' component={Collections} content={children} />
|
||||
}
|
||||
|
||||
<Route component={BundleColumnError} />
|
||||
</WrappedSwitch>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
158
app/javascript/mastodon/reducers/slices/collections.ts
Normal file
158
app/javascript/mastodon/reducers/slices/collections.ts
Normal file
@@ -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<string, ApiCollectionJSON>;
|
||||
// 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<string, ApiCollectionJSON> = {};
|
||||
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;
|
||||
},
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { annualReport } from './annual_report';
|
||||
import { collections } from './collections';
|
||||
|
||||
export const sliceReducers = {
|
||||
annualReport,
|
||||
collections,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
%w(
|
||||
/blocks
|
||||
/bookmarks
|
||||
/collections/(*any)
|
||||
/conversations
|
||||
/deck/(*any)
|
||||
/directory
|
||||
|
||||
Reference in New Issue
Block a user