Add ability to follow hashtag from menu (#40640)

This commit is contained in:
Echo
2026-09-22 09:12:22 +00:00
committed by GitHub
parent 2545647820
commit 45d2fe7ec5
10 changed files with 260 additions and 105 deletions

View File

@@ -12,8 +12,10 @@
}
}
:any-link {
:any-link,
button {
color: var(--color-text-status-links);
text-decoration: underline;
&:hover {
text-decoration: none;

View File

@@ -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 (
<Menu>
{children}
<MenuList container={undefined}>
{signedIn && (
<MenuItem onClick={toggleFollow}>
{tag.following ? (
<FormattedMessage
id='hashtag.unfollow'
defaultMessage='Unfollow hashtag'
/>
) : (
<FormattedMessage
id='hashtag.follow'
defaultMessage='Follow hashtag'
/>
)}
</MenuItem>
)}
<MenuItemLink to={`/tags/${encodeURIComponent(tag.name)}`}>
<FormattedMessage
id='hashtag.browse'
defaultMessage='Browse posts in #{hashtag}'
values={{ hashtag: tag.name }}
/>
</MenuItemLink>
{!!account && (
<MenuItemLink
to={`/@${account.acct}/tagged/${encodeURIComponent(tag.name)}`}
>
<FormattedMessage
id='hashtag.browse_from_account'
defaultMessage='Browse posts from @{name} in #{hashtag}'
values={{
name: account.username,
hashtag: tag.name,
}}
/>
</MenuItemLink>
)}
{signedIn && (
<>
<MenuItemDivider />
<MenuItemLink as='a' href='/filters' destructive>
<FormattedMessage
id='hashtag.mute'
defaultMessage='Mute #{hashtag}'
values={{ hashtag: tag.name }}
/>
</MenuItemLink>
</>
)}
</MenuList>
</Menu>
);
};

View File

@@ -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 = <As extends React.ElementType>({
if (isMobile && isOpen) {
return (
<BottomSheet {...props} onClose={onClose}>
{children}
</BottomSheet>
<Portal container={container}>
<BottomSheet {...props} onClose={onClose}>
{children}
</BottomSheet>
</Portal>
);
}

View File

@@ -0,0 +1,12 @@
.hashtag {
appearance: none;
border: none;
background: none;
font-size: inherit;
margin: none;
padding: none;
&:hover {
text-decoration: underline;
}
}

View File

@@ -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<HandledLinkProps & ComponentProps<'a'>> = ({
) {
const hashtag = text.slice(1).trim();
if (isRedesignEnabled()) {
return (
<HashtagMenu tagId={hashtag} accountId={hashtagAccountId}>
<MenuTrigger as='button' className={classes.hashtag}>
{children}
</MenuTrigger>
</HashtagMenu>
);
}
return (
<Link
className={classNames('mention hashtag', className)}

View File

@@ -6,6 +6,8 @@ import { useOverflowButton } from '@/mastodon/hooks/useOverflow';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { Button } from '../button/redesign';
import { HashtagMenu } from '../hashtag_menu';
import { MenuTrigger } from '../menu';
import classes from './styles.module.scss';
@@ -32,16 +34,14 @@ export const StatusHashtagBar: React.FC<{
style={{ maxWidth }}
>
{hashtags.map((hashtag, index) => (
<Button
key={hashtag}
size='xs'
as='link'
to={`/tags/${hashtag}`}
data-menu-hashtag={accountId}
inert={hiddenIndex > 0 && index >= hiddenIndex}
>
#{hashtag}
</Button>
<HashtagMenu tagId={hashtag} accountId={accountId} key={hashtag}>
<MenuTrigger
size='xs'
inert={hiddenIndex > 0 && index >= hiddenIndex}
>
#{hashtag}
</MenuTrigger>
</HashtagMenu>
))}
</div>
{hiddenCount > 0 && !showOverflow && (

View File

@@ -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;

View File

@@ -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<ApiHashtagJSON>();
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 }) => {

View File

@@ -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 (
<Popover isOpen={open} offset={5} reference={element} onClose={handleClose}>
<Popover isOpen offset={5} reference={element} onClose={handleClose}>
{({ props, placement }) => (
<div
{...props}

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react';
import { isFulfilled } from '@reduxjs/toolkit';
import {
featureHashtag,
fetchHashtag,
followHashtag,
unfeatureHashtag,
unfollowHashtag,
} from '../actions/tags_typed';
import type { ApiHashtagJSON } from '../api_types/tags';
import { useIdentity } from '../identity_context';
import { useAppDispatch } from '../store';
export function useHashtag(tagId?: string) {
const dispatch = useAppDispatch();
const [tag, setTag] = useState<ApiHashtagJSON>();
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 };
}