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
+13 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, ChevronRight, Settings as SettingsIcon } from 'lucide-react';
import { ArrowLeft, ChevronRight, LogOut, Settings as SettingsIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
@@ -29,6 +29,7 @@ type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vac
export default function SettingsPage() {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout } = useAuthStore();
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
@@ -157,6 +158,17 @@ export default function SettingsPage() {
</button>
))}
</div>
{/* Logout */}
<div className="border-t border-border px-5 py-3">
<button
onClick={() => { logout(); router.push('/login'); }}
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
>
<LogOut className="w-4 h-4" />
<span>{tSidebar('sign_out')}</span>
</button>
</div>
</div>
{/* Bottom Navigation */}
+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}
+5 -1
View File
@@ -4,6 +4,7 @@ import { useCallback, DragEvent } from "react";
import { Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
interface UseEmailDragOptions {
email: Email;
@@ -46,6 +47,7 @@ function createDragPreview(count: number): HTMLElement {
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
const { selectedEmailIds, emails } = useEmailStore();
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
const isMobile = useUIStore((state) => state.isMobile);
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
// Determine which emails to drag:
@@ -88,7 +90,9 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
return {
dragHandlers: {
dragHandlers: isMobile
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useCallback, useRef, useState } from "react";
const LONG_PRESS_DURATION = 500;
const MOVE_THRESHOLD = 10;
export function useLongPress(
onLongPress: (position: { clientX: number; clientY: number }) => void,
enabled: boolean = true
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const firedRef = useRef(false);
const [isPressed, setIsPressed] = useState(false);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
startPosRef.current = null;
setIsPressed(false);
}, []);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
if (!enabled) return;
const touch = e.touches[0];
startPosRef.current = { x: touch.clientX, y: touch.clientY };
firedRef.current = false;
setIsPressed(true);
timerRef.current = setTimeout(() => {
firedRef.current = true;
setIsPressed(false);
onLongPress({ clientX: touch.clientX, clientY: touch.clientY });
}, LONG_PRESS_DURATION);
},
[enabled, onLongPress]
);
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!startPosRef.current || !timerRef.current) return;
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - startPosRef.current.x);
const dy = Math.abs(touch.clientY - startPosRef.current.y);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
cancel();
}
},
[cancel]
);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (firedRef.current) {
e.preventDefault();
}
cancel();
firedRef.current = false;
},
[cancel]
);
const onTouchCancel = useCallback(() => {
cancel();
firedRef.current = false;
}, [cancel]);
return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed };
}
+1
View File
@@ -3,6 +3,7 @@ import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
output: "standalone",
allowedDevOrigins: ["192.168.1.51"],
turbopack: {
root: import.meta.dirname,
},