From 8ebe2e673e2fd175140df7275eb362c8eecfec31 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 4 Feb 2026 18:45:41 +0100 Subject: [PATCH] Split collection editor into dedicated routes (#37731) --- .../mastodon/api_types/collections.ts | 3 +- .../mastodon/components/form_fields/index.ts | 2 + .../mastodon/features/collections/editor.tsx | 268 ------------------ .../features/collections/editor/accounts.tsx | 67 +++++ .../features/collections/editor/details.tsx | 172 +++++++++++ .../features/collections/editor/index.tsx | 135 +++++++++ .../features/collections/editor/settings.tsx | 197 +++++++++++++ .../features/collections/editor/state.ts | 41 +++ .../collections/editor/styles.module.scss | 15 + .../collections/editor/wizard_step_header.tsx | 23 ++ .../mastodon/features/collections/index.tsx | 35 ++- app/javascript/mastodon/features/ui/index.jsx | 5 +- app/javascript/mastodon/locales/en.json | 19 +- 13 files changed, 699 insertions(+), 283 deletions(-) delete mode 100644 app/javascript/mastodon/features/collections/editor.tsx create mode 100644 app/javascript/mastodon/features/collections/editor/accounts.tsx create mode 100644 app/javascript/mastodon/features/collections/editor/details.tsx create mode 100644 app/javascript/mastodon/features/collections/editor/index.tsx create mode 100644 app/javascript/mastodon/features/collections/editor/settings.tsx create mode 100644 app/javascript/mastodon/features/collections/editor/state.ts create mode 100644 app/javascript/mastodon/features/collections/editor/styles.module.scss create mode 100644 app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index c1a17b5dc26..cded45f1a3b 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -70,7 +70,8 @@ type CommonPayloadFields = Pick< ApiCollectionJSON, 'name' | 'description' | 'sensitive' | 'discoverable' > & { - tag_name?: string; + tag_name?: string | null; + language?: ApiCollectionJSON['language']; }; export interface ApiUpdateCollectionPayload extends Partial { diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index f87626cb655..e44525e3837 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -1,6 +1,8 @@ export { FormStack } from './form_stack'; +export { Fieldset } from './fieldset'; export { TextInputField, TextInput } from './text_input_field'; export { TextAreaField, TextArea } from './text_area_field'; export { CheckboxField, Checkbox } from './checkbox_field'; +export { RadioButtonField, RadioButton } from './radio_button_field'; export { ToggleField, Toggle } from './toggle_field'; export { SelectField, Select } from './select_field'; diff --git a/app/javascript/mastodon/features/collections/editor.tsx b/app/javascript/mastodon/features/collections/editor.tsx deleted file mode 100644 index af301e121c5..00000000000 --- a/app/javascript/mastodon/features/collections/editor.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useCallback, useState, useEffect } from 'react'; - -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { useParams, useHistory } from 'react-router-dom'; - -import { isFulfilled } from '@reduxjs/toolkit'; - -import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; -import type { - ApiCollectionJSON, - ApiCreateCollectionPayload, - ApiUpdateCollectionPayload, -} from 'mastodon/api_types/collections'; -import { Button } from 'mastodon/components/button'; -import { Column } from 'mastodon/components/column'; -import { ColumnHeader } from 'mastodon/components/column_header'; -import { - CheckboxField, - FormStack, - TextAreaField, -} from 'mastodon/components/form_fields'; -import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { - createCollection, - fetchCollection, - updateCollection, -} from 'mastodon/reducers/slices/collections'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -const messages = defineMessages({ - edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' }, - create: { - id: 'column.create_collection', - defaultMessage: 'Create collection', - }, -}); - -const CollectionSettings: React.FC<{ - collection?: ApiCollectionJSON | null; -}> = ({ collection }) => { - const dispatch = useAppDispatch(); - const history = useHistory(); - - const { - id, - name: initialName = '', - description: initialDescription = '', - tag, - discoverable: initialDiscoverable = true, - sensitive: initialSensitive = false, - } = collection ?? {}; - - const [name, setName] = useState(initialName); - const [description, setDescription] = useState(initialDescription); - const [topic, setTopic] = useState(tag?.name ?? ''); - const [discoverable] = useState(initialDiscoverable); - const [sensitive, setSensitive] = useState(initialSensitive); - - const handleNameChange = useCallback( - (event: React.ChangeEvent) => { - setName(event.target.value); - }, - [], - ); - - const handleDescriptionChange = useCallback( - (event: React.ChangeEvent) => { - setDescription(event.target.value); - }, - [], - ); - - const handleTopicChange = useCallback( - (event: React.ChangeEvent) => { - setTopic(event.target.value); - }, - [], - ); - - const handleSensitiveChange = useCallback( - (event: React.ChangeEvent) => { - setSensitive(event.target.checked); - }, - [], - ); - - const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - - if (id) { - const payload: ApiUpdateCollectionPayload = { - id, - name, - description, - tag_name: topic, - discoverable, - sensitive, - }; - - void dispatch(updateCollection({ payload })).then(() => { - history.push(`/collections`); - }); - } else { - const payload: ApiCreateCollectionPayload = { - name, - description, - discoverable, - sensitive, - }; - if (topic) { - payload.tag_name = topic; - } - - void dispatch( - createCollection({ - payload, - }), - ).then((result) => { - if (isFulfilled(result)) { - history.replace( - `/collections/${result.payload.collection.id}/edit`, - ); - history.push(`/collections`); - } - }); - } - }, - [id, dispatch, name, description, topic, discoverable, sensitive, history], - ); - - return ( - - - } - hint={ - - } - value={name} - onChange={handleNameChange} - maxLength={40} - /> - - - } - hint={ - - } - value={description} - onChange={handleDescriptionChange} - maxLength={100} - /> - - - } - hint={ - - } - value={topic} - onChange={handleTopicChange} - maxLength={40} - /> - - - } - hint={ - - } - checked={sensitive} - onChange={handleSensitiveChange} - /> - -
- -
-
- ); -}; - -export const CollectionEditorPage: React.FC<{ - multiColumn?: boolean; -}> = ({ multiColumn }) => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const { id } = useParams<{ id?: string }>(); - const collection = useAppSelector((state) => - id ? state.collections.collections[id] : undefined, - ); - const isEditMode = !!id; - const isLoading = isEditMode && !collection; - - useEffect(() => { - if (id) { - void dispatch(fetchCollection({ collectionId: id })); - } - }, [dispatch, id]); - - const pageTitle = intl.formatMessage(id ? messages.edit : messages.create); - - return ( - - - -
- {isLoading ? ( - - ) : ( - - )} -
- - - {pageTitle} - - -
- ); -}; diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx new file mode 100644 index 00000000000..7e78874765d --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useHistory, useLocation } from 'react-router-dom'; + +import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; +import { Button } from 'mastodon/components/button'; +import { FormStack } from 'mastodon/components/form_fields'; + +import type { TempCollectionState } from './state'; +import { getInitialState } from './state'; +import { WizardStepHeader } from './wizard_step_header'; + +export const CollectionAccounts: React.FC<{ + collection?: ApiCollectionJSON | null; +}> = ({ collection }) => { + const history = useHistory(); + const location = useLocation(); + + const { id } = getInitialState(collection, location.state); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (!id) { + history.push(`/collections/new/details`); + } + }, + [id, history], + ); + + return ( + + {!id && ( + + } + description={ + + } + /> + )} +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx new file mode 100644 index 00000000000..65c00b5f263 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -0,0 +1,172 @@ +import { useCallback, useState } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useHistory, useLocation } from 'react-router-dom'; + +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, +} from 'mastodon/api_types/collections'; +import { Button } from 'mastodon/components/button'; +import { FormStack, TextAreaField } from 'mastodon/components/form_fields'; +import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; +import { updateCollection } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch } from 'mastodon/store'; + +import type { TempCollectionState } from './state'; +import { getInitialState } from './state'; +import { WizardStepHeader } from './wizard_step_header'; + +export const CollectionDetails: React.FC<{ + collection?: ApiCollectionJSON | null; +}> = ({ collection }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + const location = useLocation(); + + const { id, initialName, initialDescription, initialTopic } = getInitialState( + collection, + location.state, + ); + + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [topic, setTopic] = useState(initialTopic); + + const handleNameChange = useCallback( + (event: React.ChangeEvent) => { + setName(event.target.value); + }, + [], + ); + + const handleDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + setDescription(event.target.value); + }, + [], + ); + + const handleTopicChange = useCallback( + (event: React.ChangeEvent) => { + setTopic(event.target.value); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (id) { + const payload: ApiUpdateCollectionPayload = { + id, + name, + description, + tag_name: topic || null, + }; + + void dispatch(updateCollection({ payload })).then(() => { + history.push(`/collections`); + }); + } else { + const payload: Partial = { + name, + description, + tag_name: topic || null, + }; + + history.replace('/collections/new', payload); + history.push('/collections/new/settings', payload); + } + }, + [id, dispatch, name, description, topic, history], + ); + + return ( + + {!id && ( + + } + /> + )} + + } + hint={ + + } + value={name} + onChange={handleNameChange} + maxLength={40} + /> + + + } + hint={ + + } + value={description} + onChange={handleDescriptionChange} + maxLength={100} + /> + + + } + hint={ + + } + value={topic} + onChange={handleTopicChange} + maxLength={40} + /> + +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx new file mode 100644 index 00000000000..ad378e3a436 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/index.tsx @@ -0,0 +1,135 @@ +import { useEffect } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { + Switch, + Route, + useParams, + useRouteMatch, + matchPath, + useLocation, +} from 'react-router-dom'; + +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { fetchCollection } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { CollectionAccounts } from './accounts'; +import { CollectionDetails } from './details'; +import { CollectionSettings } from './settings'; + +export const messages = defineMessages({ + create: { + id: 'collections.create_collection', + defaultMessage: 'Create collection', + }, + newCollection: { + id: 'collections.new_collection', + defaultMessage: 'New collection', + }, + editDetails: { + id: 'collections.edit_details', + defaultMessage: 'Edit basic details', + }, + manageAccounts: { + id: 'collections.manage_accounts', + defaultMessage: 'Manage accounts', + }, + manageAccountsLong: { + id: 'collections.manage_accounts_in_collection', + defaultMessage: 'Manage accounts in this collection', + }, + editSettings: { + id: 'collections.edit_settings', + defaultMessage: 'Edit settings', + }, +}); + +function usePageTitle(id: string | undefined) { + const { path } = useRouteMatch(); + const location = useLocation(); + + if (!id) { + return messages.newCollection; + } + + if (matchPath(location.pathname, { path, exact: true })) { + return messages.manageAccounts; + } else if (matchPath(location.pathname, { path: `${path}/details` })) { + return messages.editDetails; + } else if (matchPath(location.pathname, { path: `${path}/settings` })) { + return messages.editSettings; + } else { + throw new Error('No page title defined for route'); + } +} + +export const CollectionEditorPage: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const { path } = useRouteMatch(); + const collection = useAppSelector((state) => + id ? state.collections.collections[id] : undefined, + ); + const isEditMode = !!id; + const isLoading = isEditMode && !collection; + + useEffect(() => { + if (id) { + void dispatch(fetchCollection({ collectionId: id })); + } + }, [dispatch, id]); + + const pageTitle = intl.formatMessage(usePageTitle(id)); + + return ( + + + +
+ {isLoading ? ( + + ) : ( + + } + /> + } + /> + } + /> + + )} +
+ + + {pageTitle} + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/editor/settings.tsx b/app/javascript/mastodon/features/collections/editor/settings.tsx new file mode 100644 index 00000000000..22e96448f48 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/settings.tsx @@ -0,0 +1,197 @@ +import { useCallback, useState } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useHistory, useLocation } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, +} from 'mastodon/api_types/collections'; +import { Button } from 'mastodon/components/button'; +import { + Fieldset, + FormStack, + CheckboxField, + RadioButtonField, +} from 'mastodon/components/form_fields'; +import { + createCollection, + updateCollection, +} from 'mastodon/reducers/slices/collections'; +import { useAppDispatch } from 'mastodon/store'; + +import type { TempCollectionState } from './state'; +import { getInitialState } from './state'; +import { WizardStepHeader } from './wizard_step_header'; + +export const CollectionSettings: React.FC<{ + collection?: ApiCollectionJSON | null; +}> = ({ collection }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + const location = useLocation(); + + const { id, initialDiscoverable, initialSensitive, ...temporaryState } = + getInitialState(collection, location.state); + + const [discoverable, setDiscoverable] = useState(initialDiscoverable); + const [sensitive, setSensitive] = useState(initialSensitive); + + const handleDiscoverableChange = useCallback( + (event: React.ChangeEvent) => { + setDiscoverable(event.target.value === 'public'); + }, + [], + ); + + const handleSensitiveChange = useCallback( + (event: React.ChangeEvent) => { + setSensitive(event.target.checked); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (id) { + const payload: ApiUpdateCollectionPayload = { + id, + discoverable, + sensitive, + }; + + void dispatch(updateCollection({ payload })).then(() => { + history.push(`/collections`); + }); + } else { + const payload: ApiCreateCollectionPayload = { + name: temporaryState.initialName, + description: temporaryState.initialDescription, + discoverable, + sensitive, + }; + if (temporaryState.initialTopic) { + payload.tag_name = temporaryState.initialTopic; + } + + void dispatch( + createCollection({ + payload, + }), + ).then((result) => { + if (isFulfilled(result)) { + history.replace( + `/collections/${result.payload.collection.id}/edit/settings`, + ); + history.push(`/collections`); + } + }); + } + }, + [id, discoverable, sensitive, dispatch, history, temporaryState], + ); + + return ( + + {!id && ( + + } + /> + )} +
+ } + > + + } + hint={ + + } + value='public' + checked={discoverable} + onChange={handleDiscoverableChange} + /> + + } + hint={ + + } + value='unlisted' + checked={!discoverable} + onChange={handleDiscoverableChange} + /> +
+ +
+ } + > + + } + hint={ + + } + checked={sensitive} + onChange={handleSensitiveChange} + /> +
+ +
+ +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/editor/state.ts b/app/javascript/mastodon/features/collections/editor/state.ts new file mode 100644 index 00000000000..18566776518 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/state.ts @@ -0,0 +1,41 @@ +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, +} from '@/mastodon/api_types/collections'; + +/** + * Temporary editor state across creation steps, + * kept in location state + */ +export type TempCollectionState = + | Partial + | undefined; + +/** + * Resolve initial editor state. Temporary location state + * trumps stored data, otherwise initial values are returned. + */ +export function getInitialState( + collection: ApiCollectionJSON | null | undefined, + locationState: TempCollectionState, +) { + const { + id, + name = '', + description = '', + tag, + language = '', + discoverable = true, + sensitive = false, + } = collection ?? {}; + + return { + id, + initialName: locationState?.name ?? name, + initialDescription: locationState?.description ?? description, + initialTopic: locationState?.tag_name ?? tag?.name ?? '', + initialLanguage: locationState?.language ?? language, + initialDiscoverable: locationState?.discoverable ?? discoverable, + initialSensitive: locationState?.sensitive ?? sensitive, + }; +} diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss new file mode 100644 index 00000000000..5dc942659b7 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss @@ -0,0 +1,15 @@ +.step { + font-size: 13px; + color: var(--color-text-secondary); +} + +.title { + font-size: 22px; + line-height: 1.2; + margin-top: 4px; +} + +.description { + font-size: 15px; + margin-top: 8px; +} diff --git a/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx b/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx new file mode 100644 index 00000000000..dcf0ed4a3f0 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx @@ -0,0 +1,23 @@ +import { FormattedMessage } from 'react-intl'; + +import classes from './styles.module.scss'; + +export const WizardStepHeader: React.FC<{ + step: number; + title: React.ReactElement; + description?: React.ReactElement; +}> = ({ step, title, description }) => { + return ( +
+ + {(content) =>

{content}

} +
+

{title}

+ {!!description &&

{description}

} +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index bd1c4f790bb..0a587aedf47 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -21,12 +21,10 @@ import { } from 'mastodon/reducers/slices/collections'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { messages as editorMessages } from './editor'; + 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', @@ -60,14 +58,35 @@ const ListItem: React.FC<{ const menu = useMemo( () => [ { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, - { text: intl.formatMessage(messages.delete), action: handleDeleteClick }, + null, + { + text: intl.formatMessage(editorMessages.manageAccounts), + to: `/collections/${id}/edit`, + }, + { + text: intl.formatMessage(editorMessages.editDetails), + to: `/collections/${id}/edit/details`, + }, + { + text: intl.formatMessage(editorMessages.editSettings), + to: `/collections/${id}/edit/settings`, + }, + null, + { + text: intl.formatMessage(messages.delete), + action: handleDeleteClick, + dangerous: true, + }, ], [intl, id, handleDeleteClick], ); return (
- + {name} @@ -132,8 +151,8 @@ export const Collections: React.FC<{ diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 5ba78f599a8..abe09e81a49 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -231,10 +231,7 @@ class SwitchingColumnsArea extends PureComponent { {areCollectionsEnabled() && - - } - {areCollectionsEnabled() && - + } {areCollectionsEnabled() && diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index b722b218982..e89efa84d5d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -236,28 +236,43 @@ "collections.collection_description": "Description", "collections.collection_name": "Name", "collections.collection_topic": "Topic", + "collections.content_warning": "Content warning", + "collections.continue": "Continue", + "collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.", + "collections.create.accounts_title": "Who will you feature in this collection?", + "collections.create.basic_details_title": "Basic details", + "collections.create.settings_title": "Settings", + "collections.create.steps": "Step {step}/{total}", "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.description_length_hint": "100 characters limit", + "collections.edit_details": "Edit basic details", + "collections.edit_settings": "Edit settings", "collections.error_loading_collections": "There was an error when trying to load your collections.", + "collections.manage_accounts": "Manage accounts", + "collections.manage_accounts_in_collection": "Manage accounts in this collection", "collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.name_length_hint": "100 characters limit", + "collections.new_collection": "New collection", "collections.no_collections_yet": "No collections yet.", "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.view_collection": "View collection", + "collections.visibility_public": "Public", + "collections.visibility_public_hint": "Discoverable in search results and other areas where recommendations appear.", + "collections.visibility_title": "Visibility", + "collections.visibility_unlisted": "Unlisted", + "collections.visibility_unlisted_hint": "Visible to anyone with a link. Hidden from search results and recommendations.", "column.about": "About", "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", "column.collections": "My collections", "column.community": "Local timeline", - "column.create_collection": "Create collection", "column.create_list": "Create list", "column.direct": "Private mentions", "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", - "column.edit_collection": "Edit collection", "column.edit_list": "Edit list", "column.favourites": "Favorites", "column.firehose": "Live feeds",