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
+9 -5
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,11 +90,13 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
return {
dragHandlers: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
dragHandlers: isMobile
? { draggable: false, onDragStart: () => {}, onDragEnd: () => {} }
: {
draggable: true,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
isDragging: isThisEmailDragging,
};
}
+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 };
}