From d18a47b6a704ea599e7ab0abe1fdd7a3a791a334 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 26 Feb 2026 14:55:10 +0100 Subject: [PATCH] Profile editing: Utilize new API (#37990) --- app/javascript/mastodon/api.ts | 7 + app/javascript/mastodon/api/accounts.ts | 17 ++- app/javascript/mastodon/api_types/profile.ts | 42 ++++++ .../account_edit/components/bio_modal.tsx | 29 ++-- .../account_edit/components/name_modal.tsx | 22 ++- .../features/account_edit/featured_tags.tsx | 21 ++- .../mastodon/features/account_edit/index.tsx | 137 ++++++++++-------- .../mastodon/reducers/custom_emojis.js | 1 + .../mastodon/reducers/slices/profile_edit.ts | 102 +++++++++++-- app/javascript/mastodon/utils/types.ts | 5 + app/serializers/rest/profile_serializer.rb | 1 + 11 files changed, 283 insertions(+), 101 deletions(-) create mode 100644 app/javascript/mastodon/api_types/profile.ts diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 2af29c783e..39617d82fe 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -179,3 +179,10 @@ export async function apiRequestDelete< >(url: ApiUrl, params?: RequestParamsOrData) { return apiRequest('DELETE', url, { params }); } + +export async function apiRequestPatch( + url: ApiUrl, + data?: RequestParamsOrData, +) { + return apiRequest('PATCH', url, { data }); +} diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 9c35d619a4..da4b0e94f8 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -1,4 +1,9 @@ -import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import { + apiRequestPost, + apiRequestGet, + apiRequestDelete, + apiRequestPatch, +} from 'mastodon/api'; import type { ApiAccountJSON, ApiFamiliarFollowersJSON, @@ -9,6 +14,11 @@ import type { ApiHashtagJSON, } from 'mastodon/api_types/tags'; +import type { + ApiProfileJSON, + ApiProfileUpdateParams, +} from '../api_types/profile'; + export const apiSubmitAccountNote = (id: string, value: string) => apiRequestPost(`v1/accounts/${id}/note`, { comment: value, @@ -54,3 +64,8 @@ export const apiGetFamiliarFollowers = (id: string) => apiRequestGet('v1/accounts/familiar_followers', { id, }); + +export const apiGetProfile = () => apiRequestGet('v1/profile'); + +export const apiPatchProfile = (params: ApiProfileUpdateParams) => + apiRequestPatch('v1/profile', params); diff --git a/app/javascript/mastodon/api_types/profile.ts b/app/javascript/mastodon/api_types/profile.ts new file mode 100644 index 0000000000..7968f008ed --- /dev/null +++ b/app/javascript/mastodon/api_types/profile.ts @@ -0,0 +1,42 @@ +import type { ApiAccountFieldJSON } from './accounts'; + +export interface ApiProfileJSON { + id: string; + display_name: string; + note: string; + fields: ApiAccountFieldJSON[]; + avatar: string; + avatar_static: string; + avatar_description: string; + header: string; + header_static: string; + header_description: string; + locked: boolean; + bot: boolean; + hide_collections: boolean; + discoverable: boolean; + indexable: boolean; + show_media: boolean; + show_media_replies: boolean; + show_featured: boolean; + attribution_domains: string[]; +} + +export type ApiProfileUpdateParams = Partial< + Pick< + ApiProfileJSON, + | 'display_name' + | 'note' + | 'locked' + | 'bot' + | 'hide_collections' + | 'discoverable' + | 'indexable' + | 'show_media' + | 'show_media_replies' + | 'show_featured' + > +> & { + attribution_domains?: string[]; + fields_attributes?: Pick[]; +}; diff --git a/app/javascript/mastodon/features/account_edit/components/bio_modal.tsx b/app/javascript/mastodon/features/account_edit/components/bio_modal.tsx index fbc74e769b..3391bd7290 100644 --- a/app/javascript/mastodon/features/account_edit/components/bio_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/components/bio_modal.tsx @@ -4,12 +4,11 @@ import type { ChangeEventHandler, FC } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { TextArea } from '@/mastodon/components/form_fields'; -import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils'; import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals'; -import { useAccount } from '@/mastodon/hooks/useAccount'; -import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import { patchProfile } from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import classes from '../styles.module.scss'; @@ -38,10 +37,11 @@ export const BioModal: FC = ({ onClose }) => { const titleId = useId(); const counterId = useId(); const textAreaRef = useRef(null); - const accountId = useCurrentAccountId(); - const account = useAccount(accountId); - const [newBio, setNewBio] = useState(account?.note_plain ?? ''); + const { profile: { bio } = {}, isPending } = useAppSelector( + (state) => state.profileEdit, + ); + const [newBio, setNewBio] = useState(bio ?? ''); const handleChange: ChangeEventHandler = useCallback( (event) => { setNewBio(event.currentTarget.value); @@ -55,19 +55,22 @@ export const BioModal: FC = ({ onClose }) => { }); }, []); - if (!account) { - return ; - } + const dispatch = useAppDispatch(); + const handleSave = useCallback(() => { + if (!isPending) { + void dispatch(patchProfile({ note: newBio })).then(onClose); + } + }, [dispatch, isPending, newBio, onClose]); return ( MAX_BIO_LENGTH} noFocusButton >
diff --git a/app/javascript/mastodon/features/account_edit/components/name_modal.tsx b/app/javascript/mastodon/features/account_edit/components/name_modal.tsx index 0b38419b6f..c7f533bba2 100644 --- a/app/javascript/mastodon/features/account_edit/components/name_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/components/name_modal.tsx @@ -7,8 +7,8 @@ import { TextInput } from '@/mastodon/components/form_fields'; import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils'; import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals'; -import { useAccount } from '@/mastodon/hooks/useAccount'; -import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import { patchProfile } from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import classes from '../styles.module.scss'; @@ -37,10 +37,11 @@ export const NameModal: FC = ({ onClose }) => { const titleId = useId(); const counterId = useId(); const inputRef = useRef(null); - const accountId = useCurrentAccountId(); - const account = useAccount(accountId); - const [newName, setNewName] = useState(account?.display_name ?? ''); + const { profile: { displayName } = {}, isPending } = useAppSelector( + (state) => state.profileEdit, + ); + const [newName, setNewName] = useState(displayName ?? ''); const handleChange: ChangeEventHandler = useCallback( (event) => { setNewName(event.currentTarget.value); @@ -54,13 +55,22 @@ export const NameModal: FC = ({ onClose }) => { }); }, []); + const dispatch = useAppDispatch(); + const handleSave = useCallback(() => { + if (!isPending) { + void dispatch(patchProfile({ display_name: newName })).then(onClose); + } + }, [dispatch, isPending, newName, onClose]); + return ( MAX_NAME_LENGTH} noCloseOnConfirm noFocusButton > diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index eaea7a2205..4095707a26 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -14,7 +14,11 @@ import { fetchFeaturedTags, fetchSuggestedTags, } from '@/mastodon/reducers/slices/profile_edit'; -import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/mastodon/store'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; import { AccountEditItemList } from './components/item_list'; @@ -28,14 +32,23 @@ const messages = defineMessages({ }, }); +const selectTags = createAppSelector( + [(state) => state.profileEdit], + (profileEdit) => ({ + tags: profileEdit.tags ?? [], + tagSuggestions: profileEdit.tagSuggestions ?? [], + isLoading: !profileEdit.tags || !profileEdit.tagSuggestions, + isPending: profileEdit.isPending, + }), +); + export const AccountEditFeaturedTags: FC = () => { const accountId = useCurrentAccountId(); const account = useAccount(accountId); const intl = useIntl(); - const { tags, tagSuggestions, isLoading, isPending } = useAppSelector( - (state) => state.profileEdit, - ); + const { tags, tagSuggestions, isLoading, isPending } = + useAppSelector(selectTags); const dispatch = useAppDispatch(); useEffect(() => { diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index 2673c5363f..c8bc93f15f 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -7,13 +7,17 @@ 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 { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; +import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; +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 } from '@/mastodon/reducers/slices/profile_edit'; +import { + fetchFeaturedTags, + fetchProfile, +} from '@/mastodon/reducers/slices/profile_edit'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { AccountEditColumn, AccountEditEmptyColumn } from './components/column'; @@ -82,11 +86,10 @@ export const AccountEdit: FC = () => { const dispatch = useAppDispatch(); - const { tags: featuredTags, isLoading: isTagsLoading } = useAppSelector( - (state) => state.profileEdit, - ); + const { profile, tags = [] } = useAppSelector((state) => state.profileEdit); useEffect(() => { void dispatch(fetchFeaturedTags()); + void dispatch(fetchProfile()); }, [dispatch]); const handleOpenModal = useCallback( @@ -107,14 +110,20 @@ export const AccountEdit: FC = () => { history.push('/profile/featured_tags'); }, [history]); - if (!accountId || !account) { + // Normally we would use the account emoji, but we want all custom emojis to be available to render after editing. + const emojis = useAppSelector((state) => state.custom_emojis); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: profile?.id, + }); + + if (!accountId || !account || !profile) { 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; + const headerSrc = autoPlayGif ? profile.header : profile.headerStatic; + const hasName = !!profile.displayName; + const hasBio = !!profile.bio; + const hasTags = tags.length > 0; return ( { - - } - > - - + + + } + > + + - - } - > - - + + } + > + + - + - - } - > - {featuredTags.map((tag) => `#${tag.name}`).join(', ')} - + + } + > + {tags.map((tag) => `#${tag.name}`).join(', ')} + - + + ); }; diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index 56ec80f2ff..47aa3edbbb 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -4,6 +4,7 @@ import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis'; import { buildCustomEmojis } from '../features/emoji/emoji'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +/** @type {ImmutableList} */ const initialState = ImmutableList([]); export default function custom_emojis(state = initialState, action) { diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index c966325203..4f5bd6a4c8 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -6,10 +6,16 @@ import { debounce } from 'lodash'; import { apiDeleteFeaturedTag, apiGetCurrentFeaturedTags, + apiGetProfile, apiGetTagSuggestions, + apiPatchProfile, apiPostFeaturedTag, } from '@/mastodon/api/accounts'; import { apiGetSearch } from '@/mastodon/api/search'; +import type { + ApiProfileJSON, + ApiProfileUpdateParams, +} from '@/mastodon/api_types/profile'; import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags'; import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; import type { AppDispatch } from '@/mastodon/store'; @@ -17,11 +23,21 @@ import { createAppAsyncThunk, createDataLoadingThunk, } from '@/mastodon/store/typed_functions'; +import type { SnakeToCamelCase } from '@/mastodon/utils/types'; -interface ProfileEditState { - tags: ApiFeaturedTagJSON[]; - tagSuggestions: ApiFeaturedTagJSON[]; - isLoading: boolean; +type ProfileData = { + [Key in keyof Omit< + ApiProfileJSON, + 'note' + > as SnakeToCamelCase]: ApiProfileJSON[Key]; +} & { + bio: ApiProfileJSON['note']; +}; + +export interface ProfileEditState { + profile?: ProfileData; + tags?: ApiFeaturedTagJSON[]; + tagSuggestions?: ApiFeaturedTagJSON[]; isPending: boolean; search: { query: string; @@ -31,9 +47,6 @@ interface ProfileEditState { } const initialState: ProfileEditState = { - tags: [], - tagSuggestions: [], - isLoading: true, isPending: false, search: { query: '', @@ -49,6 +62,7 @@ const profileEditSlice = createSlice({ if (state.search.query === action.payload) { return; } + state.search.query = action.payload; state.search.isLoading = false; state.search.results = undefined; @@ -60,13 +74,25 @@ const profileEditSlice = createSlice({ }, }, extraReducers(builder) { + builder.addCase(fetchProfile.fulfilled, (state, action) => { + state.profile = action.payload; + }); 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(patchProfile.pending, (state) => { + state.isPending = true; + }); + builder.addCase(patchProfile.rejected, (state) => { + state.isPending = false; + }); + builder.addCase(patchProfile.fulfilled, (state, action) => { + state.profile = action.payload; + state.isPending = false; }); builder.addCase(addFeaturedTag.pending, (state) => { @@ -76,12 +102,18 @@ const profileEditSlice = createSlice({ state.isPending = false; }); builder.addCase(addFeaturedTag.fulfilled, (state, action) => { + if (!state.tags) { + return; + } + 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, - ); + if (state.tagSuggestions) { + state.tagSuggestions = state.tagSuggestions.filter( + (tag) => tag.name !== action.meta.arg.name, + ); + } state.isPending = false; }); @@ -92,6 +124,10 @@ const profileEditSlice = createSlice({ state.isPending = false; }); builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { + if (!state.tags) { + return; + } + state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId); state.isPending = false; }); @@ -106,7 +142,7 @@ 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 currentTags = new Set((state.tags ?? []).map((tag) => tag.name)); for (const tag of action.payload) { if (currentTags.has(tag.name)) { @@ -125,6 +161,41 @@ const profileEditSlice = createSlice({ export const profileEdit = profileEditSlice.reducer; export const { clearSearch } = profileEditSlice.actions; +const transformProfile = (result: ApiProfileJSON): ProfileData => ({ + id: result.id, + displayName: result.display_name, + bio: result.note, + fields: result.fields, + avatar: result.avatar, + avatarStatic: result.avatar_static, + avatarDescription: result.avatar_description, + header: result.header, + headerStatic: result.header_static, + headerDescription: result.header_description, + locked: result.locked, + bot: result.bot, + hideCollections: result.hide_collections, + discoverable: result.discoverable, + indexable: result.indexable, + showMedia: result.show_media, + showMediaReplies: result.show_media_replies, + showFeatured: result.show_featured, + attributionDomains: result.attribution_domains, +}); + +export const fetchProfile = createDataLoadingThunk( + `${profileEditSlice.name}/fetchProfile`, + apiGetProfile, + transformProfile, +); + +export const patchProfile = createDataLoadingThunk( + `${profileEditSlice.name}/patchProfile`, + (params: Partial) => apiPatchProfile(params), + transformProfile, + { useLoadingBar: false }, +); + export const fetchFeaturedTags = createDataLoadingThunk( `${profileEditSlice.name}/fetchFeaturedTags`, apiGetCurrentFeaturedTags, @@ -143,7 +214,10 @@ export const addFeaturedTag = createDataLoadingThunk( { condition(arg, { getState }) { const state = getState(); - return !state.profileEdit.tags.some((tag) => tag.name === arg.name); + return ( + !!state.profileEdit.tags && + !state.profileEdit.tags.some((tag) => tag.name === arg.name) + ); }, }, ); diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index f51b3ad8b3..2383dbc50d 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -24,3 +24,8 @@ export type OmitValueType = { export type AnyFunction = (...args: never) => unknown; export type OmitUnion = TBase & Omit; + +export type SnakeToCamelCase = + S extends `${infer T}_${infer U}` + ? `${T}${Capitalize>}` + : S; diff --git a/app/serializers/rest/profile_serializer.rb b/app/serializers/rest/profile_serializer.rb index d535e3776d..fb4ffab989 100644 --- a/app/serializers/rest/profile_serializer.rb +++ b/app/serializers/rest/profile_serializer.rb @@ -3,6 +3,7 @@ class REST::ProfileSerializer < ActiveModel::Serializer include RoutingHelper + # Please update app/javascript/api_types/profile.ts when making changes to the attributes attributes :id, :display_name, :note, :fields, :avatar, :avatar_static, :avatar_description, :header, :header_static, :header_description, :locked, :bot,