Add dropdown menu to hashtag links in web UI (#34393)
This commit is contained in:
		
					parent
					
						
							
								a296facdea
							
						
					
				
			
			
				commit
				
					
						a9cfaa6eed
					
				
			
		
					 7 changed files with 213 additions and 22 deletions
				
			
		|  | @ -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> | ||||
|  |  | |||
|  | @ -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 }, | ||||
|         }), | ||||
|       ); | ||||
|     }, | ||||
|  |  | |||
|  | @ -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> | ||||
|       ))} | ||||
|  |  | |||
|  | @ -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'); | ||||
|  |  | |||
|  | @ -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 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} /> | ||||
|  |  | |||
|  | @ -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.", | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue