From 45d2fe7ec5e1f08bc31ab690c498ed0e8d4a42ac Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 22 Sep 2026 09:12:22 +0000 Subject: [PATCH] Add ability to follow hashtag from menu (#40640) --- .../components/account_bio/styles.module.scss | 4 +- .../mastodon/components/hashtag_menu.tsx | 88 +++++++++++++++++++ .../mastodon/components/menu/card.tsx | 9 +- .../status/handled_link.module.scss | 12 +++ .../components/status/handled_link.tsx | 16 ++++ .../components/status/hashtag_bar.tsx | 20 ++--- .../components/hashtag_column_menu.tsx | 3 +- .../components/hashtag_header.tsx | 84 +----------------- .../ui/components/hashtag_menu_controller.tsx | 42 +++++++-- app/javascript/mastodon/hooks/useHashtag.ts | 87 ++++++++++++++++++ 10 files changed, 260 insertions(+), 105 deletions(-) create mode 100644 app/javascript/mastodon/components/hashtag_menu.tsx create mode 100644 app/javascript/mastodon/components/status/handled_link.module.scss create mode 100644 app/javascript/mastodon/hooks/useHashtag.ts diff --git a/app/javascript/mastodon/components/account_bio/styles.module.scss b/app/javascript/mastodon/components/account_bio/styles.module.scss index 62e64a219a0..c660921a30d 100644 --- a/app/javascript/mastodon/components/account_bio/styles.module.scss +++ b/app/javascript/mastodon/components/account_bio/styles.module.scss @@ -12,8 +12,10 @@ } } - :any-link { + :any-link, + button { color: var(--color-text-status-links); + text-decoration: underline; &:hover { text-decoration: none; diff --git a/app/javascript/mastodon/components/hashtag_menu.tsx b/app/javascript/mastodon/components/hashtag_menu.tsx new file mode 100644 index 00000000000..e64697a1f9f --- /dev/null +++ b/app/javascript/mastodon/components/hashtag_menu.tsx @@ -0,0 +1,88 @@ +import { FormattedMessage } from 'react-intl'; + +import { useHashtag } from '@/mastodon/hooks/useHashtag'; +import { useIdentity } from '@/mastodon/identity_context'; + +import { selectPlainAccount } from '../selectors/accounts'; +import { useAppSelector } from '../store'; + +import { + Menu, + MenuItem, + MenuItemDivider, + MenuItemLink, + MenuList, +} from './menu'; + +export const HashtagMenu: React.FC<{ + tagId: string; + accountId?: string; + children?: React.ReactNode; +}> = ({ tagId, accountId, children }) => { + const { tag, toggleFollow } = useHashtag(tagId); + const account = useAppSelector((state) => + selectPlainAccount(state, accountId), + ); + const { signedIn } = useIdentity(); + + if (!tag) { + return null; + } + + return ( + + {children} + + + {signedIn && ( + + {tag.following ? ( + + ) : ( + + )} + + )} + + + + {!!account && ( + + + + )} + {signedIn && ( + <> + + + + + + )} + + + ); +}; diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx index 47411b80599..8a655a82cbc 100644 --- a/app/javascript/mastodon/components/menu/card.tsx +++ b/app/javascript/mastodon/components/menu/card.tsx @@ -9,6 +9,7 @@ import type { PolymorphicProps } from '@/types/polymorphic'; import { BottomSheet } from '../bottom_sheet'; import { Popover } from '../popover'; import type { PopoverProps } from '../popover'; +import { Portal } from '../popover/portal'; import classes from './styles.module.scss'; @@ -97,9 +98,11 @@ export const PopoverMenuCard = ({ if (isMobile && isOpen) { return ( - - {children} - + + + {children} + + ); } diff --git a/app/javascript/mastodon/components/status/handled_link.module.scss b/app/javascript/mastodon/components/status/handled_link.module.scss new file mode 100644 index 00000000000..17705bb6131 --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.module.scss @@ -0,0 +1,12 @@ +.hashtag { + appearance: none; + border: none; + background: none; + font-size: inherit; + margin: none; + padding: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index e571edfea6d..ec4ce870d61 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -6,8 +6,14 @@ import { Link } from 'react-router-dom'; import type { ApiMentionJSON } from '@/mastodon/api_types/statuses'; import { getCollectionPath } from '@/mastodon/features/collections/utils'; +import { isRedesignEnabled } from '@/mastodon/utils/environment'; import type { OnElementHandler } from '@/mastodon/utils/html'; +import { HashtagMenu } from '../hashtag_menu'; +import { MenuTrigger } from '../menu'; + +import classes from './handled_link.module.scss'; + export interface HandledLinkProps { href: string; text: string; @@ -38,6 +44,16 @@ export const HandledLink: FC> = ({ ) { const hashtag = text.slice(1).trim(); + if (isRedesignEnabled()) { + return ( + + + {children} + + + ); + } + return ( {hashtags.map((hashtag, index) => ( - + + 0 && index >= hiddenIndex} + > + #{hashtag} + + ))} {hiddenCount > 0 && !showOverflow && ( diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_column_menu.tsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_column_menu.tsx index 12807911834..edc35289bf6 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_column_menu.tsx +++ b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_column_menu.tsx @@ -12,12 +12,13 @@ import { MenuItemDivider, } from '@/mastodon/components/menu'; import type { MenuItemCheckboxChangeHandler } from '@/mastodon/components/menu/items'; +import { useHashtag } from '@/mastodon/hooks/useHashtag'; import { useIdentity } from '@/mastodon/identity_context'; import { useAppDispatch } from 'mastodon/store'; import { useColumnSettings } from '../../public_timeline/components/feed_column_settings'; -import { useHashtag, messages } from './hashtag_header'; +import { messages } from './hashtag_header'; export const HashtagColumnMenu: React.FC<{ tagId: string; diff --git a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx index a99f2ccbc0f..a7d575e1bb7 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx +++ b/app/javascript/mastodon/features/hashtag_timeline/components/hashtag_header.tsx @@ -1,24 +1,14 @@ -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; -import { isFulfilled } from '@reduxjs/toolkit'; - +import { useHashtag } from '@/mastodon/hooks/useHashtag'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import { - fetchHashtag, - followHashtag, - unfollowHashtag, - featureHashtag, - unfeatureHashtag, -} from 'mastodon/actions/tags_typed'; -import type { ApiHashtagJSON } from 'mastodon/api_types/tags'; import { Button } from 'mastodon/components/button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { ShortNumber } from 'mastodon/components/short_number'; import { useIdentity } from 'mastodon/identity_context'; import { PERMISSION_MANAGE_TAXONOMIES } from 'mastodon/permissions'; -import { useAppDispatch } from 'mastodon/store'; export const messages = defineMessages({ followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, @@ -76,76 +66,6 @@ const usesTodayRenderer = ( /> ); -export function useHashtag(tagId: string) { - const dispatch = useAppDispatch(); - const [tag, setTag] = useState(); - - useEffect(() => { - void dispatch(fetchHashtag({ tagId })).then((result) => { - if (isFulfilled(result)) { - setTag(result.payload); - } - - return ''; - }); - }, [dispatch, tagId, setTag]); - - const toggleFeature = useCallback(() => { - if (!tag) { - return; - } - if (tag.featuring) { - void dispatch(unfeatureHashtag({ tagId })).then((result) => { - if (isFulfilled(result)) { - setTag(result.payload); - } - - return ''; - }); - } else { - void dispatch(featureHashtag({ tagId })).then((result) => { - if (isFulfilled(result)) { - setTag(result.payload); - } - - return ''; - }); - } - }, [dispatch, tag, tagId]); - - const { signedIn } = useIdentity(); - - const toggleFollow = useCallback(() => { - if (!signedIn || !tag) { - return; - } - - if (tag.following) { - setTag((hashtag) => hashtag && { ...hashtag, following: false }); - - void dispatch(unfollowHashtag({ tagId })).then((result) => { - if (isFulfilled(result)) { - setTag(result.payload); - } - - return ''; - }); - } else { - setTag((hashtag) => hashtag && { ...hashtag, following: true }); - - void dispatch(followHashtag({ tagId })).then((result) => { - if (isFulfilled(result)) { - setTag(result.payload); - } - - return ''; - }); - } - }, [dispatch, signedIn, tag, tagId]); - - return { tag, toggleFollow, toggleFeature }; -} - export const HashtagHeader: React.FC<{ tagId: string; }> = ({ tagId }) => { diff --git a/app/javascript/mastodon/features/ui/components/hashtag_menu_controller.tsx b/app/javascript/mastodon/features/ui/components/hashtag_menu_controller.tsx index 082795ede01..2209c6f75c6 100644 --- a/app/javascript/mastodon/features/ui/components/hashtag_menu_controller.tsx +++ b/app/javascript/mastodon/features/ui/components/hashtag_menu_controller.tsx @@ -4,6 +4,7 @@ import { useIntl, defineMessages } from 'react-intl'; import { useLocation } from 'react-router-dom'; +import { useHashtag } from '@/mastodon/hooks/useHashtag'; import { DropdownMenu } from 'mastodon/components/dropdown_menu'; import { Popover } from 'mastodon/components/popover'; import { useIdentity } from 'mastodon/identity_context'; @@ -11,6 +12,14 @@ import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppSelector } from 'mastodon/store'; const messages = defineMessages({ + follow: { + id: 'hashtag.follow', + defaultMessage: 'Follow hashtag', + }, + unfollow: { + id: 'hashtag.unfollow', + defaultMessage: 'Unfollow hashtag', + }, browseHashtag: { id: 'hashtag.browse', defaultMessage: 'Browse posts in #{hashtag}', @@ -46,6 +55,8 @@ export const HashtagMenuController: React.FC = () => { const { element = null, accountId, hashtag } = target ?? {}; const open = !!element; + const { tag, toggleFollow } = useHashtag(hashtag); + const account = useAppSelector((state) => accountId ? state.accounts.get(accountId) : undefined, ); @@ -93,26 +104,41 @@ export const HashtagMenuController: React.FC = () => { }, []); const menu = useMemo(() => { - const arr: MenuItem[] = [ + if (!tag) { + return []; + } + + const arr: MenuItem[] = signedIn + ? [ + { + text: intl.formatMessage( + tag.following ? messages.unfollow : messages.follow, + ), + action: toggleFollow, + }, + ] + : []; + + arr.push( { text: intl.formatMessage(messages.browseHashtag, { - hashtag, + hashtag: tag.name, }), - to: `/tags/${hashtag}`, + to: `/tags/${encodeURIComponent(tag.name)}`, }, { text: intl.formatMessage(messages.browseHashtagFromAccount, { hashtag, name: account?.username, }), - to: `/@${account?.acct}/tagged/${hashtag}`, + to: `/@${account?.acct}/tagged/${encodeURIComponent(tag.name)}`, }, - ]; + ); if (signedIn) { arr.push(null, { text: intl.formatMessage(messages.muteHashtag, { - hashtag, + hashtag: tag.name, }), href: '/filters', dangerous: true, @@ -120,14 +146,14 @@ export const HashtagMenuController: React.FC = () => { } return arr; - }, [intl, hashtag, account, signedIn]); + }, [tag, intl, toggleFollow, hashtag, account, signedIn]); if (!open) { return null; } return ( - + {({ props, placement }) => (
(); + + useEffect(() => { + if (!tagId) { + return; + } + void dispatch(fetchHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + }, [dispatch, tagId, setTag]); + + const toggleFeature = useCallback(() => { + if (!tag || !tagId) { + return; + } + if (tag.featuring) { + void dispatch(unfeatureHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } else { + void dispatch(featureHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } + }, [dispatch, tag, tagId]); + + const { signedIn } = useIdentity(); + + const toggleFollow = useCallback(() => { + if (!signedIn || !tag || !tagId) { + return; + } + + if (tag.following) { + setTag((hashtag) => hashtag && { ...hashtag, following: false }); + + void dispatch(unfollowHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } else { + setTag((hashtag) => hashtag && { ...hashtag, following: true }); + + void dispatch(followHashtag({ tagId })).then((result) => { + if (isFulfilled(result)) { + setTag(result.payload); + } + + return ''; + }); + } + }, [dispatch, signedIn, tag, tagId]); + + return { tag, toggleFollow, toggleFeature }; +}