Profile redesign: Nudge to add featured tags (#38315)
This commit is contained in:
parent
bd16e3f63e
commit
931da0c327
@ -1,4 +1,4 @@
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ import classes from './styles.module.css';
|
|||||||
export interface CalloutProps {
|
export interface CalloutProps {
|
||||||
variant?:
|
variant?:
|
||||||
| 'default'
|
| 'default'
|
||||||
// | 'subtle'
|
| 'subtle'
|
||||||
| 'feature'
|
| 'feature'
|
||||||
| 'inverted'
|
| 'inverted'
|
||||||
| 'success'
|
| 'success'
|
||||||
@ -31,9 +31,9 @@ export interface CalloutProps {
|
|||||||
/** Set to false to hide the icon. */
|
/** Set to false to hide the icon. */
|
||||||
icon?: IconProp | boolean;
|
icon?: IconProp | boolean;
|
||||||
onPrimary?: () => void;
|
onPrimary?: () => void;
|
||||||
primaryLabel?: string;
|
primaryLabel?: string | ReactElement;
|
||||||
onSecondary?: () => void;
|
onSecondary?: () => void;
|
||||||
secondaryLabel?: string;
|
secondaryLabel?: string | ReactElement;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
extraContent?: ReactNode;
|
extraContent?: ReactNode;
|
||||||
@ -41,7 +41,7 @@ export interface CalloutProps {
|
|||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: classes.variantDefault as string,
|
default: classes.variantDefault as string,
|
||||||
// subtle: classes.variantSubtle as string,
|
subtle: classes.variantSubtle as string,
|
||||||
feature: classes.variantFeature as string,
|
feature: classes.variantFeature as string,
|
||||||
inverted: classes.variantInverted as string,
|
inverted: classes.variantInverted as string,
|
||||||
success: classes.variantSuccess as string,
|
success: classes.variantSuccess as string,
|
||||||
|
|||||||
@ -32,6 +32,10 @@
|
|||||||
.body {
|
.body {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
@ -51,6 +55,7 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
text-wrap: nowrap;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
transition: color 0.1s ease-in-out;
|
transition: color 0.1s ease-in-out;
|
||||||
|
|
||||||
@ -80,14 +85,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .variantSubtle {
|
.variantSubtle {
|
||||||
border: 1px solid var(--color-bg-brand-softer);
|
border: 1px solid var(--color-bg-brand-softer);
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
background-color: var(--color-bg-brand-softer);
|
background-color: var(--color-bg-brand-softer);
|
||||||
}
|
}
|
||||||
} */
|
}
|
||||||
|
|
||||||
.variantFeature {
|
.variantFeature {
|
||||||
background-color: var(--color-bg-brand-base);
|
background-color: var(--color-bg-brand-base);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||||||
import { Combobox } from '@/mastodon/components/form_fields';
|
import { Combobox } from '@/mastodon/components/form_fields';
|
||||||
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
|
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
|
||||||
import type { TagSearchResult } 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 { useAppDispatch } from '@/mastodon/store';
|
||||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ export const AccountEditTagSearch: FC = () => {
|
|||||||
(item: TagSearchResult) => {
|
(item: TagSearchResult) => {
|
||||||
resetSearch();
|
resetSearch();
|
||||||
setQuery('');
|
setQuery('');
|
||||||
void dispatch(addFeaturedTag({ name: item.name }));
|
void dispatch(addFeaturedTags({ names: [item.name] }));
|
||||||
},
|
},
|
||||||
[dispatch, resetSearch],
|
[dispatch, resetSearch],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useAccount } from '@/mastodon/hooks/useAccount';
|
|||||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
|
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
|
||||||
import {
|
import {
|
||||||
addFeaturedTag,
|
addFeaturedTags,
|
||||||
deleteFeaturedTag,
|
deleteFeaturedTag,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchSuggestedTags,
|
fetchSuggestedTags,
|
||||||
@ -128,7 +128,7 @@ const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleAddTag = useCallback(() => {
|
const handleAddTag = useCallback(() => {
|
||||||
void dispatch(addFeaturedTag({ name }));
|
void dispatch(addFeaturedTags({ names: [name] }));
|
||||||
}, [dispatch, name]);
|
}, [dispatch, name]);
|
||||||
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
|
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,7 +18,10 @@ import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
|||||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||||
import StatusList from '@/mastodon/components/status_list';
|
import StatusList from '@/mastodon/components/status_list';
|
||||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
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 { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
@ -34,6 +37,7 @@ import {
|
|||||||
usePinnedStatusIds,
|
usePinnedStatusIds,
|
||||||
} from './pinned_statuses';
|
} from './pinned_statuses';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
|
import { TagSuggestions } from './tags_suggestions';
|
||||||
|
|
||||||
const emptyList = ImmutableList<string>();
|
const emptyList = ImmutableList<string>();
|
||||||
|
|
||||||
@ -135,6 +139,7 @@ const Prepend: FC<{
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
forceEmpty: boolean;
|
forceEmpty: boolean;
|
||||||
}> = ({ forceEmpty, accountId }) => {
|
}> = ({ forceEmpty, accountId }) => {
|
||||||
|
const me = useCurrentAccountId();
|
||||||
if (forceEmpty) {
|
if (forceEmpty) {
|
||||||
return <AccountHeader accountId={accountId} hideTabs />;
|
return <AccountHeader accountId={accountId} hideTabs />;
|
||||||
}
|
}
|
||||||
@ -144,6 +149,7 @@ const Prepend: FC<{
|
|||||||
<AccountHeader accountId={accountId} hideTabs />
|
<AccountHeader accountId={accountId} hideTabs />
|
||||||
<AccountFilters />
|
<AccountFilters />
|
||||||
<FeaturedTags accountId={accountId} />
|
<FeaturedTags accountId={accountId} />
|
||||||
|
{me === accountId && <TagSuggestions />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -49,8 +49,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagsWrapper {
|
.tagsWrapper,
|
||||||
|
.tagSuggestions {
|
||||||
margin: 0 24px 8px;
|
margin: 0 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
@ -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 you’ve posted about {items}. Add these as featured hashtags?'
|
||||||
|
values={{
|
||||||
|
items: (
|
||||||
|
<FormattedList
|
||||||
|
value={suggestedTags.map(({ name }) => `#${name}`)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -628,6 +628,10 @@
|
|||||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||||
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
||||||
"featured_tags.more_items": "+{count}",
|
"featured_tags.more_items": "+{count}",
|
||||||
|
"featured_tags.suggestions": "Lately you’ve 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_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.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.",
|
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
||||||
|
|||||||
@ -104,24 +104,24 @@ const profileEditSlice = createSlice({
|
|||||||
state.isPending = false;
|
state.isPending = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addCase(addFeaturedTag.pending, (state) => {
|
builder.addCase(addFeaturedTags.pending, (state) => {
|
||||||
state.isPending = true;
|
state.isPending = true;
|
||||||
});
|
});
|
||||||
builder.addCase(addFeaturedTag.rejected, (state) => {
|
builder.addCase(addFeaturedTags.rejected, (state) => {
|
||||||
state.isPending = false;
|
state.isPending = false;
|
||||||
});
|
});
|
||||||
builder.addCase(addFeaturedTag.fulfilled, (state, action) => {
|
builder.addCase(addFeaturedTags.fulfilled, (state, action) => {
|
||||||
if (!state.profile) {
|
if (!state.profile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.profile.featuredTags = [
|
state.profile.featuredTags = [
|
||||||
...state.profile.featuredTags,
|
...state.profile.featuredTags,
|
||||||
transformTag(action.payload),
|
...action.payload.map(transformTag),
|
||||||
].toSorted((a, b) => a.name.localeCompare(b.name));
|
].toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
if (state.tagSuggestions) {
|
if (state.tagSuggestions) {
|
||||||
state.tagSuggestions = state.tagSuggestions.filter(
|
state.tagSuggestions = state.tagSuggestions.filter(
|
||||||
(tag) => tag.name !== action.meta.arg.name,
|
(tag) => !action.meta.arg.names.includes(tag.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.isPending = false;
|
state.isPending = false;
|
||||||
@ -350,20 +350,11 @@ export const fetchSuggestedTags = createDataLoadingThunk(
|
|||||||
{ useLoadingBar: false },
|
{ useLoadingBar: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const addFeaturedTag = createDataLoadingThunk(
|
export const addFeaturedTags = createDataLoadingThunk(
|
||||||
`${profileEditSlice.name}/addFeaturedTag`,
|
`${profileEditSlice.name}/addFeaturedTag`,
|
||||||
({ name }: { name: string }) => apiPostFeaturedTag(name),
|
({ names }: { names: string[] }) =>
|
||||||
{
|
Promise.all(names.map((n) => apiPostFeaturedTag(n))),
|
||||||
condition(arg, { getState }) {
|
{ useLoadingBar: false },
|
||||||
const state = getState();
|
|
||||||
return (
|
|
||||||
!!state.profileEdit.profile &&
|
|
||||||
!state.profileEdit.profile.featuredTags.some(
|
|
||||||
(tag) => tag.name === arg.name,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const deleteFeaturedTag = createDataLoadingThunk(
|
export const deleteFeaturedTag = createDataLoadingThunk(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user