1
0
Fork 0
mirror of https://github.com/mastodon/mastodon.git synced 2025-04-17 22:32:17 +02:00

Add dropdown menu to hashtag links in web UI ()

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
app/javascript/mastodon

View file

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

View file

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

View file

@ -20,6 +20,7 @@ export type StatusLike = Record<{
contentHTML: string;
media_attachments: List<unknown>;
spoiler_text?: string;
account: Record<{ id: string }>;
}>;
function normalizeHashtag(hashtag: string) {
@ -195,13 +196,19 @@ export function getHashtagBarForStatus(status: StatusLike) {
return {
statusContentProps,
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
hashtagBar: (
<HashtagBar
hashtags={hashtagsInBar}
accountId={status.getIn(['account', 'id']) as string}
/>
),
};
}
const HashtagBar: React.FC<{
hashtags: string[];
}> = ({ hashtags }) => {
accountId: string;
}> = ({ hashtags, accountId }) => {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
setExpanded(true);
@ -218,7 +225,11 @@ const HashtagBar: React.FC<{
return (
<div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}>
<Link
key={hashtag}
to={`/tags/${hashtag}`}
data-menu-hashtag={accountId}
>
#<span>{hashtag}</span>
</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] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
link.setAttribute('data-menu-hashtag', this.props.status.getIn(['account', 'id']));
} else {
link.setAttribute('title', link.href);
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 Header from './components/header';
import { UploadArea } from './components/upload_area';
import { HashtagMenuController } from './components/hashtag_menu_controller';
import ColumnsAreaContainer from './containers/columns_area_container';
import LoadingBarContainer from './containers/loading_bar_container';
import ModalContainer from './containers/modal_container';
@ -611,6 +612,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />}
<AlertsController />
{!disableHoverCards && <HoverCardController />}
<HashtagMenuController />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View file

@ -381,6 +381,8 @@
"generic.saved": "Saved",
"getting_started.heading": "Getting started",
"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.any": "or {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_today": "{count, plural, one {{counter} post} other {{counter} posts}} today",
"hashtag.follow": "Follow hashtag",
"hashtag.mute": "Mute #{hashtag}",
"hashtag.unfollow": "Unfollow hashtag",
"hashtags.and_other": "…and {count, plural, other {# more}}",
"hints.profiles.followers_may_be_missing": "Followers for this profile may be missing.",