Profile editing: Utilize new API (#37990)

This commit is contained in:
Echo
2026-02-26 14:55:10 +01:00
committed by GitHub
parent 51b81b3ce9
commit d18a47b6a7
11 changed files with 283 additions and 101 deletions

View File

@@ -179,3 +179,10 @@ export async function apiRequestDelete<
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) { >(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
return apiRequest<ApiResponse>('DELETE', url, { params }); 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

@@ -1,4 +1,9 @@
import { apiRequestPost, apiRequestGet, apiRequestDelete } from 'mastodon/api'; import {
apiRequestPost,
apiRequestGet,
apiRequestDelete,
apiRequestPatch,
} from 'mastodon/api';
import type { import type {
ApiAccountJSON, ApiAccountJSON,
ApiFamiliarFollowersJSON, ApiFamiliarFollowersJSON,
@@ -9,6 +14,11 @@ import type {
ApiHashtagJSON, ApiHashtagJSON,
} from 'mastodon/api_types/tags'; } from 'mastodon/api_types/tags';
import type {
ApiProfileJSON,
ApiProfileUpdateParams,
} from '../api_types/profile';
export const apiSubmitAccountNote = (id: string, value: string) => export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, { apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
comment: value, comment: value,
@@ -54,3 +64,8 @@ export const apiGetFamiliarFollowers = (id: string) =>
apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', { apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', {
id, 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 { defineMessages, useIntl } from 'react-intl';
import { TextArea } from '@/mastodon/components/form_fields'; import { TextArea } from '@/mastodon/components/form_fields';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils'; import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals'; import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals'; import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
import { useAccount } from '@/mastodon/hooks/useAccount'; import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import classes from '../styles.module.scss'; import classes from '../styles.module.scss';
@@ -38,10 +37,11 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
const titleId = useId(); const titleId = useId();
const counterId = useId(); const counterId = useId();
const textAreaRef = useRef<HTMLTextAreaElement>(null); 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( const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => { (event) => {
setNewBio(event.currentTarget.value); setNewBio(event.currentTarget.value);
@@ -55,19 +55,22 @@ export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
}); });
}, []); }, []);
if (!account) { const dispatch = useAppDispatch();
return <LoadingIndicator />; const handleSave = useCallback(() => {
} if (!isPending) {
void dispatch(patchProfile({ note: newBio })).then(onClose);
}
}, [dispatch, isPending, newBio, onClose]);
return ( return (
<ConfirmationModal <ConfirmationModal
title={intl.formatMessage( title={intl.formatMessage(bio ? messages.editTitle : messages.addTitle)}
account.note_plain ? messages.editTitle : messages.addTitle,
)}
titleId={titleId} titleId={titleId}
confirm={intl.formatMessage(messages.save)} confirm={intl.formatMessage(messages.save)}
onConfirm={onClose} // To be implemented onConfirm={handleSave}
onClose={onClose} onClose={onClose}
updating={isPending}
disabled={newBio.length > MAX_BIO_LENGTH}
noFocusButton noFocusButton
> >
<div className={classes.inputWrapper}> <div className={classes.inputWrapper}>

View File

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

View File

@@ -14,7 +14,11 @@ import {
fetchFeaturedTags, fetchFeaturedTags,
fetchSuggestedTags, fetchSuggestedTags,
} from '@/mastodon/reducers/slices/profile_edit'; } 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 { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
import { AccountEditItemList } from './components/item_list'; 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 = () => { export const AccountEditFeaturedTags: FC = () => {
const accountId = useCurrentAccountId(); const accountId = useCurrentAccountId();
const account = useAccount(accountId); const account = useAccount(accountId);
const intl = useIntl(); const intl = useIntl();
const { tags, tagSuggestions, isLoading, isPending } = useAppSelector( const { tags, tagSuggestions, isLoading, isPending } =
(state) => state.profileEdit, useAppSelector(selectTags);
);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {

View File

@@ -7,13 +7,17 @@ import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/mastodon/actions/modal'; import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar'; 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 { 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 { fetchFeaturedTags } from '@/mastodon/reducers/slices/profile_edit'; import {
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';
@@ -82,11 +86,10 @@ export const AccountEdit: FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { tags: featuredTags, isLoading: isTagsLoading } = useAppSelector( const { profile, tags = [] } = useAppSelector((state) => state.profileEdit);
(state) => state.profileEdit,
);
useEffect(() => { useEffect(() => {
void dispatch(fetchFeaturedTags()); void dispatch(fetchFeaturedTags());
void dispatch(fetchProfile());
}, [dispatch]); }, [dispatch]);
const handleOpenModal = useCallback( const handleOpenModal = useCallback(
@@ -107,14 +110,20 @@ export const AccountEdit: FC = () => {
history.push('/profile/featured_tags'); history.push('/profile/featured_tags');
}, [history]); }, [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} />; return <AccountEditEmptyColumn notFound={!accountId} />;
} }
const headerSrc = autoPlayGif ? account.header : account.header_static; const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
const hasName = !!account.display_name; const hasName = !!profile.displayName;
const hasBio = !!account.note_plain; const hasBio = !!profile.bio;
const hasTags = !isTagsLoading && featuredTags.length > 0; const hasTags = tags.length > 0;
return ( return (
<AccountEditColumn <AccountEditColumn
@@ -128,62 +137,64 @@ export const AccountEdit: FC = () => {
<Avatar account={account} size={80} className={classes.avatar} /> <Avatar account={account} size={80} className={classes.avatar} />
</header> </header>
<AccountEditSection <CustomEmojiProvider emojis={emojis}>
title={messages.displayNameTitle} <AccountEditSection
description={messages.displayNamePlaceholder} title={messages.displayNameTitle}
showDescription={!hasName} description={messages.displayNamePlaceholder}
buttons={ showDescription={!hasName}
<EditButton buttons={
onClick={handleNameEdit} <EditButton
item={messages.displayNameTitle} onClick={handleNameEdit}
edit={hasName} item={messages.displayNameTitle}
/> edit={hasName}
} />
> }
<DisplayNameSimple account={account} /> >
</AccountEditSection> <EmojiHTML htmlString={profile.displayName} {...htmlHandlers} />
</AccountEditSection>
<AccountEditSection <AccountEditSection
title={messages.bioTitle} title={messages.bioTitle}
description={messages.bioPlaceholder} description={messages.bioPlaceholder}
showDescription={!hasBio} showDescription={!hasBio}
buttons={ buttons={
<EditButton <EditButton
onClick={handleBioEdit} onClick={handleBioEdit}
item={messages.bioTitle} item={messages.bioTitle}
edit={hasBio} edit={hasBio}
/> />
} }
> >
<AccountBio accountId={accountId} /> <EmojiHTML htmlString={profile.bio} {...htmlHandlers} />
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection
title={messages.customFieldsTitle} title={messages.customFieldsTitle}
description={messages.customFieldsPlaceholder} description={messages.customFieldsPlaceholder}
showDescription showDescription
/> />
<AccountEditSection <AccountEditSection
title={messages.featuredHashtagsTitle} title={messages.featuredHashtagsTitle}
description={messages.featuredHashtagsPlaceholder} description={messages.featuredHashtagsPlaceholder}
showDescription={!hasTags} showDescription={!hasTags}
buttons={ buttons={
<EditButton <EditButton
onClick={handleFeaturedTagsEdit} onClick={handleFeaturedTagsEdit}
edit={hasTags} edit={hasTags}
item={messages.featuredHashtagsItem} item={messages.featuredHashtagsItem}
/> />
} }
> >
{featuredTags.map((tag) => `#${tag.name}`).join(', ')} {tags.map((tag) => `#${tag.name}`).join(', ')}
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection
title={messages.profileTabTitle} title={messages.profileTabTitle}
description={messages.profileTabSubtitle} description={messages.profileTabSubtitle}
showDescription showDescription
/> />
</CustomEmojiProvider>
</AccountEditColumn> </AccountEditColumn>
); );
}; };

View File

@@ -4,6 +4,7 @@ import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis';
import { buildCustomEmojis } from '../features/emoji/emoji'; import { buildCustomEmojis } from '../features/emoji/emoji';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
/** @type {ImmutableList<import('@/mastodon/models/custom_emoji').CustomEmoji>} */
const initialState = ImmutableList([]); const initialState = ImmutableList([]);
export default function custom_emojis(state = initialState, action) { export default function custom_emojis(state = initialState, action) {

View File

@@ -6,10 +6,16 @@ import { debounce } from 'lodash';
import { import {
apiDeleteFeaturedTag, apiDeleteFeaturedTag,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,
apiGetProfile,
apiGetTagSuggestions, apiGetTagSuggestions,
apiPatchProfile,
apiPostFeaturedTag, apiPostFeaturedTag,
} from '@/mastodon/api/accounts'; } from '@/mastodon/api/accounts';
import { apiGetSearch } from '@/mastodon/api/search'; import { apiGetSearch } from '@/mastodon/api/search';
import type {
ApiProfileJSON,
ApiProfileUpdateParams,
} from '@/mastodon/api_types/profile';
import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags'; import { hashtagToFeaturedTag } from '@/mastodon/api_types/tags';
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags'; import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
import type { AppDispatch } from '@/mastodon/store'; import type { AppDispatch } from '@/mastodon/store';
@@ -17,11 +23,21 @@ import {
createAppAsyncThunk, createAppAsyncThunk,
createDataLoadingThunk, createDataLoadingThunk,
} from '@/mastodon/store/typed_functions'; } from '@/mastodon/store/typed_functions';
import type { SnakeToCamelCase } from '@/mastodon/utils/types';
interface ProfileEditState { type ProfileData = {
tags: ApiFeaturedTagJSON[]; [Key in keyof Omit<
tagSuggestions: ApiFeaturedTagJSON[]; ApiProfileJSON,
isLoading: boolean; 'note'
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
} & {
bio: ApiProfileJSON['note'];
};
export interface ProfileEditState {
profile?: ProfileData;
tags?: ApiFeaturedTagJSON[];
tagSuggestions?: ApiFeaturedTagJSON[];
isPending: boolean; isPending: boolean;
search: { search: {
query: string; query: string;
@@ -31,9 +47,6 @@ interface ProfileEditState {
} }
const initialState: ProfileEditState = { const initialState: ProfileEditState = {
tags: [],
tagSuggestions: [],
isLoading: true,
isPending: false, isPending: false,
search: { search: {
query: '', query: '',
@@ -49,6 +62,7 @@ const profileEditSlice = createSlice({
if (state.search.query === action.payload) { if (state.search.query === action.payload) {
return; return;
} }
state.search.query = action.payload; state.search.query = action.payload;
state.search.isLoading = false; state.search.isLoading = false;
state.search.results = undefined; state.search.results = undefined;
@@ -60,13 +74,25 @@ const profileEditSlice = createSlice({
}, },
}, },
extraReducers(builder) { extraReducers(builder) {
builder.addCase(fetchProfile.fulfilled, (state, action) => {
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.map(hashtagToFeaturedTag);
state.isLoading = false;
}); });
builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => { builder.addCase(fetchFeaturedTags.fulfilled, (state, action) => {
state.tags = action.payload; 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) => { builder.addCase(addFeaturedTag.pending, (state) => {
@@ -76,12 +102,18 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(addFeaturedTag.fulfilled, (state, action) => { builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) {
return;
}
state.tags = [...state.tags, action.payload].toSorted( state.tags = [...state.tags, action.payload].toSorted(
(a, b) => b.statuses_count - a.statuses_count, (a, b) => b.statuses_count - a.statuses_count,
); );
state.tagSuggestions = state.tagSuggestions.filter( if (state.tagSuggestions) {
(tag) => tag.name !== action.meta.arg.name, state.tagSuggestions = state.tagSuggestions.filter(
); (tag) => tag.name !== action.meta.arg.name,
);
}
state.isPending = false; state.isPending = false;
}); });
@@ -92,6 +124,10 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => { builder.addCase(deleteFeaturedTag.fulfilled, (state, action) => {
if (!state.tags) {
return;
}
state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId); state.tags = state.tags.filter((tag) => tag.id !== action.meta.arg.tagId);
state.isPending = false; state.isPending = false;
}); });
@@ -106,7 +142,7 @@ 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: 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) { for (const tag of action.payload) {
if (currentTags.has(tag.name)) { if (currentTags.has(tag.name)) {
@@ -125,6 +161,41 @@ const profileEditSlice = createSlice({
export const profileEdit = profileEditSlice.reducer; export const profileEdit = profileEditSlice.reducer;
export const { clearSearch } = profileEditSlice.actions; 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( export const fetchFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/fetchFeaturedTags`, `${profileEditSlice.name}/fetchFeaturedTags`,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,
@@ -143,7 +214,10 @@ export const addFeaturedTag = createDataLoadingThunk(
{ {
condition(arg, { getState }) { condition(arg, { getState }) {
const state = 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 AnyFunction = (...args: never) => unknown;
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>; 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;

View File

@@ -3,6 +3,7 @@
class REST::ProfileSerializer < ActiveModel::Serializer class REST::ProfileSerializer < ActiveModel::Serializer
include RoutingHelper include RoutingHelper
# Please update app/javascript/api_types/profile.ts when making changes to the attributes
attributes :id, :display_name, :note, :fields, attributes :id, :display_name, :note, :fields,
:avatar, :avatar_static, :avatar_description, :header, :header_static, :header_description, :avatar, :avatar_static, :avatar_description, :header, :header_static, :header_description,
:locked, :bot, :locked, :bot,