mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-27 16:17:48 -05:00
Add ability to follow hashtag from menu (#40640)
This commit is contained in:
@@ -12,8 +12,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
:any-link {
|
||||
:any-link,
|
||||
button {
|
||||
color: var(--color-text-status-links);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
88
app/javascript/mastodon/components/hashtag_menu.tsx
Normal file
88
app/javascript/mastodon/components/hashtag_menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.hashtag {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
margin: none;
|
||||
padding: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
87
app/javascript/mastodon/hooks/useHashtag.ts
Normal file
87
app/javascript/mastodon/hooks/useHashtag.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user