feat: mobile long-press context menu, settings logout button

Mobile:
- Add useLongPress hook (500ms, movement cancellation, press feedback)
- Disable drag-and-drop on mobile in useEmailDrag
- Wire long-press context menu to all email list item components
- Add select-none and visual press feedback (scale + ring)

Settings:
- Add logout button to settings page sidebar
This commit is contained in:
Linus Rath
2026-03-14 19:16:49 +01:00
parent d1478fa46b
commit a9a8710909
7 changed files with 171 additions and 15 deletions
+21 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -10,6 +11,8 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { getEmailColorTag } from "@/lib/thread-utils";
@@ -44,6 +47,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
@@ -56,8 +72,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
@@ -71,7 +88,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
+20 -2
View File
@@ -1,13 +1,16 @@
"use client";
import { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
interface ThreadEmailItemProps {
email: Email;
@@ -36,6 +39,19 @@ export function ThreadEmailItem({
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
@@ -61,8 +77,9 @@ export function ThreadEmailItem({
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative cursor-pointer transition-all duration-150",
"relative cursor-pointer select-none transition-all duration-150",
"pl-12 pr-4",
"border-l-2 border-l-transparent",
selected
@@ -71,7 +88,8 @@ export function ThreadEmailItem({
isUnread && !selected && "bg-amber-50 dark:bg-amber-900/20",
!isLast && "border-b border-border/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
+34 -5
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useCallback } from "react";
import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
@@ -11,6 +11,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
import { useTranslations } from "next-intl";
@@ -58,6 +59,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
sourceMailboxId: selectedMailbox,
});
const isMobile = useUIStore((state) => state.isMobile);
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
@@ -84,8 +98,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<div
ref={ref}
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
resolvedColorTag ? resolvedColorTag : (
selected
? "bg-accent"
@@ -96,7 +111,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
@@ -231,6 +247,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
threadEmails: thread.emails,
});
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
latestEmail
);
}, [onContextMenu, latestEmail]),
isMobile
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
@@ -310,8 +337,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<div ref={ref} className={cn("border-b border-border", isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30")}>
<div
{...dragHandlers}
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200",
"relative group cursor-pointer select-none transition-all duration-200",
colorTag ? colorTag : (
isSelected
? "bg-accent"
@@ -322,7 +350,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40"
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleHeaderClick}
onContextMenu={handleContextMenu}