import type { RefObject } from 'preact'; import { useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { LongPressEventType, useLongPress } from 'use-long-press'; import { supportsNativeQuote } from '../utils/quote-utils'; import showCompose from '../utils/show-compose'; import showToast from '../utils/show-toast'; import { isIOS } from './status-helpers'; import type { AnyStatus } from './status-types'; export type ContextMenuHandle = { closeMenu?: () => void }; export type ContextMenuPropsShape = { anchorRef?: { current: Element | null }; anchorPoint?: { x: number; y: number }; align?: 'start' | 'center' | 'end'; direction?: 'left' | 'right' | 'top' | 'bottom'; gap?: number; shift?: number; }; interface StatusContextMenuArgs { allowContextMenu?: boolean; isSizeLarge: boolean; previewMode?: boolean; readOnly?: boolean; deleted?: boolean; quoted?: boolean | number; statusRef: RefObject; replyStatus: (e?: KeyboardEvent, replyMode?: string) => void; favouriteStatusNotify: () => Promise; bookmarkStatusNotify: () => Promise; confirmBoostStatus: () => Promise; canBoost?: boolean; reblogged?: boolean | null; username?: string; acct?: string; sameInstance: boolean; authenticated?: boolean; unauthInteractionErrorMessage: string; quoteDisabled?: boolean | null; quoteMetaText?: string | null; status: AnyStatus; url?: string | null; boostToast: (reblogged?: boolean | null, username?: string, acct?: string) => string; } export default function useStatusContextMenu({ allowContextMenu, isSizeLarge, previewMode, readOnly, deleted, quoted, statusRef, replyStatus, favouriteStatusNotify, bookmarkStatusNotify, confirmBoostStatus, canBoost, reblogged, username, acct, sameInstance, authenticated, unauthInteractionErrorMessage, quoteDisabled, quoteMetaText, status, url, boostToast, }: StatusContextMenuArgs) { const contextMenuRef = useRef(null); const [isContextMenuOpen, setIsContextMenuOpen] = useState( false, ); const [contextMenuProps, setContextMenuProps] = useState({}); const showContextMenu = allowContextMenu || (!isSizeLarge && !previewMode && !deleted && !quoted); const bindLongPressContext = useLongPress( isIOS && showContextMenu ? (e: PointerEvent | (TouchEvent & { pointerType?: string })) => { if ((e as PointerEvent).pointerType === 'mouse') return; const { clientX, clientY } = (e as TouchEvent).touches?.[0] || (e as PointerEvent); const link = (e.target as Element).closest('a'); if ( link && statusRef.current!.contains(link) && !link.getAttribute('href')!.startsWith('#') ) return; e.preventDefault(); setContextMenuProps({ anchorPoint: { x: clientX, y: clientY, }, direction: 'right', }); setIsContextMenuOpen(true); } : null, { threshold: 600, captureEvent: true, detect: LongPressEventType.Touch, cancelOnMovement: 2, }, ); const hotkeysEnabled = !readOnly && !previewMode && !quoted; const rRef = useHotkeys( 'r, shift+r', (e, handler) => { if (e.shiftKey !== handler.shift) return; replyStatus(e); }, { enabled: hotkeysEnabled, useKey: true, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.key.toLowerCase() !== 'r', }, ); const fRef = useHotkeys( 'f, l', () => { void favouriteStatusNotify(); }, { enabled: hotkeysEnabled, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || !['f', 'l'].includes(e.key.toLowerCase()), useKey: true, }, ); const dRef = useHotkeys( 'd', () => { void bookmarkStatusNotify(); }, { enabled: hotkeysEnabled, useKey: true, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.key.toLowerCase() !== 'd', }, ); const bRef = useHotkeys( 'shift+b', (evt) => { if (!evt.shiftKey) return; void (async () => { try { const done = await confirmBoostStatus(); if (!isSizeLarge && done) { showToast(boostToast(reblogged, username, acct)); } } catch (e) { console.error(e); } })(); }, { enabled: hotkeysEnabled && canBoost, useKey: true, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.key.toLowerCase() !== 'b', }, ); const xRef = useHotkeys( 'x', (e: KeyboardEvent) => { const activeStatus = document.activeElement!.closest( '.status-link, .status-focus', ); if (!activeStatus) return; const spoilerButton = activeStatus.querySelector( '.spoiler-button:not(.spoiling)', ); if (spoilerButton) { e.stopPropagation(); spoilerButton.click(); return; } const spoilerMediaButton = activeStatus.querySelector( '.spoiler-media-button:not(.spoiling)', ); if (spoilerMediaButton) { e.stopPropagation(); spoilerMediaButton.click(); } }, { useKey: true, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.key.toLowerCase() !== 'x', }, ); const qRef = useHotkeys( 'q', () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); return; } if (supportsNativeQuote()) { if (quoteDisabled) { showToast(quoteMetaText as string); } else { showCompose({ quoteStatus: status, } as Parameters[0]); } return; } showCompose({ draftStatus: { status: `\n${url}`, }, } as Parameters[0]); }, { enabled: hotkeysEnabled, useKey: true, ignoreEventWhen: (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.key.toLowerCase() !== 'q', }, ); const bindHotkeyRefs = (nodeRef: Element | null) => { (rRef as { current: Element | null }).current = nodeRef; (fRef as { current: Element | null }).current = nodeRef; (dRef as { current: Element | null }).current = nodeRef; (bRef as { current: Element | null }).current = nodeRef; (xRef as { current: Element | null }).current = nodeRef; (qRef as { current: Element | null }).current = nodeRef; }; return { contextMenuRef, isContextMenuOpen, setIsContextMenuOpen, contextMenuProps, setContextMenuProps, showContextMenu, bindLongPressContext, bindHotkeyRefs, }; }