diff --git a/app/javascript/mastodon/components/callout/index.tsx b/app/javascript/mastodon/components/callout/index.tsx index a9232ec3a7..fe088e2a83 100644 --- a/app/javascript/mastodon/components/callout/index.tsx +++ b/app/javascript/mastodon/components/callout/index.tsx @@ -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, diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css index 7f33c96eae..14003ccf5d 100644 --- a/app/javascript/mastodon/components/callout/styles.module.css +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -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); diff --git a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx index 9c67f4e4dd..3b423c0735 100644 --- a/app/javascript/mastodon/features/account_edit/components/tag_search.tsx +++ b/app/javascript/mastodon/features/account_edit/components/tag_search.tsx @@ -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], ); diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index dbcdad6d62..929fd554d5 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -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 ; }; diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx index c88d2a12d9..693813bdbe 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -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(); @@ -135,6 +139,7 @@ const Prepend: FC<{ accountId: string; forceEmpty: boolean; }> = ({ forceEmpty, accountId }) => { + const me = useCurrentAccountId(); if (forceEmpty) { return ; } @@ -144,6 +149,7 @@ const Prepend: FC<{ + {me === accountId && } ); }; diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index b39892beec..1df19feb1d 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -49,8 +49,12 @@ } } -.tagsWrapper { +.tagsWrapper, +.tagSuggestions { margin: 0 24px 8px; +} + +.tagsWrapper { display: flex; flex-wrap: nowrap; justify-content: flex-start; diff --git a/app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx b/app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx new file mode 100644 index 0000000000..93ac491f6c --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/tags_suggestions.tsx @@ -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 ( + + {chunks}, + }} + /> + + ); + } + + if ( + isLoading || + !suggestedTags.length || + existingTagCount > 0 || + wasDismissed + ) { + return null; + } + + return ( + + } + onSecondary={dismiss} + secondaryLabel={ + + } + > + `#${name}`)} + /> + ), + }} + /> + + ); +}; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e3feb120a2..c33e48dcdd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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 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 Edit Profile > Featured hashtags.", + "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.", diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index 9148a55f5f..2437352fbd 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -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(