[Glitch] Profile editing: Utilize new API

Port d18a47b6a7 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-02-26 14:55:10 +01:00
committed by Claire
parent dc27eccc8e
commit 49bed149df
10 changed files with 277 additions and 100 deletions

View File

@@ -158,3 +158,10 @@ export async function apiRequestDelete<
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params });
}
export async function apiRequestPatch<ApiResponse = unknown, ApiData = unknown>(
url: ApiUrl,
data?: RequestParamsOrData<ApiData>,
) {
return apiRequest<ApiResponse>('PATCH', url, { data });
}

View File

@@ -2,6 +2,7 @@ import {
apiRequestPost,
apiRequestGet,
apiRequestDelete,
apiRequestPatch,
} from 'flavours/glitch/api';
import type {
ApiAccountJSON,
@@ -13,6 +14,11 @@ import type {
ApiHashtagJSON,
} from 'flavours/glitch/api_types/tags';
import type {
ApiProfileJSON,
ApiProfileUpdateParams,
} from '../api_types/profile';
export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
comment: value,
@@ -58,3 +64,8 @@ export const apiGetFamiliarFollowers = (id: string) =>
apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', {
id,
});
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
export const apiPatchProfile = (params: ApiProfileUpdateParams) =>
apiRequestPatch<ApiProfileJSON>('v1/profile', params);

View File

@@ -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<ApiAccountFieldJSON, 'name' | 'value'>[];
};

View File

@@ -4,12 +4,11 @@ import type { ChangeEventHandler, FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/flavours/glitch/components/form_fields';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
@@ -38,10 +37,11 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const titleId = useId();
const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement> = useCallback(
(event) => {
setNewBio(event.currentTarget.value);
@@ -55,19 +55,22 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
});
}, []);
if (!account) {
return <LoadingIndicator />;
}
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (!isPending) {
void dispatch(patchProfile({ note: newBio })).then(onClose);
}
}, [dispatch, isPending, newBio, onClose]);
return (
<ConfirmationModal
title={intl.formatMessage(
account.note_plain ? messages.editTitle : messages.addTitle,
)}
title={intl.formatMessage(bio ? messages.editTitle : messages.addTitle)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
disabled={newBio.length > MAX_BIO_LENGTH}
noFocusButton
>
<div className={classes.inputWrapper}>

View File

@@ -7,8 +7,8 @@ import { TextInput } from '@/flavours/glitch/components/form_fields';
import { insertEmojiAtPosition } from '@/flavours/glitch/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/flavours/glitch/features/ui/components/confirmation_modals';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { patchProfile } from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import classes from '../styles.module.scss';
@@ -37,10 +37,11 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const titleId = useId();
const counterId = useId();
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement> = useCallback(
(event) => {
setNewName(event.currentTarget.value);
@@ -54,13 +55,22 @@ export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
});
}, []);
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
if (!isPending) {
void dispatch(patchProfile({ display_name: newName })).then(onClose);
}
}, [dispatch, isPending, newName, onClose]);
return (
<ConfirmationModal
title={intl.formatMessage(messages.editTitle)}
titleId={titleId}
confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented
onConfirm={handleSave}
onClose={onClose}
updating={isPending}
disabled={newName.length > MAX_NAME_LENGTH}
noCloseOnConfirm
noFocusButton
>

View File

@@ -14,7 +14,11 @@ import {
fetchFeaturedTags,
fetchSuggestedTags,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/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(() => {

View File

@@ -7,13 +7,17 @@ import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/flavours/glitch/actions/modal';
import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { DisplayNameSimple } from '@/flavours/glitch/components/display_name/simple';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { fetchFeaturedTags } from '@/flavours/glitch/reducers/slices/profile_edit';
import {
fetchFeaturedTags,
fetchProfile,
} from '@/flavours/glitch/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 <AccountEditEmptyColumn notFound={!accountId} />;
}
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 (
<AccountEditColumn
@@ -128,62 +137,64 @@ export const AccountEdit: FC = () => {
<Avatar account={account} size={80} className={classes.avatar} />
</header>
<AccountEditSection
title={messages.displayNameTitle}
description={messages.displayNamePlaceholder}
showDescription={!hasName}
buttons={
<EditButton
onClick={handleNameEdit}
item={messages.displayNameTitle}
edit={hasName}
/>
}
>
<DisplayNameSimple account={account} />
</AccountEditSection>
<CustomEmojiProvider emojis={emojis}>
<AccountEditSection
title={messages.displayNameTitle}
description={messages.displayNamePlaceholder}
showDescription={!hasName}
buttons={
<EditButton
onClick={handleNameEdit}
item={messages.displayNameTitle}
edit={hasName}
/>
}
>
<EmojiHTML htmlString={profile.displayName} {...htmlHandlers} />
</AccountEditSection>
<AccountEditSection
title={messages.bioTitle}
description={messages.bioPlaceholder}
showDescription={!hasBio}
buttons={
<EditButton
onClick={handleBioEdit}
item={messages.bioTitle}
edit={hasBio}
/>
}
>
<AccountBio accountId={accountId} />
</AccountEditSection>
<AccountEditSection
title={messages.bioTitle}
description={messages.bioPlaceholder}
showDescription={!hasBio}
buttons={
<EditButton
onClick={handleBioEdit}
item={messages.bioTitle}
edit={hasBio}
/>
}
>
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} />
</AccountEditSection>
<AccountEditSection
title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder}
showDescription
/>
<AccountEditSection
title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder}
showDescription={!hasTags}
buttons={
<EditButton
onClick={handleFeaturedTagsEdit}
edit={hasTags}
item={messages.featuredHashtagsItem}
/>
}
>
{featuredTags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection>
<AccountEditSection
title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder}
showDescription={!hasTags}
buttons={
<EditButton
onClick={handleFeaturedTagsEdit}
edit={hasTags}
item={messages.featuredHashtagsItem}
/>
}
>
{tags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection>
<AccountEditSection
title={messages.profileTabTitle}
description={messages.profileTabSubtitle}
showDescription
/>
<AccountEditSection
title={messages.profileTabTitle}
description={messages.profileTabSubtitle}
showDescription
/>
</CustomEmojiProvider>
</AccountEditColumn>
);
};

View File

@@ -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<import('@/flavours/glitch/models/custom_emoji').CustomEmoji>} */
const initialState = ImmutableList([]);
export default function custom_emojis(state = initialState, action) {

View File

@@ -6,10 +6,16 @@ import { debounce } from 'lodash';
import {
apiDeleteFeaturedTag,
apiGetCurrentFeaturedTags,
apiGetProfile,
apiGetTagSuggestions,
apiPatchProfile,
apiPostFeaturedTag,
} from '@/flavours/glitch/api/accounts';
import { apiGetSearch } from '@/flavours/glitch/api/search';
import type {
ApiProfileJSON,
ApiProfileUpdateParams,
} from '@/flavours/glitch/api_types/profile';
import { hashtagToFeaturedTag } from '@/flavours/glitch/api_types/tags';
import type { ApiFeaturedTagJSON } from '@/flavours/glitch/api_types/tags';
import type { AppDispatch } from '@/flavours/glitch/store';
@@ -17,11 +23,21 @@ import {
createAppAsyncThunk,
createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions';
import type { SnakeToCamelCase } from '@/flavours/glitch/utils/types';
interface ProfileEditState {
tags: ApiFeaturedTagJSON[];
tagSuggestions: ApiFeaturedTagJSON[];
isLoading: boolean;
type ProfileData = {
[Key in keyof Omit<
ApiProfileJSON,
'note'
> as SnakeToCamelCase<Key>]: 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<ApiProfileUpdateParams>) => 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)
);
},
},
);

View File

@@ -24,3 +24,8 @@ export type OmitValueType<T, V> = {
export type AnyFunction = (...args: never) => unknown;
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;
export type SnakeToCamelCase<S extends string> =
S extends `${infer T}_${infer U}`
? `${T}${Capitalize<SnakeToCamelCase<U>>}`
: S;