From 74b3b6c798d1f137947e80df8eefb7412e70febd Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 2 Mar 2026 17:32:08 +0100 Subject: [PATCH] Profile editing: Allow adding arbitrary featured tags (#38012) --- app/javascript/mastodon/api_types/profile.ts | 2 + app/javascript/mastodon/api_types/tags.ts | 12 +--- .../components/form_fields/combobox_field.tsx | 5 +- .../account_edit/components/tag_search.tsx | 65 ++++++++++++++---- .../features/account_edit/featured_tags.tsx | 20 +++--- .../mastodon/features/account_edit/index.tsx | 12 ++-- app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/slices/profile_edit.ts | 68 +++++++++++++------ 8 files changed, 120 insertions(+), 65 deletions(-) diff --git a/app/javascript/mastodon/api_types/profile.ts b/app/javascript/mastodon/api_types/profile.ts index 7968f008ed..9814bddde9 100644 --- a/app/javascript/mastodon/api_types/profile.ts +++ b/app/javascript/mastodon/api_types/profile.ts @@ -1,4 +1,5 @@ import type { ApiAccountFieldJSON } from './accounts'; +import type { ApiFeaturedTagJSON } from './tags'; export interface ApiProfileJSON { id: string; @@ -20,6 +21,7 @@ export interface ApiProfileJSON { show_media_replies: boolean; show_featured: boolean; attribution_domains: string[]; + featured_tags: ApiFeaturedTagJSON[]; } export type ApiProfileUpdateParams = Partial< diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 01d7f9e4b6..52093689bc 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -17,16 +17,6 @@ export interface ApiHashtagJSON extends ApiHashtagBase { } export interface ApiFeaturedTagJSON extends ApiHashtagBase { - statuses_count: number; + statuses_count: string; 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 0c3af80883..89193ed9d5 100644 --- a/app/javascript/mastodon/components/form_fields/combobox_field.tsx +++ b/app/javascript/mastodon/components/form_fields/combobox_field.tsx @@ -63,7 +63,10 @@ interface ComboboxProps extends TextInputProps { * Customise the rendering of each option. * The rendered content must not contain other interactive content! */ - renderItem: (item: T, state: ComboboxItemState) => React.ReactElement; + renderItem: ( + item: T, + state: ComboboxItemState, + ) => React.ReactElement | string; /** * The main selection handler, called when an option is selected or deselected. */ diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx index 78eb981402..f0bba5a745 100644 --- a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -1,9 +1,9 @@ import type { ChangeEventHandler, FC } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; -import { useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; -import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; +import type { ApiHashtagJSON } from '@/mastodon/api_types/tags'; import { Combobox } from '@/mastodon/components/form_fields'; import { addFeaturedTag, @@ -15,10 +15,50 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import classes from '../styles.module.scss'; +type SearchResult = Omit & { + label?: string; +}; + +const messages = defineMessages({ + placeholder: { + id: 'account_edit_tags.search_placeholder', + defaultMessage: 'Enter a hashtag…', + }, + addTag: { + id: 'account_edit_tags.add_tag', + defaultMessage: 'Add #{tagName}', + }, +}); + export const AccountEditTagSearch: FC = () => { - const { query, isLoading, results } = useAppSelector( - (state) => state.profileEdit.search, - ); + const intl = useIntl(); + + const { + query, + isLoading, + results: rawResults, + } = useAppSelector((state) => state.profileEdit.search); + const results = useMemo(() => { + if (!rawResults) { + return []; + } + + const results: SearchResult[] = [...rawResults]; // Make array mutable + const trimmedQuery = query.trim(); + if ( + trimmedQuery.length > 0 && + results.every( + (result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(), + ) + ) { + results.push({ + id: 'new', + name: trimmedQuery, + label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }), + }); + } + return results; + }, [intl, query, rawResults]); const dispatch = useAppDispatch(); const handleSearchChange: ChangeEventHandler = useCallback( @@ -28,10 +68,8 @@ export const AccountEditTagSearch: FC = () => { [dispatch], ); - const intl = useIntl(); - const handleSelect = useCallback( - (item: ApiFeaturedTagJSON) => { + (item: SearchResult) => { void dispatch(clearSearch()); void dispatch(addFeaturedTag({ name: item.name })); }, @@ -42,11 +80,8 @@ export const AccountEditTagSearch: FC = () => { { ); }; -const renderItem = (item: ApiFeaturedTagJSON) =>

#{item.name}

; +const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`; diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index 4095707a26..dbcdad6d62 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -3,15 +3,15 @@ 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 type { TagData } from '@/mastodon/reducers/slices/profile_edit'; import { addFeaturedTag, deleteFeaturedTag, - fetchFeaturedTags, + fetchProfile, fetchSuggestedTags, } from '@/mastodon/reducers/slices/profile_edit'; import { @@ -35,9 +35,9 @@ const messages = defineMessages({ const selectTags = createAppSelector( [(state) => state.profileEdit], (profileEdit) => ({ - tags: profileEdit.tags ?? [], + tags: profileEdit.profile?.featuredTags ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [], - isLoading: !profileEdit.tags || !profileEdit.tagSuggestions, + isLoading: !profileEdit.profile || !profileEdit.tagSuggestions, isPending: profileEdit.isPending, }), ); @@ -52,7 +52,7 @@ export const AccountEditFeaturedTags: FC = () => { const dispatch = useAppDispatch(); useEffect(() => { - void dispatch(fetchFeaturedTags()); + void dispatch(fetchProfile()); void dispatch(fetchSuggestedTags()); }, [dispatch]); @@ -78,7 +78,9 @@ export const AccountEditFeaturedTags: FC = () => { defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.' tagName='p' /> + + {tagSuggestions.length > 0 && (
{ ))}
)} + {isLoading && } + { ); }; -function renderTag(tag: ApiFeaturedTagJSON) { +function renderTag(tag: TagData) { return (

#{tag.name}

- {tag.statuses_count > 0 && ( + {tag.statusesCount > 0 && ( )} diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index da58088e89..e0c03bd536 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -15,10 +15,7 @@ import { useElementHandledLink } from '@/mastodon/components/status/handled_link import { useAccount } from '@/mastodon/hooks/useAccount'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { autoPlayGif } from '@/mastodon/initial_state'; -import { - fetchFeaturedTags, - fetchProfile, -} from '@/mastodon/reducers/slices/profile_edit'; +import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; @@ -87,9 +84,8 @@ export const AccountEdit: FC = () => { const dispatch = useAppDispatch(); - const { profile, tags = [] } = useAppSelector((state) => state.profileEdit); + const { profile } = useAppSelector((state) => state.profileEdit); useEffect(() => { - void dispatch(fetchFeaturedTags()); void dispatch(fetchProfile()); }, [dispatch]); @@ -127,7 +123,7 @@ export const AccountEdit: FC = () => { const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const hasName = !!profile.displayName; const hasBio = !!profile.bio; - const hasTags = tags.length > 0; + const hasTags = profile.featuredTags.length > 0; return ( { /> } > - {tags.map((tag) => `#${tag.name}`).join(', ')} + {profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')} as SnakeToCamelCase]: ApiProfileJSON[Key]; } & { bio: ApiProfileJSON['note']; + featuredTags: TagData[]; +}; + +export type TagData = { + [Key in keyof Omit< + ApiFeaturedTagJSON, + 'statuses_count' + > as SnakeToCamelCase]: ApiFeaturedTagJSON[Key]; +} & { + statusesCount: number; }; export interface ProfileEditState { profile?: ProfileData; - tags?: ApiFeaturedTagJSON[]; - tagSuggestions?: ApiFeaturedTagJSON[]; + tagSuggestions?: ApiHashtagJSON[]; isPending: boolean; search: { query: string; isLoading: boolean; - results?: ApiFeaturedTagJSON[]; + results?: ApiHashtagJSON[]; }; } @@ -64,7 +75,7 @@ const profileEditSlice = createSlice({ } state.search.query = action.payload; - state.search.isLoading = false; + state.search.isLoading = true; state.search.results = undefined; }, clearSearch(state) { @@ -78,10 +89,7 @@ const profileEditSlice = createSlice({ state.profile = action.payload; }); builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => { - state.tagSuggestions = action.payload.map(hashtagToFeaturedTag); - }); - builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => { - state.tags = action.payload; + state.tagSuggestions = action.payload; }); builder.addCase(patchProfile.pending, (state) => { @@ -102,13 +110,14 @@ const profileEditSlice = createSlice({ state.isPending = false; }); builder.addCase(addFeaturedTag.fulfilled, (state, action) => { - if (!state.tags) { + if (!state.profile) { return; } - state.tags = [...state.tags, action.payload].toSorted( - (a, b) => b.statuses_count - a.statuses_count, - ); + state.profile.featuredTags = [ + ...state.profile.featuredTags, + transformTag(action.payload), + ].toSorted((a, b) => a.name.localeCompare(b.name)); if (state.tagSuggestions) { state.tagSuggestions = state.tagSuggestions.filter( (tag) => tag.name !== action.meta.arg.name, @@ -124,11 +133,13 @@ const profileEditSlice = createSlice({ state.isPending = false; }); builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { - if (!state.tags) { + if (!state.profile) { return; } - state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId); + state.profile.featuredTags = state.profile.featuredTags.filter( + (tag) => tag.id !== action.meta.arg.tagId, + ); state.isPending = false; }); @@ -141,14 +152,16 @@ const profileEditSlice = createSlice({ }); builder.addCase(fetchSearchResults.fulfilled, (state, action) => { state.search.isLoading = false; - const searchResults: ApiFeaturedTagJSON[] = []; - const currentTags = new Set((state.tags ?? []).map((tag) => tag.name)); + const searchResults: ApiHashtagJSON[] = []; + const currentTags = new Set( + (state.profile?.featuredTags ?? []).map((tag) => tag.name), + ); for (const tag of action.payload) { if (currentTags.has(tag.name)) { continue; } - searchResults.push(hashtagToFeaturedTag(tag)); + searchResults.push(tag); if (searchResults.length >= 10) { break; } @@ -161,6 +174,14 @@ const profileEditSlice = createSlice({ export const profileEdit = profileEditSlice.reducer; export const { clearSearch } = profileEditSlice.actions; +const transformTag = (result: ApiFeaturedTagJSON): TagData => ({ + id: result.id, + name: result.name, + url: result.url, + statusesCount: Number.parseInt(result.statuses_count), + lastStatusAt: result.last_status_at, +}); + const transformProfile = (result: ApiProfileJSON): ProfileData => ({ id: result.id, displayName: result.display_name, @@ -181,6 +202,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({ showMediaReplies: result.show_media_replies, showFeatured: result.show_featured, attributionDomains: result.attribution_domains, + featuredTags: result.featured_tags.map(transformTag), }); export const fetchProfile = createDataLoadingThunk( @@ -215,8 +237,10 @@ export const addFeaturedTag = createDataLoadingThunk( condition(arg, { getState }) { const state = getState(); return ( - !!state.profileEdit.tags && - !state.profileEdit.tags.some((tag) => tag.name === arg.name) + !!state.profileEdit.profile && + !state.profileEdit.profile.featuredTags.some( + (tag) => tag.name === arg.name, + ) ); }, },