Profile editing: Allow adding arbitrary featured tags (#38012)

This commit is contained in:
Echo
2026-03-02 17:32:08 +01:00
committed by GitHub
parent 03b2f77ad2
commit 74b3b6c798
8 changed files with 120 additions and 65 deletions

View File

@@ -1,4 +1,5 @@
import type { ApiAccountFieldJSON } from './accounts'; import type { ApiAccountFieldJSON } from './accounts';
import type { ApiFeaturedTagJSON } from './tags';
export interface ApiProfileJSON { export interface ApiProfileJSON {
id: string; id: string;
@@ -20,6 +21,7 @@ export interface ApiProfileJSON {
show_media_replies: boolean; show_media_replies: boolean;
show_featured: boolean; show_featured: boolean;
attribution_domains: string[]; attribution_domains: string[];
featured_tags: ApiFeaturedTagJSON[];
} }
export type ApiProfileUpdateParams = Partial< export type ApiProfileUpdateParams = Partial<

View File

@@ -17,16 +17,6 @@ export interface ApiHashtagJSON extends ApiHashtagBase {
} }
export interface ApiFeaturedTagJSON extends ApiHashtagBase { export interface ApiFeaturedTagJSON extends ApiHashtagBase {
statuses_count: number; statuses_count: string;
last_status_at: string | null; 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,
};
}

View File

@@ -63,7 +63,10 @@ interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
* Customise the rendering of each option. * Customise the rendering of each option.
* The rendered content must not contain other interactive content! * 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. * The main selection handler, called when an option is selected or deselected.
*/ */

View File

@@ -1,9 +1,9 @@
import type { ChangeEventHandler, FC } from 'react'; 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 { Combobox } from '@/mastodon/components/form_fields';
import { import {
addFeaturedTag, addFeaturedTag,
@@ -15,10 +15,50 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import classes from '../styles.module.scss'; import classes from '../styles.module.scss';
type SearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
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 = () => { export const AccountEditTagSearch: FC = () => {
const { query, isLoading, results } = useAppSelector( const intl = useIntl();
(state) => state.profileEdit.search,
); 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 dispatch = useAppDispatch();
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback( const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
@@ -28,10 +68,8 @@ export const AccountEditTagSearch: FC = () => {
[dispatch], [dispatch],
); );
const intl = useIntl();
const handleSelect = useCallback( const handleSelect = useCallback(
(item: ApiFeaturedTagJSON) => { (item: SearchResult) => {
void dispatch(clearSearch()); void dispatch(clearSearch());
void dispatch(addFeaturedTag({ name: item.name })); void dispatch(addFeaturedTag({ name: item.name }));
}, },
@@ -42,11 +80,8 @@ export const AccountEditTagSearch: FC = () => {
<Combobox <Combobox
value={query} value={query}
onChange={handleSearchChange} onChange={handleSearchChange}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage(messages.placeholder)}
id: 'account_edit_tags.search_placeholder', items={results}
defaultMessage: 'Enter a hashtag…',
})}
items={results ?? []}
isLoading={isLoading} isLoading={isLoading}
renderItem={renderItem} renderItem={renderItem}
onSelectItem={handleSelect} onSelectItem={handleSelect}
@@ -57,4 +92,4 @@ export const AccountEditTagSearch: FC = () => {
); );
}; };
const renderItem = (item: ApiFeaturedTagJSON) => <p>#{item.name}</p>; const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`;

View File

@@ -3,15 +3,15 @@ import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { Tag } from '@/mastodon/components/tags/tag'; import { Tag } from '@/mastodon/components/tags/tag';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
import { import {
addFeaturedTag, addFeaturedTag,
deleteFeaturedTag, deleteFeaturedTag,
fetchFeaturedTags, fetchProfile,
fetchSuggestedTags, fetchSuggestedTags,
} from '@/mastodon/reducers/slices/profile_edit'; } from '@/mastodon/reducers/slices/profile_edit';
import { import {
@@ -35,9 +35,9 @@ const messages = defineMessages({
const selectTags = createAppSelector( const selectTags = createAppSelector(
[(state) => state.profileEdit], [(state) => state.profileEdit],
(profileEdit) => ({ (profileEdit) => ({
tags: profileEdit.tags ?? [], tags: profileEdit.profile?.featuredTags ?? [],
tagSuggestions: profileEdit.tagSuggestions ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [],
isLoading: !profileEdit.tags || !profileEdit.tagSuggestions, isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
isPending: profileEdit.isPending, isPending: profileEdit.isPending,
}), }),
); );
@@ -52,7 +52,7 @@ export const AccountEditFeaturedTags: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
void dispatch(fetchFeaturedTags()); void dispatch(fetchProfile());
void dispatch(fetchSuggestedTags()); void dispatch(fetchSuggestedTags());
}, [dispatch]); }, [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 pages Activity view.' defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.'
tagName='p' tagName='p'
/> />
<AccountEditTagSearch /> <AccountEditTagSearch />
{tagSuggestions.length > 0 && ( {tagSuggestions.length > 0 && (
<div className={classes.tagSuggestions}> <div className={classes.tagSuggestions}>
<FormattedMessage <FormattedMessage
@@ -90,7 +92,9 @@ export const AccountEditFeaturedTags: FC = () => {
))} ))}
</div> </div>
)} )}
{isLoading && <LoadingIndicator />} {isLoading && <LoadingIndicator />}
<AccountEditItemList <AccountEditItemList
items={tags} items={tags}
disabled={isPending} disabled={isPending}
@@ -102,15 +106,15 @@ export const AccountEditFeaturedTags: FC = () => {
); );
}; };
function renderTag(tag: ApiFeaturedTagJSON) { function renderTag(tag: TagData) {
return ( return (
<div className={classes.tagItem}> <div className={classes.tagItem}>
<h4>#{tag.name}</h4> <h4>#{tag.name}</h4>
{tag.statuses_count > 0 && ( {tag.statusesCount > 0 && (
<FormattedMessage <FormattedMessage
id='account_edit_tags.tag_status_count' id='account_edit_tags.tag_status_count'
defaultMessage='{count, plural, one {# post} other {# posts}}' defaultMessage='{count, plural, one {# post} other {# posts}}'
values={{ count: tag.statuses_count }} values={{ count: tag.statusesCount }}
tagName='p' tagName='p'
/> />
)} )}

View File

@@ -15,10 +15,7 @@ import { useElementHandledLink } from '@/mastodon/components/status/handled_link
import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { autoPlayGif } from '@/mastodon/initial_state'; import { autoPlayGif } from '@/mastodon/initial_state';
import { import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit';
fetchFeaturedTags,
fetchProfile,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
@@ -87,9 +84,8 @@ export const AccountEdit: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { profile, tags = [] } = useAppSelector((state) => state.profileEdit); const { profile } = useAppSelector((state) => state.profileEdit);
useEffect(() => { useEffect(() => {
void dispatch(fetchFeaturedTags());
void dispatch(fetchProfile()); void dispatch(fetchProfile());
}, [dispatch]); }, [dispatch]);
@@ -127,7 +123,7 @@ export const AccountEdit: FC = () => {
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!profile.displayName; const hasName = !!profile.displayName;
const hasBio = !!profile.bio; const hasBio = !!profile.bio;
const hasTags = tags.length > 0; const hasTags = profile.featuredTags.length > 0;
return ( return (
<AccountEditColumn <AccountEditColumn
@@ -190,7 +186,7 @@ export const AccountEdit: FC = () => {
/> />
} }
> >
{tags.map((tag) => `#${tag.name}`).join(', ')} {profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection

View File

@@ -173,6 +173,7 @@
"account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "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.profile_tab.title": "Profile tab settings",
"account_edit.save": "Save", "account_edit.save": "Save",
"account_edit_tags.add_tag": "Add #{tagName}",
"account_edit_tags.column_title": "Edit featured hashtags", "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 pages Activity view.", "account_edit_tags.help_text": "Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile pages Activity view.",
"account_edit_tags.search_placeholder": "Enter a hashtag…", "account_edit_tags.search_placeholder": "Enter a hashtag…",

View File

@@ -16,8 +16,10 @@ import type {
ApiProfileJSON, ApiProfileJSON,
ApiProfileUpdateParams, ApiProfileUpdateParams,
} from '@/mastodon/api_types/profile'; } from '@/mastodon/api_types/profile';
import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags'; import type {
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; ApiFeaturedTagJSON,
ApiHashtagJSON,
} from '@/mastodon/api_types/tags';
import type { AppDispatch } from '@/mastodon/store'; import type { AppDispatch } from '@/mastodon/store';
import { import {
createAppAsyncThunk, createAppAsyncThunk,
@@ -28,21 +30,30 @@ import type { SnakeToCamelCase } from '@/mastodon/utils/types';
type ProfileData = { type ProfileData = {
[Key in keyof Omit< [Key in keyof Omit<
ApiProfileJSON, ApiProfileJSON,
'note' 'note' | 'featured_tags'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key]; > as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & { } & {
bio: ApiProfileJSON['note']; bio: ApiProfileJSON['note'];
featuredTags: TagData[];
};
export type TagData = {
[Key in keyof Omit<
ApiFeaturedTagJSON,
'statuses_count'
> as SnakeToCamelCase<Key>]: ApiFeaturedTagJSON[Key];
} & {
statusesCount: number;
}; };
export interface ProfileEditState { export interface ProfileEditState {
profile?: ProfileData; profile?: ProfileData;
tags?: ApiFeaturedTagJSON[]; tagSuggestions?: ApiHashtagJSON[];
tagSuggestions?: ApiFeaturedTagJSON[];
isPending: boolean; isPending: boolean;
search: { search: {
query: string; query: string;
isLoading: boolean; isLoading: boolean;
results?: ApiFeaturedTagJSON[]; results?: ApiHashtagJSON[];
}; };
} }
@@ -64,7 +75,7 @@ const profileEditSlice = createSlice({
} }
state.search.query = action.payload; state.search.query = action.payload;
state.search.isLoading = false; state.search.isLoading = true;
state.search.results = undefined; state.search.results = undefined;
}, },
clearSearch(state) { clearSearch(state) {
@@ -78,10 +89,7 @@ const profileEditSlice = createSlice({
state.profile = action.payload; state.profile = action.payload;
}); });
builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => { builder.addCase(fetchSuggestedTags.fulfilled, (state, action) => {
state.tagSuggestions = action.payload.map(hashtagToFeaturedTag); state.tagSuggestions = action.payload;
});
builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => {
state.tags = action.payload;
}); });
builder.addCase(patchProfile.pending, (state) => { builder.addCase(patchProfile.pending, (state) => {
@@ -102,13 +110,14 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(addFeaturedTag.fulfilled, (state, action) => { builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) { if (!state.profile) {
return; return;
} }
state.tags = [...state.tags, action.payload].toSorted( state.profile.featuredTags = [
(a, b) => b.statuses_count - a.statuses_count, ...state.profile.featuredTags,
); transformTag(action.payload),
].toSorted((a, b) => a.name.localeCompare(b.name));
if (state.tagSuggestions) { if (state.tagSuggestions) {
state.tagSuggestions = state.tagSuggestions.filter( state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name, (tag) => tag.name !== action.meta.arg.name,
@@ -124,11 +133,13 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) { if (!state.profile) {
return; 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; state.isPending = false;
}); });
@@ -141,14 +152,16 @@ const profileEditSlice = createSlice({
}); });
builder.addCase(fetchSearchResults.fulfilled, (state, action) => { builder.addCase(fetchSearchResults.fulfilled, (state, action) => {
state.search.isLoading = false; state.search.isLoading = false;
const searchResults: ApiFeaturedTagJSON[] = []; const searchResults: ApiHashtagJSON[] = [];
const currentTags = new Set((state.tags ?? []).map((tag) => tag.name)); const currentTags = new Set(
(state.profile?.featuredTags ?? []).map((tag) => tag.name),
);
for (const tag of action.payload) { for (const tag of action.payload) {
if (currentTags.has(tag.name)) { if (currentTags.has(tag.name)) {
continue; continue;
} }
searchResults.push(hashtagToFeaturedTag(tag)); searchResults.push(tag);
if (searchResults.length >= 10) { if (searchResults.length >= 10) {
break; break;
} }
@@ -161,6 +174,14 @@ const profileEditSlice = createSlice({
export const profileEdit = profileEditSlice.reducer; export const profileEdit = profileEditSlice.reducer;
export const { clearSearch } = profileEditSlice.actions; 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 => ({ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
id: result.id, id: result.id,
displayName: result.display_name, displayName: result.display_name,
@@ -181,6 +202,7 @@ const transformProfile = (result: ApiProfileJSON): ProfileData => ({
showMediaReplies: result.show_media_replies, showMediaReplies: result.show_media_replies,
showFeatured: result.show_featured, showFeatured: result.show_featured,
attributionDomains: result.attribution_domains, attributionDomains: result.attribution_domains,
featuredTags: result.featured_tags.map(transformTag),
}); });
export const fetchProfile = createDataLoadingThunk( export const fetchProfile = createDataLoadingThunk(
@@ -215,8 +237,10 @@ export const addFeaturedTag = createDataLoadingThunk(
condition(arg, { getState }) { condition(arg, { getState }) {
const state = getState(); const state = getState();
return ( return (
!!state.profileEdit.tags && !!state.profileEdit.profile &&
!state.profileEdit.tags.some((tag) => tag.name === arg.name) !state.profileEdit.profile.featuredTags.some(
(tag) => tag.name === arg.name,
)
); );
}, },
}, },