From ef6405ab28e02686c0c9aa69d77eb43909be5f6f Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 23 Feb 2026 16:53:49 +0100 Subject: [PATCH] Profile editing: Featured tags (#37952) --- app/javascript/mastodon/api/accounts.ts | 21 ++- app/javascript/mastodon/api_types/tags.ts | 20 +- .../components/form_fields/combobox_field.tsx | 4 +- .../account_edit/components/column.tsx | 57 ++++++ .../account_edit/components/edit_button.tsx | 100 ++++++++++ .../account_edit/components/item_list.tsx | 89 +++++++++ .../account_edit/components/section.tsx | 27 +-- .../account_edit/components/tag_search.tsx | 60 ++++++ .../features/account_edit/featured_tags.tsx | 117 ++++++++++++ .../mastodon/features/account_edit/index.tsx | 107 ++++++----- .../features/account_edit/styles.module.scss | 160 ++++++++++++---- app/javascript/mastodon/features/ui/index.jsx | 24 ++- .../features/ui/util/async-components.js | 5 + app/javascript/mastodon/locales/en.json | 10 +- .../mastodon/reducers/slices/index.ts | 2 + .../mastodon/reducers/slices/profile_edit.ts | 178 ++++++++++++++++++ .../mastodon/reducers/user_lists.js | 3 +- 17 files changed, 869 insertions(+), 115 deletions(-) create mode 100644 app/javascript/mastodon/features/account_edit/components/column.tsx create mode 100644 app/javascript/mastodon/features/account_edit/components/edit_button.tsx create mode 100644 app/javascript/mastodon/features/account_edit/components/item_list.tsx create mode 100644 app/javascript/mastodon/features/account_edit/components/tag_search.tsx create mode 100644 app/javascript/mastodon/features/account_edit/featured_tags.tsx create mode 100644 app/javascript/mastodon/reducers/slices/profile_edit.ts diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index fb99978cada..9c35d619a4c 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -1,10 +1,13 @@ -import { apiRequestPost, apiRequestGet } from 'mastodon/api'; +import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api'; import type { ApiAccountJSON, ApiFamiliarFollowersJSON, } from 'mastodon/api_types/accounts'; import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships'; -import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; +import type { + ApiFeaturedTagJSON, + ApiHashtagJSON, +} from 'mastodon/api_types/tags'; export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { @@ -30,7 +33,19 @@ export const apiRemoveAccountFromFollowers = (id: string) => ); export const apiGetFeaturedTags = (id: string) => - apiRequestGet(`v1/accounts/${id}/featured_tags`); + apiRequestGet(`v1/accounts/${id}/featured_tags`); + +export const apiGetCurrentFeaturedTags = () => + apiRequestGet(`v1/featured_tags`); + +export const apiPostFeaturedTag = (name: string) => + apiRequestPost('v1/featured_tags', { name }); + +export const apiDeleteFeaturedTag = (id: string) => + apiRequestDelete(`v1/featured_tags/${id}`); + +export const apiGetTagSuggestions = () => + apiRequestGet('v1/featured_tags/suggestions'); export const apiGetEndorsedAccounts = (id: string) => apiRequestGet(`v1/accounts/${id}/endorsements`); diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 3066b4f1f1b..01d7f9e4b61 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -4,11 +4,29 @@ interface ApiHistoryJSON { uses: string; } -export interface ApiHashtagJSON { +interface ApiHashtagBase { id: string; name: string; url: string; +} + +export interface ApiHashtagJSON extends ApiHashtagBase { history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; following?: boolean; featuring?: boolean; } + +export interface ApiFeaturedTagJSON extends ApiHashtagBase { + statuses_count: number; + last_status_at: string | null; +} + +export function hashtagToFeaturedTag(tag: ApiHashtagJSON): ApiFeaturedTagJSON { + return { + id: tag.id, + name: tag.name, + url: tag.url, + statuses_count: 0, + last_status_at: null, + }; +} diff --git a/app/javascript/mastodon/components/form_fields/combobox_field.tsx b/app/javascript/mastodon/components/form_fields/combobox_field.tsx index 7e274d3a67a..0c3af808838 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -49,7 +49,7 @@ interface ComboboxProps extends TextInputProps { /** * A function that must return a unique id for each option passed via `items` */ - getItemId: (item: T) => string; + getItemId?: (item: T) => string; /** * Providing this function turns the combobox into a multi-select box that assumes * multiple options to be selectable. Single-selection is handled automatically. @@ -113,7 +113,7 @@ const ComboboxWithRef = ( value, isLoading = false, items, - getItemId, + getItemId = (item) => item.id, getIsItemDisabled, getIsItemSelected, disabled, diff --git a/app/javascript/mastodon/features/account_edit/components/column.tsx b/app/javascript/mastodon/features/account_edit/components/column.tsx new file mode 100644 index 00000000000..5f0ad929a1b --- /dev/null +++ b/app/javascript/mastodon/features/account_edit/components/column.tsx @@ -0,0 +1,57 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { Column } from '@/mastodon/components/column'; +import { ColumnHeader } from '@/mastodon/components/column_header'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; + +import { useColumnsContext } from '../../ui/util/columns_context'; +import classes from '../styles.module.scss'; + +export const AccountEditEmptyColumn: FC<{ + notFound?: boolean; +}> = ({ notFound }) => { + const { multiColumn } = useColumnsContext(); + + if (notFound) { + return ; + } + + return ( + + + + ); +}; + +export const AccountEditColumn: FC<{ + title: string; + to: string; + children: React.ReactNode; +}> = ({ to, title, children }) => { + const { multiColumn } = useColumnsContext(); + + return ( + + + + + } + /> + + {children} + + ); +}; diff --git a/app/javascript/mastodon/features/account_edit/components/edit_button.tsx b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx new file mode 100644 index 00000000000..f2fecf21d0c --- /dev/null +++ b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx @@ -0,0 +1,100 @@ +import type { FC, MouseEventHandler } from 'react'; + +import type { MessageDescriptor } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { Button } from '@/mastodon/components/button'; +import { IconButton } from '@/mastodon/components/icon_button'; +import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; +import EditIcon from '@/material-icons/400-24px/edit.svg?react'; + +import classes from '../styles.module.scss'; + +const messages = defineMessages({ + add: { + id: 'account_edit.button.add', + defaultMessage: 'Add {item}', + }, + edit: { + id: 'account_edit.button.edit', + defaultMessage: 'Edit {item}', + }, + delete: { + id: 'account_edit.button.delete', + defaultMessage: 'Delete {item}', + }, +}); + +export interface EditButtonProps { + onClick: MouseEventHandler; + item: string | MessageDescriptor; + edit?: boolean; + icon?: boolean; + disabled?: boolean; +} + +export const EditButton: FC = ({ + onClick, + item, + edit = false, + icon = edit, + disabled, +}) => { + const intl = useIntl(); + + const itemText = typeof item === 'string' ? item : intl.formatMessage(item); + const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], { + item: itemText, + }); + + if (icon) { + return ( + + ); + } + + return ( + + ); +}; + +export const EditIconButton: FC<{ + onClick: MouseEventHandler; + title: string; + disabled?: boolean; +}> = ({ title, onClick, disabled }) => ( + +); + +export const DeleteIconButton: FC<{ + onClick: MouseEventHandler; + item: string; + disabled?: boolean; +}> = ({ onClick, item, disabled }) => { + const intl = useIntl(); + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_edit/components/item_list.tsx b/app/javascript/mastodon/features/account_edit/components/item_list.tsx new file mode 100644 index 00000000000..eb6cf590f5e --- /dev/null +++ b/app/javascript/mastodon/features/account_edit/components/item_list.tsx @@ -0,0 +1,89 @@ +import { useCallback } from 'react'; + +import classes from '../styles.module.scss'; + +import { DeleteIconButton, EditButton } from './edit_button'; + +interface AnyItem { + id: string; + name: string; +} + +interface AccountEditItemListProps { + renderItem?: (item: Item) => React.ReactNode; + items: Item[]; + onEdit?: (item: Item) => void; + onDelete?: (item: Item) => void; + disabled?: boolean; +} + +export const AccountEditItemList = ({ + renderItem, + items, + onEdit, + onDelete, + disabled, +}: AccountEditItemListProps) => { + if (items.length === 0) { + return null; + } + + return ( +
    + {items.map((item) => ( +
  • + {renderItem?.(item) ?? item.name} + +
  • + ))} +
+ ); +}; + +type AccountEditItemButtonsProps = Pick< + AccountEditItemListProps, + 'onEdit' | 'onDelete' | 'disabled' +> & { item: Item }; + +const AccountEditItemButtons = ({ + item, + onDelete, + onEdit, + disabled, +}: AccountEditItemButtonsProps) => { + const handleEdit = useCallback(() => { + onEdit?.(item); + }, [item, onEdit]); + const handleDelete = useCallback(() => { + onDelete?.(item); + }, [item, onDelete]); + + if (!onEdit && !onDelete) { + return null; + } + + return ( +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/features/account_edit/components/section.tsx b/app/javascript/mastodon/features/account_edit/components/section.tsx index 21046b601d0..49643b51b1c 100644 --- a/app/javascript/mastodon/features/account_edit/components/section.tsx +++ b/app/javascript/mastodon/features/account_edit/components/section.tsx @@ -1,55 +1,36 @@ import type { FC, ReactNode } from 'react'; import type { MessageDescriptor } from 'react-intl'; -import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { IconButton } from '@/mastodon/components/icon_button'; -import EditIcon from '@/material-icons/400-24px/edit.svg?react'; - import classes from '../styles.module.scss'; -const buttonMessage = defineMessage({ - id: 'account_edit.section_edit_button', - defaultMessage: 'Edit', -}); - interface AccountEditSectionProps { title: MessageDescriptor; description?: MessageDescriptor; showDescription?: boolean; - onEdit?: () => void; children?: ReactNode; className?: string; - extraButtons?: ReactNode; + buttons?: ReactNode; } export const AccountEditSection: FC = ({ title, description, showDescription, - onEdit, children, className, - extraButtons, + buttons, }) => { - const intl = useIntl(); return (

- {onEdit && ( - - )} - {extraButtons} + {buttons}
{showDescription && (

diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx new file mode 100644 index 00000000000..78eb9814020 --- /dev/null +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -0,0 +1,60 @@ +import type { ChangeEventHandler, FC } from 'react'; +import { useCallback } from 'react'; + +import { useIntl } from 'react-intl'; + +import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; +import { Combobox } from '@/mastodon/components/form_fields'; +import { + addFeaturedTag, + clearSearch, + updateSearchQuery, +} from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import SearchIcon from '@/material-icons/400-24px/search.svg?react'; + +import classes from '../styles.module.scss'; + +export const AccountEditTagSearch: FC = () => { + const { query, isLoading, results } = useAppSelector( + (state) => state.profileEdit.search, + ); + + const dispatch = useAppDispatch(); + const handleSearchChange: ChangeEventHandler = useCallback( + (e) => { + void dispatch(updateSearchQuery(e.target.value)); + }, + [dispatch], + ); + + const intl = useIntl(); + + const handleSelect = useCallback( + (item: ApiFeaturedTagJSON) => { + void dispatch(clearSearch()); + void dispatch(addFeaturedTag({ name: item.name })); + }, + [dispatch], + ); + + return ( + + ); +}; + +const renderItem = (item: ApiFeaturedTagJSON) =>

#{item.name}

; diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx new file mode 100644 index 00000000000..a123b90c57a --- /dev/null +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import { Tag } from '@/mastodon/components/tags/tag'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import { + addFeaturedTag, + deleteFeaturedTag, + fetchFeaturedTags, + fetchSuggestedTags, +} from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; +import { AccountEditItemList } from './components/item_list'; +import { AccountEditTagSearch } from './components/tag_search'; +import classes from './styles.module.scss'; + +const messages = defineMessages({ + columnTitle: { + id: 'account_edit_tags.column_title', + defaultMessage: 'Edit featured hashtags', + }, +}); + +export const AccountEditFeaturedTags: FC = () => { + const accountId = useCurrentAccountId(); + const account = useAccount(accountId); + const intl = useIntl(); + + const { tags, tagSuggestions, isLoading, isPending } = useAppSelector( + (state) => state.profileEdit, + ); + + const dispatch = useAppDispatch(); + useEffect(() => { + void dispatch(fetchFeaturedTags()); + void dispatch(fetchSuggestedTags()); + }, [dispatch]); + + const handleDeleteTag = useCallback( + ({ id }: { id: string }) => { + void dispatch(deleteFeaturedTag({ tagId: id })); + }, + [dispatch], + ); + + if (!accountId || !account) { + return ; + } + + return ( + +
+ + + {tagSuggestions.length > 0 && ( +
+ + {tagSuggestions.map((tag) => ( + + ))} +
+ )} + {isLoading && } + +
+
+ ); +}; + +function renderTag(tag: ApiFeaturedTagJSON) { + return ( +
+

#{tag.name}

+ {tag.statuses_count > 0 && ( + + )} +
+ ); +} + +const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({ + name, + disabled, +}) => { + const dispatch = useAppDispatch(); + const handleAddTag = useCallback(() => { + void dispatch(addFeaturedTag({ name })); + }, [dispatch, name]); + return ; +}; diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index dc48641f373..2673c5363f5 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -1,28 +1,31 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import type { FC } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import type { ModalType } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal'; import { AccountBio } from '@/mastodon/components/account_bio'; import { Avatar } from '@/mastodon/components/avatar'; -import { Column } from '@/mastodon/components/column'; -import { ColumnHeader } from '@/mastodon/components/column_header'; import { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; -import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; -import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { autoPlayGif } from '@/mastodon/initial_state'; -import { useAppDispatch } from '@/mastodon/store'; +import { fetchFeaturedTags } from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; +import { EditButton } from './components/edit_button'; import { AccountEditSection } from './components/section'; import classes from './styles.module.scss'; const messages = defineMessages({ + columnTitle: { + id: 'account_edit.column_title', + defaultMessage: 'Edit Profile', + }, displayNameTitle: { id: 'account_edit.display_name.title', defaultMessage: 'Display name', @@ -58,6 +61,10 @@ const messages = defineMessages({ defaultMessage: 'Help others identify, and have quick access to, your favorite topics.', }, + featuredHashtagsItem: { + id: 'account_edit.featured_hashtags.item', + defaultMessage: 'hashtags', + }, profileTabTitle: { id: 'account_edit.profile_tab.title', defaultMessage: 'Profile tab settings', @@ -68,12 +75,20 @@ const messages = defineMessages({ }, }); -export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { +export const AccountEdit: FC = () => { const accountId = useCurrentAccountId(); const account = useAccount(accountId); const intl = useIntl(); const dispatch = useAppDispatch(); + + const { tags: featuredTags, isLoading: isTagsLoading } = useAppSelector( + (state) => state.profileEdit, + ); + useEffect(() => { + void dispatch(fetchFeaturedTags()); + }, [dispatch]); + const handleOpenModal = useCallback( (type: ModalType, props?: Record) => { dispatch(openModal({ modalType: type, modalProps: props ?? {} })); @@ -87,38 +102,25 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { handleOpenModal('ACCOUNT_EDIT_BIO'); }, [handleOpenModal]); - if (!accountId) { - return ; - } + const history = useHistory(); + const handleFeaturedTagsEdit = useCallback(() => { + history.push('/profile/featured_tags'); + }, [history]); - if (!account) { - return ( - - - - ); + if (!accountId || !account) { + return ; } const headerSrc = autoPlayGif ? account.header : account.header_static; + const hasName = !!account.display_name; + const hasBio = !!account.note_plain; + const hasTags = !isTagsLoading && featuredTags.length > 0; return ( - - - - - } - /> +
{headerSrc && } @@ -129,8 +131,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + } > @@ -138,8 +146,14 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + } > @@ -153,14 +167,23 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + showDescription={!hasTags} + buttons={ + + } + > + {featuredTags.map((tag) => `#${tag.name}`).join(', ')} + - + ); }; diff --git a/app/javascript/mastodon/features/account_edit/styles.module.scss b/app/javascript/mastodon/features/account_edit/styles.module.scss index 8af244d38b7..ee8603cc4f2 100644 --- a/app/javascript/mastodon/features/account_edit/styles.module.scss +++ b/app/javascript/mastodon/features/account_edit/styles.module.scss @@ -1,15 +1,4 @@ -.column { - border: 1px solid var(--color-border-primary); - border-top-width: 0; -} - -.columnHeader { - :global(.column-header__buttons) { - align-items: center; - padding-inline-end: 16px; - height: auto; - } -} +// Profile Edit Page .profileImage { height: 120px; @@ -35,41 +24,42 @@ border: 1px solid var(--color-border-primary); } -.section { - padding: 20px; - border-bottom: 1px solid var(--color-border-primary); - font-size: 15px; +// Featured Tags Page + +.wrapper { + padding: 24px; } -.sectionHeader { +.autoComplete, +.tagSuggestions { + margin: 12px 0; +} + +.tagSuggestions { display: flex; + gap: 4px; + flex-wrap: wrap; align-items: center; - gap: 8px; - margin-bottom: 8px; - > button { - border: 1px solid var(--color-border-primary); - border-radius: 8px; - box-sizing: border-box; - padding: 4px; - - svg { - width: 20px; - height: 20px; - } + // Add more padding to the suggestions label + > span { + margin-right: 4px; } } -.sectionTitle { - flex-grow: 1; - font-size: 17px; - font-weight: 600; -} +.tagItem { + > h4 { + font-size: 15px; + font-weight: 500; + } -.sectionSubtitle { - color: var(--color-text-secondary); + > p { + color: var(--color-text-secondary); + } } +// Modals + .inputWrapper { position: relative; } @@ -100,6 +90,104 @@ textarea.inputText { } } +// Column component + +.column { + border: 1px solid var(--color-border-primary); + border-top-width: 0; +} + +.columnHeader { + :global(.column-header__buttons) { + align-items: center; + padding-inline-end: 16px; + height: auto; + } +} + +// Edit button component + +.editButton { + border: 1px solid var(--color-border-primary); + border-radius: 8px; + box-sizing: border-box; + padding: 4px; + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + + &:global(.button) { + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 13px; + padding: 4px 8px; + + &:active, + &:focus, + &:hover { + background-color: var(--color-bg-brand-softer); + } + } + + svg { + width: 20px; + height: 20px; + } +} + +.deleteButton { + --default-icon-color: var(--color-text-error); + --hover-bg-color: var(--color-bg-error-base-hover); + --hover-icon-color: var(--color-text-on-error-base); +} + +// Item list component + +.itemList { + > li { + display: flex; + align-items: center; + padding: 12px 0; + + > :first-child { + flex-grow: 1; + } + } +} + +.itemListButtons { + display: flex; + align-items: center; + gap: 4px; +} + +// Section component + +.section { + padding: 20px; + border-bottom: 1px solid var(--color-border-primary); + font-size: 15px; +} + +.sectionHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.sectionTitle { + flex-grow: 1; + font-size: 17px; + font-weight: 600; +} + +.sectionSubtitle { + color: var(--color-text-secondary); +} + +// Counter component + .counter { margin-top: 4px; font-size: 13px; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 5a9cebe5f4b..9e61158f149 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -82,6 +82,7 @@ import { AccountFeatured, AccountAbout, AccountEdit, + AccountEditFeaturedTags, Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; @@ -164,9 +165,8 @@ class SwitchingColumnsArea extends PureComponent { redirect = ; } - const profileRedesignEnabled = isServerFeatureEnabled('profile_redesign'); const profileRedesignRoutes = []; - if (profileRedesignEnabled) { + if (isServerFeatureEnabled('profile_redesign')) { profileRedesignRoutes.push( , ); @@ -188,13 +188,27 @@ class SwitchingColumnsArea extends PureComponent { ); } } else { - // If the redesign is not enabled but someone shares an /about link, redirect to the root. profileRedesignRoutes.push( + , + // If the redesign is not enabled but someone shares an /about link, redirect to the root. , ); } + if (isClientFeatureEnabled('profile_editing')) { + profileRedesignRoutes.push( + , + + ) + } else { + // If profile editing is not enabled, redirect to the home timeline as the current editing pages are outside React Router. + profileRedesignRoutes.push( + , + , + ); + } + return ( @@ -234,8 +248,6 @@ class SwitchingColumnsArea extends PureComponent { - {isClientFeatureEnabled('profile_editing') && } - @@ -243,8 +255,8 @@ class SwitchingColumnsArea extends PureComponent { - {!profileRedesignEnabled && } {...profileRedesignRoutes} + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 2beedaba264..20ed6a6969d 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -103,6 +103,11 @@ export function AccountEdit() { .then((module) => ({ default: module.AccountEdit })); } +export function AccountEditFeaturedTags() { + return import('../../account_edit/featured_tags') + .then((module) => ({ default: module.AccountEditFeaturedTags })); +} + export function Followers () { return import('../../followers'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index c3dd1c010d8..a157f0efe92 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -145,6 +145,9 @@ "account_edit.bio.title": "Bio", "account_edit.bio_modal.add_title": "Add bio", "account_edit.bio_modal.edit_title": "Edit bio", + "account_edit.button.add": "Add {item}", + "account_edit.button.delete": "Delete {item}", + "account_edit.button.edit": "Edit {item}", "account_edit.char_counter": "{currentLength}/{maxLength} characters", "account_edit.column_button": "Done", "account_edit.column_title": "Edit Profile", @@ -152,6 +155,7 @@ "account_edit.custom_fields.title": "Custom fields", "account_edit.display_name.placeholder": "Your display name is how your name appears on your profile and in timelines.", "account_edit.display_name.title": "Display name", + "account_edit.featured_hashtags.item": "hashtags", "account_edit.featured_hashtags.placeholder": "Help others identify, and have quick access to, your favorite topics.", "account_edit.featured_hashtags.title": "Featured hashtags", "account_edit.name_modal.add_title": "Add display name", @@ -159,7 +163,11 @@ "account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "account_edit.profile_tab.title": "Profile tab settings", "account_edit.save": "Save", - "account_edit.section_edit_button": "Edit", + "account_edit_tags.column_title": "Edit featured hashtags", + "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.", + "account_edit_tags.search_placeholder": "Enter a hashtag…", + "account_edit_tags.suggestions": "Suggestions:", + "account_edit_tags.tag_status_count": "{count} posts", "account_note.placeholder": "Click to add note", "admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up", diff --git a/app/javascript/mastodon/reducers/slices/index.ts b/app/javascript/mastodon/reducers/slices/index.ts index 06a384d5629..8d4ecd552d3 100644 --- a/app/javascript/mastodon/reducers/slices/index.ts +++ b/app/javascript/mastodon/reducers/slices/index.ts @@ -1,7 +1,9 @@ import { annualReport } from './annual_report'; import { collections } from './collections'; +import { profileEdit } from './profile_edit'; export const sliceReducers = { annualReport, collections, + profileEdit, }; diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts new file mode 100644 index 00000000000..c9663252039 --- /dev/null +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -0,0 +1,178 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import { debounce } from 'lodash'; + +import { + apiDeleteFeaturedTag, + apiGetCurrentFeaturedTags, + apiGetTagSuggestions, + apiPostFeaturedTag, +} from '@/mastodon/api/accounts'; +import { apiGetSearch } from '@/mastodon/api/search'; +import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags'; +import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; +import type { AppDispatch } from '@/mastodon/store'; +import { + createAppAsyncThunk, + createDataLoadingThunk, +} from '@/mastodon/store/typed_functions'; + +interface ProfileEditState { + tags: ApiFeaturedTagJSON[]; + tagSuggestions: ApiFeaturedTagJSON[]; + isLoading: boolean; + isPending: boolean; + search: { + query: string; + isLoading: boolean; + results?: ApiFeaturedTagJSON[]; + }; +} + +const initialState: ProfileEditState = { + tags: [], + tagSuggestions: [], + isLoading: true, + isPending: false, + search: { + query: '', + isLoading: false, + }, +}; + +const profileEditSlice = createSlice({ + name: 'profileEdit', + initialState, + reducers: { + setSearchQuery(state, action: PayloadAction) { + if (state.search.query === action.payload) { + return; + } + state.search.query = action.payload; + state.search.isLoading = false; + state.search.results = undefined; + }, + clearSearch(state) { + state.search.query = ''; + state.search.isLoading = false; + state.search.results = undefined; + }, + }, + extraReducers(builder) { + builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => { + state.tagSuggestions = action.payload.map(hashtagToFeaturedTag); + state.isLoading = false; + }); + builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => { + state.tags = action.payload; + state.isLoading = false; + }); + + builder.addCase(addFeaturedTag.pending, (state) => { + state.isPending = true; + }); + builder.addCase(addFeaturedTag.rejected, (state) => { + state.isPending = false; + }); + builder.addCase(addFeaturedTag.fulfilled, (state, action) => { + state.tags = [...state.tags, action.payload].toSorted( + (a, b) => b.statuses_count - a.statuses_count, + ); + state.tagSuggestions = state.tagSuggestions.filter( + (tag) => tag.name !== action.meta.arg.name, + ); + state.isPending = false; + }); + + builder.addCase(deleteFeaturedTag.pending, (state) => { + state.isPending = true; + }); + builder.addCase(deleteFeaturedTag.rejected, (state) => { + state.isPending = false; + }); + builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { + state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId); + state.isPending = false; + }); + + builder.addCase(fetchSearchResults.pending, (state) => { + state.search.isLoading = true; + }); + builder.addCase(fetchSearchResults.rejected, (state) => { + state.search.isLoading = false; + state.search.results = undefined; + }); + builder.addCase(fetchSearchResults.fulfilled, (state, action) => { + state.search.isLoading = false; + const searchResults: ApiFeaturedTagJSON[] = []; + const currentTags = new Set(state.tags.map((tag) => tag.name)); + + for (const tag of action.payload) { + if (currentTags.has(tag.name)) { + continue; + } + searchResults.push(hashtagToFeaturedTag(tag)); + if (searchResults.length >= 10) { + break; + } + } + state.search.results = searchResults; + }); + }, +}); + +export const profileEdit = profileEditSlice.reducer; +export const { clearSearch } = profileEditSlice.actions; + +export const fetchFeaturedTags = createDataLoadingThunk( + `${profileEditSlice.name}/fetchFeaturedTags`, + apiGetCurrentFeaturedTags, + { useLoadingBar: false }, +); + +export const fetchSuggestedTags = createDataLoadingThunk( + `${profileEditSlice.name}/fetchSuggestedTags`, + apiGetTagSuggestions, + { useLoadingBar: false }, +); + +export const addFeaturedTag = createDataLoadingThunk( + `${profileEditSlice.name}/addFeaturedTag`, + ({ name }: { name: string }) => apiPostFeaturedTag(name), + { + condition(arg, { getState }) { + const state = getState(); + return !state.profileEdit.tags.some((tag) => tag.name === arg.name); + }, + }, +); + +export const deleteFeaturedTag = createDataLoadingThunk( + `${profileEditSlice.name}/deleteFeaturedTag`, + ({ tagId }: { tagId: string }) => apiDeleteFeaturedTag(tagId), +); + +const debouncedFetchSearchResults = debounce( + async (dispatch: AppDispatch, query: string) => { + await dispatch(fetchSearchResults({ q: query })); + }, + 300, +); + +export const updateSearchQuery = createAppAsyncThunk( + `${profileEditSlice.name}/updateSearchQuery`, + (query: string, { dispatch }) => { + dispatch(profileEditSlice.actions.setSearchQuery(query)); + + if (query.trim().length > 0) { + void debouncedFetchSearchResults(dispatch, query); + } + }, +); + +export const fetchSearchResults = createDataLoadingThunk( + `${profileEditSlice.name}/fetchSearchResults`, + ({ q }: { q: string }) => apiGetSearch({ q, type: 'hashtags', limit: 11 }), + (result) => result.hashtags, +); diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 0393c06763a..25afce54d0a 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -77,7 +77,8 @@ const initialState = ImmutableMap({ follow_requests: initialListState, blocks: initialListState, mutes: initialListState, - featured_tags: initialListState, + /** @type {ImmutableMap} */ + featured_tags: ImmutableMap(), }); const normalizeList = (state, path, accounts, next) => {