Profile redesign: Nudge to add featured tags (#38315)

This commit is contained in:
Echo
2026-03-23 10:07:53 +01:00
committed by GitHub
parent bd16e3f63e
commit 931da0c327
9 changed files with 169 additions and 31 deletions

View File

@@ -1,4 +1,4 @@
import type { FC, ReactNode } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import { useIntl } from 'react-intl';
@@ -19,7 +19,7 @@ import classes from './styles.module.css';
export interface CalloutProps {
variant?:
| 'default'
// | 'subtle'
| 'subtle'
| 'feature'
| 'inverted'
| 'success'
@@ -31,9 +31,9 @@ export interface CalloutProps {
/** Set to false to hide the icon. */
icon?: IconProp | boolean;
onPrimary?: () => void;
primaryLabel?: string;
primaryLabel?: string | ReactElement;
onSecondary?: () => void;
secondaryLabel?: string;
secondaryLabel?: string | ReactElement;
onClose?: () => void;
id?: string;
extraContent?: ReactNode;
@@ -41,7 +41,7 @@ export interface CalloutProps {
const variantClasses = {
default: classes.variantDefault as string,
// subtle: classes.variantSubtle as string,
subtle: classes.variantSubtle as string,
feature: classes.variantFeature as string,
inverted: classes.variantInverted as string,
success: classes.variantSuccess as string,

View File

@@ -32,6 +32,10 @@
.body {
flex-grow: 1;
a {
color: inherit;
}
h3 {
font-weight: 500;
margin-bottom: 5px;
@@ -51,6 +55,7 @@
color: inherit;
font-weight: 500;
padding: 0;
text-wrap: nowrap;
text-decoration: underline;
transition: color 0.1s ease-in-out;
@@ -80,14 +85,14 @@
}
}
/* .variantSubtle {
.variantSubtle {
border: 1px solid var(--color-bg-brand-softer);
background-color: var(--color-bg-primary);
.icon {
background-color: var(--color-bg-brand-softer);
}
} */
}
.variantFeature {
background-color: var(--color-bg-brand-base);

View File

@@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { Combobox } from '@/mastodon/components/form_fields';
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
import type { TagSearchResult } from '@/mastodon/hooks/useSearchTags';
import { addFeaturedTag } from '@/mastodon/reducers/slices/profile_edit';
import { addFeaturedTags } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch } from '@/mastodon/store';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@@ -47,7 +47,7 @@ export const AccountEditTagSearch: FC = () => {
(item: TagSearchResult) => {
resetSearch();
setQuery('');
void dispatch(addFeaturedTag({ name: item.name }));
void dispatch(addFeaturedTags({ names: [item.name] }));
},
[dispatch, resetSearch],
);

View File

@@ -9,7 +9,7 @@ import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
import {
addFeaturedTag,
addFeaturedTags,
deleteFeaturedTag,
fetchProfile,
fetchSuggestedTags,
@@ -128,7 +128,7 @@ const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
}) => {
const dispatch = useAppDispatch();
const handleAddTag = useCallback(() => {
void dispatch(addFeaturedTag({ name }));
void dispatch(addFeaturedTags({ names: [name] }));
}, [dispatch, name]);
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
};

View File

@@ -18,7 +18,10 @@ import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { RemoteHint } from '@/mastodon/components/remote_hint';
import StatusList from '@/mastodon/components/status_list';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import {
useAccountId,
useCurrentAccountId,
} from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -34,6 +37,7 @@ import {
usePinnedStatusIds,
} from './pinned_statuses';
import classes from './styles.module.scss';
import { TagSuggestions } from './tags_suggestions';
const emptyList = ImmutableList<string>();
@@ -135,6 +139,7 @@ const Prepend: FC<{
accountId: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId }) => {
const me = useCurrentAccountId();
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
@@ -144,6 +149,7 @@ const Prepend: FC<{
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
{me === accountId && <TagSuggestions />}
</>
);
};

View File

@@ -49,8 +49,12 @@
}
}
.tagsWrapper {
.tagsWrapper,
.tagSuggestions {
margin: 0 24px 8px;
}
.tagsWrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;

View File

@@ -0,0 +1,128 @@
import type { FC } from 'react';
import { useEffect, useCallback, useState } from 'react';
import { FormattedMessage, FormattedList } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
import { Callout } from '@/mastodon/components/callout';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useDismissible } from '@/mastodon/hooks/useDismissible';
import {
fetchProfile,
fetchSuggestedTags,
addFeaturedTags,
} from '@/mastodon/reducers/slices/profile_edit';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import classes from './styles.module.scss';
const MAX_SUGGESTED_TAGS = 3;
export const TagSuggestions: FC = () => {
const { dismiss, wasDismissed } = useDismissible(
'profile/featured_tag_suggestions',
);
const suggestedTags = useAppSelector((state) =>
state.profileEdit.tagSuggestions?.slice(0, MAX_SUGGESTED_TAGS),
);
const existingTagCount = useAppSelector(
(state) => state.profileEdit.profile?.featuredTags.length,
);
const dispatch = useAppDispatch();
const isLoading = !suggestedTags || existingTagCount === undefined;
useEffect(() => {
if (isLoading) {
void dispatch(fetchProfile());
void dispatch(fetchSuggestedTags());
}
}, [dispatch, isLoading]);
const me = useCurrentAccountId();
const [showSuccessNotice, setSuccessNotice] = useState(false);
const handleAdd = useCallback(() => {
if (!suggestedTags?.length || !me) {
return;
}
const addTags = async () => {
await dispatch(
addFeaturedTags({ names: suggestedTags.map((tag) => tag.name) }),
);
await dispatch(fetchFeaturedTags({ accountId: me }));
setSuccessNotice(true);
dismiss();
};
void addTags();
}, [dismiss, dispatch, me, suggestedTags]);
const handleDismissSuccessNotice = useCallback(() => {
setSuccessNotice(false);
}, []);
if (showSuccessNotice) {
return (
<Callout
variant='subtle'
className={classes.tagSuggestions}
onClose={handleDismissSuccessNotice}
>
<FormattedMessage
id='featured_tags.suggestions.added'
defaultMessage='Manage your featured hashtags at any time under <link>Edit Profile > Featured hashtags</link>.'
values={{
link: (chunks) => <Link to='/profile/featured_tags'>{chunks}</Link>,
}}
/>
</Callout>
);
}
if (
isLoading ||
!suggestedTags.length ||
existingTagCount > 0 ||
wasDismissed
) {
return null;
}
return (
<Callout
id='featured_tags.suggestions'
variant='subtle'
className={classes.tagSuggestions}
onPrimary={handleAdd}
primaryLabel={
<FormattedMessage
id='featured_tags.suggestions.add'
defaultMessage='Add'
/>
}
onSecondary={dismiss}
secondaryLabel={
<FormattedMessage
id='featured_tags.suggestions.dismiss'
defaultMessage='No thanks'
/>
}
>
<FormattedMessage
id='featured_tags.suggestions'
defaultMessage='Lately youve posted about {items}. Add these as featured hashtags?'
values={{
items: (
<FormattedList
value={suggestedTags.map(({ name }) => `#${name}`)}
/>
),
}}
/>
</Callout>
);
};

View File

@@ -628,6 +628,10 @@
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
"featured_carousel.slide": "Post {current, number} of {max, number}",
"featured_tags.more_items": "+{count}",
"featured_tags.suggestions": "Lately youve posted about {items}. Add these as featured hashtags?",
"featured_tags.suggestions.add": "Add",
"featured_tags.suggestions.added": "Manage your featured hashtags at any time under <link>Edit Profile > Featured hashtags</link>.",
"featured_tags.suggestions.dismiss": "No thanks",
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
"filter_modal.added.context_mismatch_title": "Context mismatch!",
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",

View File

@@ -104,24 +104,24 @@ const profileEditSlice = createSlice({
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => {
builder.addCase(addFeaturedTags.pending, (state) => {
state.isPending = true;
});
builder.addCase(addFeaturedTag.rejected, (state) => {
builder.addCase(addFeaturedTags.rejected, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
builder.addCase(addFeaturedTags.fulfilled, (state, action) => {
if (!state.profile) {
return;
}
state.profile.featuredTags = [
...state.profile.featuredTags,
transformTag(action.payload),
...action.payload.map(transformTag),
].toSorted((a, b) => a.name.localeCompare(b.name));
if (state.tagSuggestions) {
state.tagSuggestions = state.tagSuggestions.filter(
(tag) => tag.name !== action.meta.arg.name,
(tag) => !action.meta.arg.names.includes(tag.name),
);
}
state.isPending = false;
@@ -350,20 +350,11 @@ export const fetchSuggestedTags = createDataLoadingThunk(
{ useLoadingBar: false },
);
export const addFeaturedTag = createDataLoadingThunk(
export const addFeaturedTags = createDataLoadingThunk(
`${profileEditSlice.name}/addFeaturedTag`,
({ name }: { name: string }) => apiPostFeaturedTag(name),
{
condition(arg, { getState }) {
const state = getState();
return (
!!state.profileEdit.profile &&
!state.profileEdit.profile.featuredTags.some(
(tag) => tag.name === arg.name,
)
);
},
},
({ names }: { names: string[] }) =>
Promise.all(names.map((n) => apiPostFeaturedTag(n))),
{ useLoadingBar: false },
);
export const deleteFeaturedTag = createDataLoadingThunk(