feat: implement long-press dropdown menu for mobile avatars and streamline tooltip usage in sidebar components

This commit is contained in:
Anish Sarkar 2026-02-06 19:18:40 +05:30
parent 6857c1d7e8
commit f7278f75f4
3 changed files with 172 additions and 40 deletions

View file

@ -1,6 +1,7 @@
"use client";
import { Settings, Trash2, Users } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
ContextMenu,
@ -9,6 +10,13 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@ -73,6 +81,35 @@ export function SearchSpaceAvatar({
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
// Long-press state for mobile
const [longPressMenuOpen, setLongPressMenuOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touchMoved = useRef(false);
const handleTouchStart = useCallback(() => {
touchMoved.current = false;
longPressTimer.current = setTimeout(() => {
if (!touchMoved.current) {
setLongPressMenuOpen(true);
}
}, 500);
}, []);
const handleTouchMove = useCallback(() => {
touchMoved.current = true;
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}, []);
const tooltipContent = (
<div className="flex flex-col">
<span>{name}</span>
@ -112,26 +149,67 @@ export function SearchSpaceAvatar({
</button>
);
const menuItems = (
<>
{onSettings && (
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
)}
{onSettings && onDelete && <DropdownMenuSeparator />}
{onDelete && isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
)}
{onDelete && !isOwner && (
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" />
{t("leave")}
</DropdownMenuItem>
)}
</>
);
// If delete or settings handlers are provided, wrap with context menu
if (onDelete || onSettings) {
// Mobile: use long-press triggered DropdownMenu
if (disableTooltip) {
return (
<DropdownMenu open={longPressMenuOpen} onOpenChange={setLongPressMenuOpen}>
<DropdownMenuTrigger asChild>
<div
className="inline-block"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{avatarButton}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
);
}
// Desktop: use right-click ContextMenu + Tooltip
return (
<ContextMenu>
{disableTooltip ? (
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltipContent}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<div className="inline-block">{avatarButton}</div>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltipContent}
</TooltipContent>
</Tooltip>
<ContextMenuContent className="w-48">
{onSettings && (
<ContextMenuItem onClick={onSettings}>

View file

@ -594,20 +594,15 @@ export function InboxSidebar({
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
@ -811,6 +806,18 @@ export function InboxSidebar({
</DropdownMenuContent>
</DropdownMenu>
)}
{isMobile ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -828,6 +835,7 @@ export function InboxSidebar({
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
)}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
<Tooltip>
@ -976,6 +984,29 @@ export function InboxSidebar({
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
{isMobile ? (
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -1007,6 +1038,7 @@ export function InboxSidebar({
</p>
</TooltipContent>
</Tooltip>
)}
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">

View file

@ -149,8 +149,18 @@ export function Sidebar({
defaultOpen={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
action={
onViewAllSharedChats ? (
action={
onViewAllSharedChats ? (
disableTooltips ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllSharedChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -166,10 +176,11 @@ export function Sidebar({
{t("view_all_shared_chats") || "View all shared chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
>
{isLoadingChats ? (
)
) : undefined
}
>
{isLoadingChats ? (
<div className="flex flex-col gap-0.5">
<ChatListItemSkeleton />
<ChatListItemSkeleton />
@ -210,8 +221,18 @@ export function Sidebar({
title={t("chats")}
defaultOpen={true}
fillHeight={true}
action={
onViewAllPrivateChats ? (
action={
onViewAllPrivateChats ? (
disableTooltips ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-transparent hover:text-current focus-visible:ring-0"
onClick={onViewAllPrivateChats}
>
<FolderOpen className="h-4 w-4" />
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -227,8 +248,9 @@ export function Sidebar({
{t("view_all_private_chats") || "View all private chats"}
</TooltipContent>
</Tooltip>
) : undefined
}
)
) : undefined
}
>
{isLoadingChats ? (
<div className="flex flex-col gap-0.5">