Skip to content

Commit 2576bb3

Browse files
committed
Enable long-press action for touch devices to reveal profile card actions; refine pointer and context menu event handling for improved usability.
1 parent 0109dc9 commit 2576bb3

1 file changed

Lines changed: 85 additions & 3 deletions

File tree

web/components/profile-grid.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
Wine,
2121
} from 'lucide-react'
2222
import Link from 'next/link'
23-
import React, {useMemo, useRef, useState} from 'react'
23+
import React, {useEffect, useMemo, useRef, useState} from 'react'
2424
import {PiMagnifyingGlassBold} from 'react-icons/pi'
2525
import {LocationFilterProps} from 'web/components/filters/location-filter'
2626
import GenderIcon from 'web/components/gender-icon'
@@ -83,6 +83,9 @@ type CardSizeConfig = {
8383

8484
const 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+
8689
const 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

Comments
 (0)