feat: implement long-press functionality across chat and document components for enhanced user interaction and integrate custom hook for reusability

This commit is contained in:
Anish Sarkar 2026-03-07 03:50:45 +05:30
parent 3af9962abc
commit b07f8699f6
5 changed files with 233 additions and 129 deletions

View file

@ -58,6 +58,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLongPress } from "@/hooks/use-long-press";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
import type { Document, DocumentStatus } from "./types"; import type { Document, DocumentStatus } from "./types";
@ -285,31 +286,17 @@ function MobileCardWrapper({
onLongPress: () => void; onLongPress: () => void;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const { handlers, wasLongPress } = useLongPress(onLongPress);
const didLongPressRef = useRef(false);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
return ( return (
// biome-ignore lint/a11y/useSemanticElements: touch-only long-press wrapper for mobile // biome-ignore lint/a11y/useSemanticElements: touch-only long-press wrapper for mobile
<div <div
role="group" role="group"
onTouchStart={() => { onTouchStart={handlers.onTouchStart}
didLongPressRef.current = false; onTouchMove={handlers.onTouchMove}
timerRef.current = setTimeout(() => {
didLongPressRef.current = true;
onLongPress();
}, 500);
}}
onTouchMove={clearTimer}
onTouchEnd={(e) => { onTouchEnd={(e) => {
clearTimer(); handlers.onTouchEnd();
if (didLongPressRef.current) { if (wasLongPress()) {
e.preventDefault(); e.preventDefault();
} }
}} }}

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -85,6 +85,24 @@ export function AllPrivateChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = useRef(false);
const handleLongPressStart = useCallback((threadId: number) => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
setOpenDropdownId(threadId);
}, 500);
}, []);
const handleLongPressCancel = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -354,61 +372,72 @@ export function AllPrivateChatsSidebar({
isBusy && "opacity-50 pointer-events-none" isBusy && "opacity-50 pointer-events-none"
)} )}
> >
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
disabled={isBusy} if (longPressTriggeredRef.current) {
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" longPressTriggeredRef.current = false;
> return;
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" /> }
<span className="truncate">{thread.title || "New Chat"}</span> handleThreadClick(thread.id);
</button> }}
) : ( onTouchStart={() => handleLongPressStart(thread.id)}
<Tooltip> onTouchEnd={handleLongPressCancel}
<TooltipTrigger asChild> onTouchMove={handleLongPressCancel}
<button disabled={isBusy}
type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
> >
<DropdownMenuTrigger asChild> <MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<Button <span className="truncate">{thread.title || "New Chat"}</span>
variant="ghost" </button>
size="icon" ) : (
className={cn( <Tooltip>
"h-6 w-6 shrink-0", <TooltipTrigger asChild>
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", <button
"transition-opacity" type="button"
)} onClick={() => handleThreadClick(thread.id)}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
{isDeleting ? ( <MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<Spinner size="xs" /> <span className="truncate">{thread.title || "New Chat"}</span>
) : ( </button>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> </TooltipTrigger>
)} <TooltipContent side="bottom" align="start">
<span className="sr-only">{t("more_options") || "More options"}</span> <p>
</Button> {t("updated") || "Updated"}:{" "}
</DropdownMenuTrigger> {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80"> <DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && ( {!thread.archived && (
<DropdownMenuItem <DropdownMenuItem

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -85,6 +85,24 @@ export function AllSharedChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = useRef(false);
const handleLongPressStart = useCallback((threadId: number) => {
longPressTriggeredRef.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
setOpenDropdownId(threadId);
}, 500);
}, []);
const handleLongPressCancel = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
const isSearchMode = !!debouncedSearchQuery.trim(); const isSearchMode = !!debouncedSearchQuery.trim();
useEffect(() => { useEffect(() => {
@ -354,61 +372,72 @@ export function AllSharedChatsSidebar({
isBusy && "opacity-50 pointer-events-none" isBusy && "opacity-50 pointer-events-none"
)} )}
> >
{isMobile ? ( {isMobile ? (
<button <button
type="button" type="button"
onClick={() => handleThreadClick(thread.id)} onClick={() => {
disabled={isBusy} if (longPressTriggeredRef.current) {
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden" longPressTriggeredRef.current = false;
> return;
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" /> }
<span className="truncate">{thread.title || "New Chat"}</span> handleThreadClick(thread.id);
</button> }}
) : ( onTouchStart={() => handleLongPressStart(thread.id)}
<Tooltip> onTouchEnd={handleLongPressCancel}
<TooltipTrigger asChild> onTouchMove={handleLongPressCancel}
<button disabled={isBusy}
type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}:{" "}
{format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
> >
<DropdownMenuTrigger asChild> <MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<Button <span className="truncate">{thread.title || "New Chat"}</span>
variant="ghost" </button>
size="icon" ) : (
className={cn( <Tooltip>
"h-6 w-6 shrink-0", <TooltipTrigger asChild>
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100", <button
"transition-opacity" type="button"
)} onClick={() => handleThreadClick(thread.id)}
disabled={isBusy} disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
> >
{isDeleting ? ( <MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<Spinner size="xs" /> <span className="truncate">{thread.title || "New Chat"}</span>
) : ( </button>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> </TooltipTrigger>
)} <TooltipContent side="bottom" align="start">
<span className="sr-only">{t("more_options") || "More options"}</span> <p>
</Button> {t("updated") || "Updated"}:{" "}
</DropdownMenuTrigger> {format(new Date(thread.updatedAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
isMobile
? "opacity-0 pointer-events-none absolute"
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
disabled={isBusy}
>
{isDeleting ? (
<Spinner size="xs" />
) : (
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80"> <DropdownMenuContent align="end" className="w-40 z-80">
{!thread.archived && ( {!thread.archived && (
<DropdownMenuItem <DropdownMenuItem

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useCallback, useState } from "react";
import { import {
ArchiveIcon, ArchiveIcon,
MessageSquare, MessageSquare,
@ -17,6 +18,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useIsMobile } from "@/hooks/use-mobile";
import { useLongPress } from "@/hooks/use-long-press";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ChatListItemProps { interface ChatListItemProps {
@ -39,12 +42,24 @@ export function ChatListItem({
onDelete, onDelete,
}: ChatListItemProps) { }: ChatListItemProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const isMobile = useIsMobile();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
useCallback(() => setDropdownOpen(true), [])
);
const handleClick = useCallback(() => {
if (wasLongPress()) return;
onClick?.();
}, [onClick, wasLongPress]);
return ( return (
<div className="group/item relative w-full"> <div className="group/item relative w-full">
<button <button
type="button" type="button"
onClick={onClick} onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
className={cn( className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors", "flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate", "[&>span:last-child]:truncate",
@ -57,9 +72,14 @@ export function ChatListItem({
<span className="w-[calc(100%-3rem)] ">{name}</span> <span className="w-[calc(100%-3rem)] ">{name}</span>
</button> </button>
{/* Actions dropdown */} {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity"> <div className={cn(
<DropdownMenu> "absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
isMobile
? "opacity-0 pointer-events-none"
: "opacity-0 group-hover/item:opacity-100"
)}>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6"> <Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" /> <MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />

View file

@ -0,0 +1,39 @@
import { useCallback, useRef } from "react";
const LONG_PRESS_DELAY = 500;
export function useLongPress(onLongPress: () => void, delay = LONG_PRESS_DELAY) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggeredRef = useRef(false);
const start = useCallback(() => {
triggeredRef.current = false;
timerRef.current = setTimeout(() => {
triggeredRef.current = true;
onLongPress();
}, delay);
}, [onLongPress, delay]);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const handlers = {
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: cancel,
};
const wasLongPress = useCallback(() => {
if (triggeredRef.current) {
triggeredRef.current = false;
return true;
}
return false;
}, []);
return { handlers, wasLongPress };
}