mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-09 05:06:14 +00:00
Add dropdown menu to hashtag links in web UI (#34393)
This commit is contained in:
parent
a296facdea
commit
a9cfaa6eed
|
@ -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>
|
||||||
|
|
|
@ -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 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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} />
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
Loading…
Reference in New Issue
Block a user