mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat(mobile): swipe list rows left/right for quick actions
Adds Gmail-style horizontal swipe actions to message rows on touch devices. Swiping a row past a threshold fires a configurable action and snaps back; a coloured background with an icon is revealed under the finger and grows when the release would commit. - New `useSwipeActions` hook: claims a touch as a swipe only once the horizontal travel dominates (vertical stays native list scrolling via `touch-action: pan-y`, no preventDefault), resists past the commit distance, and suppresses the tap that would otherwise follow. - Two settings (Settings -> reading/email behavior): "Swipe right action" (default Archive) and "Swipe left action" (default Delete), each one of none / archive / delete / toggle read / toggle star / spam. A direction set to an action the current folder does not expose simply does nothing. - Composes with the existing long-press (context menu) handlers; desktop is unchanged (hover actions + right-click). - Scoped to single-message rows; unit tests cover commit/short-drag/ no-action-side/vertical-scroll/tap-suppression.
This commit is contained in:
@@ -5,7 +5,8 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -16,6 +17,8 @@ import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useSwipeActions } from "@/hooks/use-swipe-actions";
|
||||
import type { SwipeAction } from "@/stores/settings-store";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -97,6 +100,16 @@ interface SingleEmailItemProps {
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
// Visual + behaviour metadata for each mobile swipe action. Keyed by the
|
||||
// SwipeAction values that map to a row callback ('none' is handled inline).
|
||||
const SWIPE_ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string }> = {
|
||||
archive: { icon: Archive, bg: 'bg-emerald-600' },
|
||||
delete: { icon: Trash2, bg: 'bg-red-600' },
|
||||
markRead: { icon: MailOpen, bg: 'bg-sky-600' },
|
||||
star: { icon: Star, bg: 'bg-amber-500' },
|
||||
spam: { icon: ShieldAlert, bg: 'bg-orange-600' },
|
||||
};
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
@@ -155,6 +168,50 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
);
|
||||
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
||||
|
||||
// Mobile swipe actions (Gmail-style): drag a row left/right to fire the
|
||||
// configured action. Desktop keeps hover actions + context menu.
|
||||
const swipeRightAction = useSettingsStore((state) => state.swipeRightAction);
|
||||
const swipeLeftAction = useSettingsStore((state) => state.swipeLeftAction);
|
||||
const runSwipeAction = useCallback((action: SwipeAction) => {
|
||||
switch (action) {
|
||||
case 'archive': onArchive?.(); break;
|
||||
case 'delete': onDelete?.(); break;
|
||||
case 'markRead': onMarkAsRead?.(isUnread); break; // unread -> read, read -> unread
|
||||
case 'star': onToggleStar?.(); break;
|
||||
case 'spam': onMarkAsSpam?.(); break;
|
||||
default: break;
|
||||
}
|
||||
}, [onArchive, onDelete, onMarkAsRead, onToggleStar, onMarkAsSpam, isUnread]);
|
||||
const isActionable = (action: SwipeAction): boolean => {
|
||||
switch (action) {
|
||||
case 'archive': return !!onArchive;
|
||||
case 'delete': return !!onDelete;
|
||||
case 'markRead': return !!onMarkAsRead;
|
||||
case 'star': return !!onToggleStar;
|
||||
case 'spam': return !!onMarkAsSpam;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
const hasRight = isActionable(swipeRightAction);
|
||||
const hasLeft = isActionable(swipeLeftAction);
|
||||
const swipeEnabled = isMobile && (hasLeft || hasRight);
|
||||
const { swipeHandlers, offsetX, side: swipeSide, willCommit, consumeTap } = useSwipeActions({
|
||||
enabled: swipeEnabled,
|
||||
hasLeft,
|
||||
hasRight,
|
||||
onCommit: (s) => runSwipeAction(s === 'right' ? swipeRightAction : swipeLeftAction),
|
||||
});
|
||||
const swipeActiveAction = swipeSide === 'right' ? swipeRightAction : swipeSide === 'left' ? swipeLeftAction : 'none';
|
||||
const swipeMeta = swipeActiveAction !== 'none' ? SWIPE_ACTION_META[swipeActiveAction] : null;
|
||||
const touchHandlers = swipeEnabled
|
||||
? {
|
||||
onTouchStart: (e: React.TouchEvent) => { longPressHandlers.onTouchStart(e); swipeHandlers.onTouchStart(e); },
|
||||
onTouchMove: (e: React.TouchEvent) => { longPressHandlers.onTouchMove(e); swipeHandlers.onTouchMove(e); },
|
||||
onTouchEnd: (e: React.TouchEvent) => { longPressHandlers.onTouchEnd(e); swipeHandlers.onTouchEnd(); },
|
||||
onTouchCancel: () => { longPressHandlers.onTouchCancel(); swipeHandlers.onTouchCancel(); },
|
||||
}
|
||||
: longPressHandlers;
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
@@ -169,6 +226,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (swipeEnabled && consumeTap()) { return; }
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
@@ -185,7 +243,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
{...touchHandlers}
|
||||
data-testid="email-list-item"
|
||||
data-email-id={email.id}
|
||||
data-subject={email.subject || ''}
|
||||
@@ -217,11 +275,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
|
||||
>
|
||||
{swipeEnabled && swipeMeta && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn('absolute inset-0 z-0 flex items-center px-5 text-white', swipeMeta.bg, swipeSide === 'right' ? 'justify-start' : 'justify-end')}
|
||||
>
|
||||
<swipeMeta.icon className={cn('h-5 w-5 transition-transform', willCommit ? 'scale-110' : 'scale-90 opacity-80')} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start', swipeEnabled && 'relative z-10 bg-inherit')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
|
||||
@@ -23,6 +23,8 @@ export function ReadingSettings() {
|
||||
deleteAction,
|
||||
permanentlyDeleteJunk,
|
||||
returnToListAfterAction,
|
||||
swipeRightAction,
|
||||
swipeLeftAction,
|
||||
showPreview,
|
||||
mailLayout,
|
||||
disableThreading,
|
||||
@@ -213,6 +215,36 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('swipe_right_action.label')} description={t('swipe_right_action.description')}>
|
||||
<Select
|
||||
value={swipeRightAction}
|
||||
onChange={(value) => updateSetting('swipeRightAction', value as typeof swipeRightAction)}
|
||||
options={[
|
||||
{ value: 'none', label: t('swipe_actions.none') },
|
||||
{ value: 'archive', label: t('swipe_actions.archive') },
|
||||
{ value: 'delete', label: t('swipe_actions.delete') },
|
||||
{ value: 'markRead', label: t('swipe_actions.mark_read') },
|
||||
{ value: 'star', label: t('swipe_actions.star') },
|
||||
{ value: 'spam', label: t('swipe_actions.spam') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('swipe_left_action.label')} description={t('swipe_left_action.description')}>
|
||||
<Select
|
||||
value={swipeLeftAction}
|
||||
onChange={(value) => updateSetting('swipeLeftAction', value as typeof swipeLeftAction)}
|
||||
options={[
|
||||
{ value: 'none', label: t('swipe_actions.none') },
|
||||
{ value: 'archive', label: t('swipe_actions.archive') },
|
||||
{ value: 'delete', label: t('swipe_actions.delete') },
|
||||
{ value: 'markRead', label: t('swipe_actions.mark_read') },
|
||||
{ value: 'star', label: t('swipe_actions.star') },
|
||||
{ value: 'spam', label: t('swipe_actions.spam') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{!isSettingHidden('showPreview') && (
|
||||
<SettingItem
|
||||
label={t('show_preview.label')}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useSwipeActions, SWIPE_COMMIT_DISTANCE } from "@/hooks/use-swipe-actions";
|
||||
|
||||
/** Build a minimal React.TouchEvent-like object with one touch point. */
|
||||
function touch(x: number, y: number) {
|
||||
return { touches: [{ clientX: x, clientY: y }] } as unknown as React.TouchEvent;
|
||||
}
|
||||
|
||||
function drag(result: { current: ReturnType<typeof useSwipeActions> }, from: number, to: number, y = 0) {
|
||||
act(() => result.current.swipeHandlers.onTouchStart(touch(from, y)));
|
||||
act(() => result.current.swipeHandlers.onTouchMove(touch(to, y)));
|
||||
act(() => result.current.swipeHandlers.onTouchEnd());
|
||||
}
|
||||
|
||||
describe("useSwipeActions", () => {
|
||||
it("commits the right action when dragged right past the threshold", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
|
||||
);
|
||||
drag(result, 0, SWIPE_COMMIT_DISTANCE + 20);
|
||||
expect(onCommit).toHaveBeenCalledWith("right");
|
||||
});
|
||||
|
||||
it("commits the left action when dragged left past the threshold", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
|
||||
);
|
||||
drag(result, 200, 200 - (SWIPE_COMMIT_DISTANCE + 20));
|
||||
expect(onCommit).toHaveBeenCalledWith("left");
|
||||
});
|
||||
|
||||
it("does not commit a short drag", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
|
||||
);
|
||||
drag(result, 0, 30);
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("does not commit toward a side with no action", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: false, hasRight: true, onCommit }),
|
||||
);
|
||||
// Big drag to the LEFT, but hasLeft is false.
|
||||
drag(result, 200, 200 - (SWIPE_COMMIT_DISTANCE + 40));
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a mostly-vertical drag (list scroll)", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
|
||||
);
|
||||
act(() => result.current.swipeHandlers.onTouchStart(touch(0, 0)));
|
||||
act(() => result.current.swipeHandlers.onTouchMove(touch(8, 60)));
|
||||
act(() => result.current.swipeHandlers.onTouchEnd());
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(result.current.offsetX).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a tap to consume after a committed swipe", () => {
|
||||
const onCommit = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useSwipeActions({ enabled: true, hasLeft: true, hasRight: true, onCommit }),
|
||||
);
|
||||
drag(result, 0, SWIPE_COMMIT_DISTANCE + 20);
|
||||
expect(result.current.consumeTap()).toBe(true);
|
||||
// Only once.
|
||||
expect(result.current.consumeTap()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type SwipeSide = "left" | "right";
|
||||
|
||||
/** Horizontal travel before a touch is claimed as a swipe rather than a tap/scroll. */
|
||||
const ACTIVATION = 12;
|
||||
/** Release past this offset (px) fires the action. */
|
||||
export const SWIPE_COMMIT_DISTANCE = 96;
|
||||
/** The row stops following the finger past this distance. */
|
||||
const MAX_OFFSET = 140;
|
||||
|
||||
/**
|
||||
* Dampen travel past the commit distance so the row feels elastic instead of
|
||||
* sliding off under the finger.
|
||||
*/
|
||||
function resist(dx: number): number {
|
||||
const sign = Math.sign(dx);
|
||||
const abs = Math.abs(dx);
|
||||
if (abs <= SWIPE_COMMIT_DISTANCE) return dx;
|
||||
return sign * Math.min(MAX_OFFSET, SWIPE_COMMIT_DISTANCE + (abs - SWIPE_COMMIT_DISTANCE) * 0.3);
|
||||
}
|
||||
|
||||
export interface UseSwipeActionsOptions {
|
||||
/** Off on desktop / when neither side has an action. */
|
||||
enabled?: boolean;
|
||||
/** Whether a left-swipe (finger moves left) has an action configured. */
|
||||
hasLeft: boolean;
|
||||
/** Whether a right-swipe (finger moves right) has an action configured. */
|
||||
hasRight: boolean;
|
||||
/** Fired once on release when the row is dragged past the commit distance. */
|
||||
onCommit: (side: SwipeSide) => void;
|
||||
}
|
||||
|
||||
export interface UseSwipeActionsResult {
|
||||
swipeHandlers: {
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
onTouchMove: (e: React.TouchEvent) => void;
|
||||
onTouchEnd: () => void;
|
||||
onTouchCancel: () => void;
|
||||
};
|
||||
/** Current horizontal offset of the row, in px. 0 when idle. */
|
||||
offsetX: number;
|
||||
/** The side currently being revealed, or null when idle. */
|
||||
side: SwipeSide | null;
|
||||
/** True once the finger is past the commit distance (release would fire). */
|
||||
willCommit: boolean;
|
||||
/** True for a beat after a committed swipe so the caller can suppress the tap. */
|
||||
consumeTap: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gmail-style horizontal swipe actions for a list row. Pair with
|
||||
* `touch-action: pan-y` on the swiped element so vertical scrolling stays
|
||||
* native while horizontal drags are handled here (no preventDefault needed).
|
||||
*
|
||||
* A right-swipe (finger moves right, `offsetX > 0`) maps to the caller's
|
||||
* "right" action; a left-swipe maps to "left". A side with no configured
|
||||
* action resists hard so the row barely moves.
|
||||
*/
|
||||
export function useSwipeActions({
|
||||
enabled = true,
|
||||
hasLeft,
|
||||
hasRight,
|
||||
onCommit,
|
||||
}: UseSwipeActionsOptions): UseSwipeActionsResult {
|
||||
const start = useRef<{ x: number; y: number } | null>(null);
|
||||
const claimed = useRef(false);
|
||||
const justSwiped = useRef(false);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
|
||||
const canGo = useCallback(
|
||||
(dx: number) => (dx < 0 ? hasLeft : hasRight),
|
||||
[hasLeft, hasRight],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
start.current = null;
|
||||
claimed.current = false;
|
||||
setOffsetX(0);
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!enabled) return;
|
||||
const t = e.touches[0];
|
||||
start.current = { x: t.clientX, y: t.clientY };
|
||||
claimed.current = false;
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!start.current) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - start.current.x;
|
||||
const dy = t.clientY - start.current.y;
|
||||
|
||||
if (!claimed.current) {
|
||||
if (Math.abs(dx) > ACTIVATION && Math.abs(dx) > Math.abs(dy)) {
|
||||
claimed.current = true;
|
||||
} else if (Math.abs(dy) > ACTIVATION) {
|
||||
// Vertical scroll — hand it back to the list.
|
||||
start.current = null;
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A swipe toward a side with no action resists to a token nudge.
|
||||
setOffsetX(canGo(dx) ? resist(dx) : dx * 0.15);
|
||||
},
|
||||
[canGo],
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (
|
||||
claimed.current &&
|
||||
Math.abs(offsetX) >= SWIPE_COMMIT_DISTANCE &&
|
||||
canGo(offsetX)
|
||||
) {
|
||||
justSwiped.current = true;
|
||||
onCommit(offsetX > 0 ? "right" : "left");
|
||||
} else if (claimed.current) {
|
||||
// A claimed-but-not-committed drag still swallows the tap that follows.
|
||||
justSwiped.current = true;
|
||||
}
|
||||
reset();
|
||||
}, [offsetX, canGo, onCommit, reset]);
|
||||
|
||||
const consumeTap = useCallback(() => {
|
||||
if (justSwiped.current) {
|
||||
justSwiped.current = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
swipeHandlers: {
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
onTouchCancel: reset,
|
||||
},
|
||||
offsetX,
|
||||
side: offsetX === 0 ? null : offsetX > 0 ? "right" : "left",
|
||||
willCommit: Math.abs(offsetX) >= SWIPE_COMMIT_DISTANCE && canGo(offsetX),
|
||||
consumeTap,
|
||||
};
|
||||
}
|
||||
@@ -1389,6 +1389,22 @@
|
||||
"label": "Return to list after delete or mark unread",
|
||||
"description": "After deleting or marking the open message unread, go back to the message list instead of opening the next message."
|
||||
},
|
||||
"swipe_right_action": {
|
||||
"label": "Swipe right action (mobile)",
|
||||
"description": "On touch devices, the action performed when you swipe a message to the right in the list."
|
||||
},
|
||||
"swipe_left_action": {
|
||||
"label": "Swipe left action (mobile)",
|
||||
"description": "On touch devices, the action performed when you swipe a message to the left in the list."
|
||||
},
|
||||
"swipe_actions": {
|
||||
"none": "None",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"mark_read": "Toggle read",
|
||||
"star": "Toggle star",
|
||||
"spam": "Mark as spam"
|
||||
},
|
||||
"rtl_editing": {
|
||||
"label": "Right-to-left editing support",
|
||||
"description": "Adds a direction button to the composer toolbar so you can set paragraphs left-to-right or right-to-left"
|
||||
|
||||
@@ -144,6 +144,8 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab';
|
||||
const DEVICE_LOCAL_SETTING_KEYS = new Set<string>(['proInterface']);
|
||||
|
||||
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
|
||||
/** Action fired by a mobile list-row swipe. 'none' disables that direction. */
|
||||
export type SwipeAction = 'none' | 'archive' | 'delete' | 'markRead' | 'star' | 'spam';
|
||||
export type HoverActionsMode = 'inline' | 'floating';
|
||||
export type HoverActionsCorner = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||
|
||||
@@ -324,6 +326,8 @@ interface SettingsState {
|
||||
hoverActions: HoverAction[]; // Quick actions shown on hover in mail list
|
||||
hoverActionsMode: HoverActionsMode; // Display mode: inline (current) or floating corner
|
||||
hoverActionsCorner: HoverActionsCorner; // Corner for floating mode
|
||||
swipeRightAction: SwipeAction; // Mobile: action when a list row is swiped right
|
||||
swipeLeftAction: SwipeAction; // Mobile: action when a list row is swiped left
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: number; // milliseconds
|
||||
@@ -555,6 +559,8 @@ const DEFAULT_SETTINGS = {
|
||||
hoverActions: ['delete', 'star', 'markRead', 'archive'] as HoverAction[],
|
||||
hoverActionsMode: 'inline' as HoverActionsMode,
|
||||
hoverActionsCorner: 'top-right' as HoverActionsCorner,
|
||||
swipeRightAction: 'archive' as SwipeAction,
|
||||
swipeLeftAction: 'delete' as SwipeAction,
|
||||
|
||||
// Composer
|
||||
autoSaveDraftInterval: 60000, // 1 minute
|
||||
@@ -774,6 +780,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
hoverActions: state.hoverActions,
|
||||
hoverActionsMode: state.hoverActionsMode,
|
||||
hoverActionsCorner: state.hoverActionsCorner,
|
||||
swipeRightAction: state.swipeRightAction,
|
||||
swipeLeftAction: state.swipeLeftAction,
|
||||
disableThreading: state.disableThreading,
|
||||
trustedSenders: state.trustedSenders,
|
||||
autoSaveDraftInterval: state.autoSaveDraftInterval,
|
||||
|
||||
Reference in New Issue
Block a user