@@ -20,7 +20,7 @@ import {
2020 Wine ,
2121} from 'lucide-react'
2222import Link from 'next/link'
23- import React , { useMemo , useRef , useState } from 'react'
23+ import React , { useEffect , useMemo , useRef , useState } from 'react'
2424import { PiMagnifyingGlassBold } from 'react-icons/pi'
2525import { LocationFilterProps } from 'web/components/filters/location-filter'
2626import GenderIcon from 'web/components/gender-icon'
@@ -83,6 +83,9 @@ type CardSizeConfig = {
8383
8484const MIN_BIO_LINES = 2
8585
86+ /** How long a touch has to stay down before it reveals the card actions instead of navigating. */
87+ const LONG_PRESS_MS = 400
88+
8689const CARD_SIZE_CONFIG : Record < CardSize , CardSizeConfig > = {
8790 small : {
8891 columns : { minCardWidth : 300 , maxColumns : 4 } ,
@@ -484,6 +487,32 @@ function ProfilePreview(props: {
484487 const ringTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
485488 const pointerStartRef = useRef < { x : number ; y : number } | null > ( null )
486489
490+ // Touch has no hover, so the actions can't hide behind one — a press and hold reveals them.
491+ const cardRef = useRef < HTMLDivElement > ( null )
492+ const [ showActions , setShowActions ] = useState ( false )
493+ const longPressTimeoutRef = useRef < NodeJS . Timeout | null > ( null )
494+ /** Set once the hold fires, so the release that follows opens the actions instead of the profile. */
495+ const longPressedRef = useRef ( false )
496+
497+ const clearLongPress = ( ) => {
498+ if ( longPressTimeoutRef . current ) {
499+ clearTimeout ( longPressTimeoutRef . current )
500+ longPressTimeoutRef . current = null
501+ }
502+ }
503+
504+ // Any press outside puts them away again — including a press on another card, which reveals its own.
505+ useEffect ( ( ) => {
506+ if ( ! showActions ) return
507+ const onDocumentPointerDown = ( e : PointerEvent ) => {
508+ if ( ! cardRef . current ?. contains ( e . target as Node ) ) setShowActions ( false )
509+ }
510+ document . addEventListener ( 'pointerdown' , onDocumentPointerDown )
511+ return ( ) => document . removeEventListener ( 'pointerdown' , onDocumentPointerDown )
512+ } , [ showActions ] )
513+
514+ useEffect ( ( ) => clearLongPress , [ ] )
515+
487516 const { theme} = useTheme ( )
488517 const isDarkTheme = isDark ( theme )
489518
@@ -521,9 +550,35 @@ function ProfilePreview(props: {
521550 return
522551 }
523552 pointerStartRef . current = { x : e . clientX , y : e . clientY }
553+
554+ longPressedRef . current = false
555+ // Mouse users get the actions on hover, so holding the button down shouldn't hijack their click.
556+ if ( e . pointerType !== 'mouse' ) {
557+ clearLongPress ( )
558+ longPressTimeoutRef . current = setTimeout ( ( ) => {
559+ longPressTimeoutRef . current = null
560+ longPressedRef . current = true
561+ setShowActions ( true )
562+ } , LONG_PRESS_MS )
563+ }
564+ }
565+
566+ // A press that turns into a scroll is not a hold.
567+ const handlePointerMove = ( e : React . PointerEvent ) => {
568+ const start = pointerStartRef . current
569+ if ( ! start || ! longPressTimeoutRef . current ) return
570+ if ( Math . abs ( e . clientX - start . x ) > 10 || Math . abs ( e . clientY - start . y ) > 10 ) clearLongPress ( )
524571 }
525572
526573 const handlePointerUp = ( e : React . PointerEvent ) => {
574+ clearLongPress ( )
575+
576+ // The hold already did something — don't also navigate or run the press feedback.
577+ if ( longPressedRef . current ) {
578+ pointerStartRef . current = null
579+ return
580+ }
581+
527582 if ( pointerStartRef . current ) {
528583 const dx = Math . abs ( e . clientX - pointerStartRef . current . x )
529584 const dy = Math . abs ( e . clientY - pointerStartRef . current . y )
@@ -550,7 +605,20 @@ function ProfilePreview(props: {
550605 pointerStartRef . current = null
551606 }
552607
553- const handleClick = ( ) => { }
608+ const handleClick = ( e : React . MouseEvent ) => {
609+ // Browsers still fire a click after the hold, which would navigate away from the actions we
610+ // just revealed.
611+ if ( longPressedRef . current ) {
612+ e . preventDefault ( )
613+ longPressedRef . current = false
614+ }
615+ }
616+
617+ // The hold would otherwise also pop the browser's own link menu on top of the actions. Only
618+ // suppressed for a touch hold — right-click ("open in new tab") stays intact.
619+ const handleContextMenu = ( e : React . MouseEvent ) => {
620+ if ( longPressedRef . current ) e . preventDefault ( )
621+ }
554622
555623 // If this profile was just hidden, render a compact placeholder with Undo action.
556624 if ( isHidden ) {
@@ -650,6 +718,7 @@ function ProfilePreview(props: {
650718
651719 return (
652720 < div
721+ ref = { cardRef }
653722 className = { clsx (
654723 'relative overflow-hidden rounded-xl bg-canvas-50' ,
655724 isLoading && 'scale-[0.94] transition-transform duration-[80ms] ease-out' ,
@@ -660,10 +729,15 @@ function ProfilePreview(props: {
660729 < Link
661730 href = { `/${ user . username } ` }
662731 onPointerDown = { handlePointerDown }
732+ onPointerMove = { handlePointerMove }
663733 onPointerUp = { handlePointerUp }
734+ onPointerCancel = { clearLongPress }
664735 onClick = { handleClick }
736+ onContextMenu = { handleContextMenu }
665737 className = { clsx (
666738 'relative overflow-hidden rounded-xl bg-canvas-50 person-card' ,
739+ // Stops the hold from turning into a text selection / iOS link callout.
740+ 'select-none [-webkit-touch-callout:none]' ,
667741 ! showRing && 'border border-canvas-300' ,
668742 'transition-all duration-[120ms] ease-in' ,
669743 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[4px]' ,
@@ -728,10 +802,18 @@ function ProfilePreview(props: {
728802 gap than sits between the actions themselves). With no score the actions take the
729803 corner themselves. */ }
730804 < Row className = "shrink-0 items-center gap-2.5" >
805+ { /* Hidden until asked for: hover on desktop, a press and hold on touch. They stay
806+ clickable while invisible on desktop only — hovering is what uncovers them there,
807+ whereas on touch an invisible button is just a trap in the corner of the card. */ }
731808 < Row
732809 className = { clsx (
733810 'items-center gap-1 transition-opacity' ,
734- 'lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100' ,
811+ showActions
812+ ? 'opacity-100'
813+ : [
814+ 'opacity-0 pointer-events-none lg:pointer-events-auto' ,
815+ 'lg:group-hover:opacity-100 lg:group-focus-within:opacity-100' ,
816+ ] ,
735817 ) }
736818 >
737819 { onHide && (
0 commit comments