mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 11:11:11 +02:00
Profile editing: Allow adding arbitrary featured tags (#38012)
This commit is contained in:
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,10 @@ interface ComboboxProps<T extends ComboboxItem> 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.
|
||||
*/
|
||||
|
||||
@@ -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<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 = () => {
|
||||
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<HTMLInputElement> = 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 = () => {
|
||||
<Combobox
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'account_edit_tags.search_placeholder',
|
||||
defaultMessage: 'Enter a hashtag…',
|
||||
})}
|
||||
items={results ?? []}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
items={results}
|
||||
isLoading={isLoading}
|
||||
renderItem={renderItem}
|
||||
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}`;
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
<AccountEditTagSearch />
|
||||
|
||||
{tagSuggestions.length > 0 && (
|
||||
<div className={classes.tagSuggestions}>
|
||||
<FormattedMessage
|
||||
@@ -90,7 +92,9 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingIndicator />}
|
||||
|
||||
<AccountEditItemList
|
||||
items={tags}
|
||||
disabled={isPending}
|
||||
@@ -102,15 +106,15 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function renderTag(tag: ApiFeaturedTagJSON) {
|
||||
function renderTag(tag: TagData) {
|
||||
return (
|
||||
<div className={classes.tagItem}>
|
||||
<h4>#{tag.name}</h4>
|
||||
{tag.statuses_count > 0 && (
|
||||
{tag.statusesCount > 0 && (
|
||||
<FormattedMessage
|
||||
id='account_edit_tags.tag_status_count'
|
||||
defaultMessage='{count, plural, one {# post} other {# posts}}'
|
||||
values={{ count: tag.statuses_count }}
|
||||
values={{ count: tag.statusesCount }}
|
||||
tagName='p'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<AccountEditColumn
|
||||
@@ -190,7 +186,7 @@ export const AccountEdit: FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{tags.map((tag) => `#${tag.name}`).join(', ')}
|
||||
{profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')}
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"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.save": "Save",
|
||||
"account_edit_tags.add_tag": "Add #{tagName}",
|
||||
"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 page’s Activity view.",
|
||||
"account_edit_tags.search_placeholder": "Enter a hashtag…",
|
||||
|
||||
@@ -16,8 +16,10 @@ 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 {
|
||||
ApiFeaturedTagJSON,
|
||||
ApiHashtagJSON,
|
||||
} from '@/mastodon/api_types/tags';
|
||||
import type { AppDispatch } from '@/mastodon/store';
|
||||
import {
|
||||
createAppAsyncThunk,
|
||||
@@ -28,21 +30,30 @@ import type { SnakeToCamelCase } from '@/mastodon/utils/types';
|
||||
type ProfileData = {
|
||||
[Key in keyof Omit<
|
||||
ApiProfileJSON,
|
||||
'note'
|
||||
'note' | 'featured_tags'
|
||||
> as SnakeToCamelCase<Key>]: ApiProfileJSON[Key];
|
||||
} & {
|
||||
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 {
|
||||
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,
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user