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

@ -16,7 +16,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
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 { Button } from "@/components/ui/button";
import {
@ -85,6 +85,24 @@ export function AllPrivateChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false);
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();
useEffect(() => {
@ -354,61 +372,72 @@ export function AllPrivateChatsSidebar({
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
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>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
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)}
{isMobile ? (
<button
type="button"
onClick={() => {
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
handleThreadClick(thread.id);
}}
onTouchStart={() => handleLongPressStart(thread.id)}
onTouchEnd={handleLongPressCancel}
onTouchMove={handleLongPressCancel}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
{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>
<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>
<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">
{!thread.archived && (
<DropdownMenuItem

View file

@ -16,7 +16,7 @@ import {
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
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 { Button } from "@/components/ui/button";
import {
@ -85,6 +85,24 @@ export function AllSharedChatsSidebar({
const [isRenaming, setIsRenaming] = useState(false);
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();
useEffect(() => {
@ -354,61 +372,72 @@ export function AllSharedChatsSidebar({
isBusy && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
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>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
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)}
{isMobile ? (
<button
type="button"
onClick={() => {
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
handleThreadClick(thread.id);
}}
onTouchStart={() => handleLongPressStart(thread.id)}
onTouchEnd={handleLongPressCancel}
onTouchMove={handleLongPressCancel}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 shrink-0",
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
"transition-opacity"
)}
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{thread.title || "New Chat"}</span>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleThreadClick(thread.id)}
disabled={isBusy}
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
>
{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>
<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>
<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">
{!thread.archived && (
<DropdownMenuItem

View file

@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import {
ArchiveIcon,
MessageSquare,
@ -17,6 +18,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useIsMobile } from "@/hooks/use-mobile";
import { useLongPress } from "@/hooks/use-long-press";
import { cn } from "@/lib/utils";
interface ChatListItemProps {
@ -39,12 +42,24 @@ export function ChatListItem({
onDelete,
}: ChatListItemProps) {
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 (
<div className="group/item relative w-full">
<button
type="button"
onClick={onClick}
onClick={handleClick}
{...(isMobile ? longPressHandlers : {})}
className={cn(
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
"[&>span:last-child]:truncate",
@ -57,9 +72,14 @@ export function ChatListItem({
<span className="w-[calc(100%-3rem)] ">{name}</span>
</button>
{/* Actions dropdown */}
<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">
<DropdownMenu>
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
<div className={cn(
"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>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />