mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 00:32:38 +02:00
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:
parent
3af9962abc
commit
b07f8699f6
5 changed files with 233 additions and 129 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
@ -357,7 +375,16 @@ export function AllPrivateChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => handleLongPressStart(thread.id)}
|
||||||
|
onTouchEnd={handleLongPressCancel}
|
||||||
|
onTouchMove={handleLongPressCancel}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +423,9 @@ export function AllPrivateChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
@ -357,7 +375,16 @@ export function AllSharedChatsSidebar({
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThreadClick(thread.id)}
|
onClick={() => {
|
||||||
|
if (longPressTriggeredRef.current) {
|
||||||
|
longPressTriggeredRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleThreadClick(thread.id);
|
||||||
|
}}
|
||||||
|
onTouchStart={() => handleLongPressStart(thread.id)}
|
||||||
|
onTouchEnd={handleLongPressCancel}
|
||||||
|
onTouchMove={handleLongPressCancel}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -396,7 +423,9 @@ export function AllSharedChatsSidebar({
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-6 shrink-0",
|
"h-6 w-6 shrink-0",
|
||||||
"md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none absolute"
|
||||||
|
: "md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100",
|
||||||
"transition-opacity"
|
"transition-opacity"
|
||||||
)}
|
)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
39
surfsense_web/hooks/use-long-press.ts
Normal file
39
surfsense_web/hooks/use-long-press.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue