diff --git a/web/components/profile-grid.tsx b/web/components/profile-grid.tsx index e55254b0..db83054e 100644 --- a/web/components/profile-grid.tsx +++ b/web/components/profile-grid.tsx @@ -20,7 +20,7 @@ import { Wine, } from 'lucide-react' import Link from 'next/link' -import React, {useMemo, useRef, useState} from 'react' +import React, {useEffect, useMemo, useRef, useState} from 'react' import {PiMagnifyingGlassBold} from 'react-icons/pi' import {LocationFilterProps} from 'web/components/filters/location-filter' import GenderIcon from 'web/components/gender-icon' @@ -83,6 +83,9 @@ type CardSizeConfig = { const MIN_BIO_LINES = 2 +/** How long a touch has to stay down before it reveals the card actions instead of navigating. */ +const LONG_PRESS_MS = 400 + const CARD_SIZE_CONFIG: Record = { small: { columns: {minCardWidth: 300, maxColumns: 4}, @@ -484,6 +487,32 @@ function ProfilePreview(props: { const ringTimeoutRef = useRef(null) const pointerStartRef = useRef<{x: number; y: number} | null>(null) + // Touch has no hover, so the actions can't hide behind one — a press and hold reveals them. + const cardRef = useRef(null) + const [showActions, setShowActions] = useState(false) + const longPressTimeoutRef = useRef(null) + /** Set once the hold fires, so the release that follows opens the actions instead of the profile. */ + const longPressedRef = useRef(false) + + const clearLongPress = () => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current) + longPressTimeoutRef.current = null + } + } + + // Any press outside puts them away again — including a press on another card, which reveals its own. + useEffect(() => { + if (!showActions) return + const onDocumentPointerDown = (e: PointerEvent) => { + if (!cardRef.current?.contains(e.target as Node)) setShowActions(false) + } + document.addEventListener('pointerdown', onDocumentPointerDown) + return () => document.removeEventListener('pointerdown', onDocumentPointerDown) + }, [showActions]) + + useEffect(() => clearLongPress, []) + const {theme} = useTheme() const isDarkTheme = isDark(theme) @@ -521,9 +550,35 @@ function ProfilePreview(props: { return } pointerStartRef.current = {x: e.clientX, y: e.clientY} + + longPressedRef.current = false + // Mouse users get the actions on hover, so holding the button down shouldn't hijack their click. + if (e.pointerType !== 'mouse') { + clearLongPress() + longPressTimeoutRef.current = setTimeout(() => { + longPressTimeoutRef.current = null + longPressedRef.current = true + setShowActions(true) + }, LONG_PRESS_MS) + } + } + + // A press that turns into a scroll is not a hold. + const handlePointerMove = (e: React.PointerEvent) => { + const start = pointerStartRef.current + if (!start || !longPressTimeoutRef.current) return + if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) clearLongPress() } const handlePointerUp = (e: React.PointerEvent) => { + clearLongPress() + + // The hold already did something — don't also navigate or run the press feedback. + if (longPressedRef.current) { + pointerStartRef.current = null + return + } + if (pointerStartRef.current) { const dx = Math.abs(e.clientX - pointerStartRef.current.x) const dy = Math.abs(e.clientY - pointerStartRef.current.y) @@ -550,7 +605,20 @@ function ProfilePreview(props: { pointerStartRef.current = null } - const handleClick = () => {} + const handleClick = (e: React.MouseEvent) => { + // Browsers still fire a click after the hold, which would navigate away from the actions we + // just revealed. + if (longPressedRef.current) { + e.preventDefault() + longPressedRef.current = false + } + } + + // The hold would otherwise also pop the browser's own link menu on top of the actions. Only + // suppressed for a touch hold — right-click ("open in new tab") stays intact. + const handleContextMenu = (e: React.MouseEvent) => { + if (longPressedRef.current) e.preventDefault() + } // If this profile was just hidden, render a compact placeholder with Undo action. if (isHidden) { @@ -650,6 +718,7 @@ function ProfilePreview(props: { return (
+ {/* Hidden until asked for: hover on desktop, a press and hold on touch. They stay + clickable while invisible on desktop only — hovering is what uncovers them there, + whereas on touch an invisible button is just a trap in the corner of the card. */} {onHide && (