Add dropdown menu to hashtag links in web UI (#34393)

This commit is contained in:
Eugen Rochko 2025-04-11 12:50:46 +02:00 committed by GitHub
parent a296facdea
commit a9cfaa6eed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 213 additions and 22 deletions

View File

@ -71,6 +71,8 @@ type RenderItemFn<Item = MenuItem> = (
}, },
) => React.ReactNode; ) => React.ReactNode;
type ItemClickFn<Item = MenuItem> = (item: Item, index: number) => void;
type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode; type RenderHeaderFn<Item = MenuItem> = (items: Item[]) => React.ReactNode;
interface DropdownMenuProps<Item = MenuItem> { interface DropdownMenuProps<Item = MenuItem> {
@ -81,10 +83,10 @@ interface DropdownMenuProps<Item = MenuItem> {
openedViaKeyboard: boolean; openedViaKeyboard: boolean;
renderItem?: RenderItemFn<Item>; renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>; renderHeader?: RenderHeaderFn<Item>;
onItemClick: (e: React.MouseEvent | React.KeyboardEvent) => void; onItemClick?: ItemClickFn<Item>;
} }
const DropdownMenu = <Item = MenuItem,>({ export const DropdownMenu = <Item = MenuItem,>({
items, items,
loading, loading,
scrollable, scrollable,
@ -176,20 +178,35 @@ const DropdownMenu = <Item = MenuItem,>({
[], [],
); );
const handleItemClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const item = items?.[i];
onClose();
if (!item) {
return;
}
if (typeof onItemClick === 'function') {
e.preventDefault();
onItemClick(item, i);
} else if (isActionItem(item)) {
e.preventDefault();
item.action();
}
},
[onClose, onItemClick, items],
);
const handleItemKeyUp = useCallback( const handleItemKeyUp = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
onItemClick(e); handleItemClick(e);
} }
}, },
[onItemClick], [handleItemClick],
);
const handleClick = useCallback(
(e: React.MouseEvent | React.KeyboardEvent) => {
onItemClick(e);
},
[onItemClick],
); );
const nativeRenderItem = (option: Item, i: number) => { const nativeRenderItem = (option: Item, i: number) => {
@ -209,7 +226,7 @@ const DropdownMenu = <Item = MenuItem,>({
element = ( element = (
<button <button
ref={i === 0 ? handleFocusedItemRef : undefined} ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp} onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
> >
@ -224,7 +241,7 @@ const DropdownMenu = <Item = MenuItem,>({
data-method={option.method} data-method={option.method}
rel='noopener' rel='noopener'
ref={i === 0 ? handleFocusedItemRef : undefined} ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp} onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
> >
@ -236,7 +253,7 @@ const DropdownMenu = <Item = MenuItem,>({
<Link <Link
to={option.to} to={option.to}
ref={i === 0 ? handleFocusedItemRef : undefined} ref={i === 0 ? handleFocusedItemRef : undefined}
onClick={handleClick} onClick={handleItemClick}
onKeyUp={handleItemKeyUp} onKeyUp={handleItemKeyUp}
data-index={i} data-index={i}
> >
@ -282,7 +299,7 @@ const DropdownMenu = <Item = MenuItem,>({
> >
{items.map((option, i) => {items.map((option, i) =>
renderItemMethod(option, i, { renderItemMethod(option, i, {
onClick: handleClick, onClick: handleItemClick,
onKeyUp: handleItemKeyUp, onKeyUp: handleItemKeyUp,
}), }),
)} )}
@ -306,7 +323,7 @@ interface DropdownProps<Item = MenuItem> {
renderItem?: RenderItemFn<Item>; renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>; renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void; onOpen?: () => void;
onItemClick?: (arg0: Item, arg1: number) => void; onItemClick?: ItemClickFn<Item>;
} }
const offset = [5, 5] as OffsetValue; const offset = [5, 5] as OffsetValue;
@ -521,7 +538,7 @@ export const Dropdown = <Item = MenuItem,>({
openedViaKeyboard={openedViaKeyboard} openedViaKeyboard={openedViaKeyboard}
renderItem={renderItem} renderItem={renderItem}
renderHeader={renderHeader} renderHeader={renderHeader}
onItemClick={handleItemClick} onItemClick={onItemClick}
/> />
</div> </div>
</div> </div>

View File

@ -36,11 +36,11 @@ export const EditedTimestamp: React.FC<{
}, [dispatch, statusId]); }, [dispatch, statusId]);
const handleItemClick = useCallback( const handleItemClick = useCallback(
(_item: HistoryItem, i: number) => { (_item: HistoryItem, index: number) => {
dispatch( dispatch(
openModal({ openModal({
modalType: 'COMPARE_HISTORY', modalType: 'COMPARE_HISTORY',
modalProps: { index: i, statusId }, modalProps: { index, statusId },
}), }),
); );
}, },

View File

@ -20,6 +20,7 @@ export type StatusLike = Record<{
contentHTML: string; contentHTML: string;
media_attachments: List<unknown>; media_attachments: List<unknown>;
spoiler_text?: string; spoiler_text?: string;
account: Record<{ id: string }>;
}>; }>;
function normalizeHashtag(hashtag: string) { function normalizeHashtag(hashtag: string) {
@ -195,13 +196,19 @@ export function getHashtagBarForStatus(status: StatusLike) {
return { return {
statusContentProps, statusContentProps,
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />, hashtagBar: (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.getIn(['account', 'id']) as string}
/>
),
}; };
} }
const HashtagBar: React.FC<{ const HashtagBar: React.FC<{
hashtags: string[]; hashtags: string[];
}> = ({ hashtags }) => { accountId: string;
}> = ({ hashtags, accountId }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setExpanded(true); setExpanded(true);
@ -218,7 +225,11 @@ const HashtagBar: React.FC<{
return ( return (
<div className='hashtag-bar'> <div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => ( {revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}> <Link
key={hashtag}
to={`/tags/${hashtag}`}
data-menu-hashtag={accountId}
>
#<span>{hashtag}</span> #<span>{hashtag}</span>
</Link> </Link>
))} ))}

View File

@ -115,6 +115,7 @@ class StatusContent extends PureComponent {
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`); link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
} else { } else {
link.setAttribute('title', link.href); link.setAttribute('title', link.href);
link.classList.add('unhandled-link'); link.classList.add('unhandled-link');

View File

@ -0,0 +1,157 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { useLocation } from 'react-router-dom';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { DropdownMenu } from 'mastodon/components/dropdown_menu';
import { useAppSelector } from 'mastodon/store';
const messages = defineMessages({
browseHashtag: {
id: 'hashtag.browse',
defaultMessage: 'Browse posts in #{hashtag}',
},
browseHashtagFromAccount: {
id: 'hashtag.browse_from_account',
defaultMessage: 'Browse posts from @{name} in #{hashtag}',
},
muteHashtag: { id: 'hashtag.mute', defaultMessage: 'Mute #{hashtag}' },
});
const offset = [5, 5] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHashtagLink = (
element: HTMLAnchorElement | null,
): element is HTMLAnchorElement => {
if (!element) {
return false;
}
return element.matches('[data-menu-hashtag]');
};
interface TargetParams {
hashtag?: string;
accountId?: string;
}
export const HashtagMenuController: React.FC = () => {
const intl = useIntl();
const [open, setOpen] = useState(false);
const [{ accountId, hashtag }, setTargetParams] = useState<TargetParams>({});
const targetRef = useRef<HTMLAnchorElement | null>(null);
const location = useLocation();
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
useEffect(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen, location]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('a');
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
if (!isHashtagLink(target)) {
return;
}
const hashtag = target.text.replace(/^#/, '');
const accountId = target.getAttribute('data-menu-hashtag');
if (!hashtag || !accountId) {
return;
}
e.preventDefault();
e.stopPropagation();
targetRef.current = target;
setOpen(true);
setTargetParams({ hashtag, accountId });
};
document.addEventListener('click', handleClick, { capture: true });
return () => {
document.removeEventListener('click', handleClick);
};
}, [setTargetParams, setOpen]);
const handleClose = useCallback(() => {
setOpen(false);
targetRef.current = null;
}, [setOpen]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.browseHashtag, {
hashtag,
}),
to: `/tags/${hashtag}`,
},
{
text: intl.formatMessage(messages.browseHashtagFromAccount, {
hashtag,
name: account?.username,
}),
to: `/@${account?.acct}/tagged/${hashtag}`,
},
null,
{
text: intl.formatMessage(messages.muteHashtag, {
hashtag,
}),
href: '/filters',
dangerous: true,
},
],
[intl, hashtag, account],
);
if (!open) {
return null;
}
return (
<Overlay
show={open}
offset={offset}
placement='bottom'
flip
target={targetRef}
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}
{...arrowProps}
/>
<DropdownMenu
items={menu}
onClose={handleClose}
openedViaKeyboard={false}
/>
</div>
</div>
)}
</Overlay>
);
};

View File

@ -31,6 +31,7 @@ import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding
import BundleColumnError from './components/bundle_column_error'; import BundleColumnError from './components/bundle_column_error';
import Header from './components/header'; import Header from './components/header';
import { UploadArea } from './components/upload_area'; import { UploadArea } from './components/upload_area';
import { HashtagMenuController } from './components/hashtag_menu_controller';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
import LoadingBarContainer from './containers/loading_bar_container'; import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container'; import ModalContainer from './containers/modal_container';
@ -611,6 +612,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />} {layout !== 'mobile' && <PictureInPicture />}
<AlertsController /> <AlertsController />
{!disableHoverCards && <HoverCardController />} {!disableHoverCards && <HoverCardController />}
<HashtagMenuController />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} /> <UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View File

@ -381,6 +381,8 @@
"generic.saved": "Saved", "generic.saved": "Saved",
"getting_started.heading": "Getting started", "getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}", "hashtag.admin_moderation": "Open moderation interface for #{name}",
"hashtag.browse": "Browse posts in #{hashtag}",
"hashtag.browse_from_account": "Browse posts from @{name} in #{hashtag}",
"hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}", "hashtag.column_header.tag_mode.none": "without {additional}",
@ -394,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}", "hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} posts}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today", "hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
"hashtag.follow": "Follow hashtag", "hashtag.follow": "Follow hashtag",
"hashtag.mute": "Mute #{hashtag}",
"hashtag.unfollow": "Unfollow hashtag", "hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}", "hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.", "hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",