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 (
+
+ );
+};
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 };
+}