fix: open a folder's actions on the phone with a long-press or its options button

This commit is contained in:
Linus Rath
2026-10-28 03:25:49 +01:00
parent c5acb4815e
commit 2c808b0990
31 changed files with 135 additions and 1 deletions
@@ -0,0 +1,60 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// Touch has no right-click: a folder's menu (rename, delete, new subfolder,
// empty, mark read) opens on a long-press and from the row's ⋮ (R7).
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const box = (id: string, name: string, role: string | null) => ({
id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0,
myRights: { mayAddItems: true, mayRename: true, mayDelete: true, mayCreateChild: true },
}) as unknown as Mailbox;
const mailboxes: Mailbox[] = [box('inbox', 'Inbox', 'inbox'), box('travel', 'Travel', null)];
const row = (mailboxId: string) =>
screen.getAllByTestId('folder-row').find((el) => el.getAttribute('data-mailbox-id') === mailboxId)!;
describe('folder menu on touch', () => {
beforeEach(() => {
vi.useFakeTimers();
useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
useSettingsStore.setState({ hideAccountSwitcher: true } as never);
});
afterEach(() => vi.useRealTimers());
it('opens on a long-press without opening the folder', () => {
const onMailboxSelect = vi.fn();
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" onMailboxSelect={onMailboxSelect} />);
fireEvent.touchStart(row('travel'), { touches: [{ clientX: 40, clientY: 200 }] });
act(() => { vi.advanceTimersByTime(600); });
fireEvent.touchEnd(row('travel'));
expect(screen.getByRole('menu')).toBeTruthy();
expect(screen.getByText('rename')).toBeTruthy();
expect(onMailboxSelect).not.toHaveBeenCalled();
});
it('opens from the row’s options button', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
fireEvent.click(row('travel').querySelector('[data-testid="folder-row-menu"]')!);
expect(screen.getByRole('menu')).toBeTruthy();
});
});
+46 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react";
import { memo, useState, useEffect, useMemo, useRef, Fragment, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -40,6 +40,7 @@ import {
Mails,
MailOpen,
MoreHorizontal,
MoreVertical,
} from "@/components/icons";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
@@ -55,6 +56,8 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useLongPress } from "@/hooks/use-long-press";
import { IconButton } from "@/components/ui/icon-button";
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { useShallow } from "zustand/react/shallow";
@@ -299,6 +302,23 @@ function SidebarRowCounts({
);
}
/**
* What a row's menu handler needs from an event, for a menu opened by a
* long-press (at the finger) or from the row's ⋮ (under the button).
*/
function menuEventAt(element: HTMLElement, point?: { clientX: number; clientY: number }): React.MouseEvent {
return {
type: point ? "contextmenu" : "click",
detail: 0,
clientX: point?.clientX ?? 0,
clientY: point?.clientY ?? 0,
currentTarget: element,
target: element,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.MouseEvent;
}
interface SidebarRowProps {
icon: ReactNode;
label: string;
@@ -357,11 +377,23 @@ function SidebarRow({
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
// Touch has no right-click: a long-press opens the same menu (R7), and a
// ⋮ shows it too.
const rowRef = useRef<HTMLDivElement>(null);
const hasMenu = !!onContextMenu && !isVirtual;
const longPress = useLongPress((point) => {
if (rowRef.current && onContextMenu) onContextMenu(menuEventAt(rowRef.current, point));
}, hasMenu);
return (
<div
ref={rowRef}
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
onTouchStart={hasMenu ? longPress.onTouchStart : undefined}
onTouchMove={hasMenu ? longPress.onTouchMove : undefined}
onTouchEnd={hasMenu ? longPress.onTouchEnd : undefined}
onTouchCancel={hasMenu ? longPress.onTouchCancel : undefined}
data-testid="folder-row"
data-mv-nav-row=""
data-folder-role={testRole ?? undefined}
@@ -445,6 +477,19 @@ function SidebarRow({
</>
)}
</button>
{hasMenu && !isCollapsed && (
<IconButton
label={t('folder_options', { name: label })}
data-testid="folder-row-menu"
className="hidden pointer-coarse:inline-flex ms-1 flex-shrink-0 text-muted-foreground"
onClick={(e) => {
e.stopPropagation();
onContextMenu(menuEventAt(e.currentTarget));
}}
>
<MoreVertical />
</IconButton>
)}
</div>
);
}
+1
View File
@@ -155,6 +155,7 @@
"shared": "مشترك",
"shared_account_hidden": "تم الإخفاء من الشريط الجانبي. أعد إظهاره من الإعدادات › التخطيط.",
"notifications_muted": "الإشعارات مكتومة",
"folder_options": "خيارات {name}",
"mail": "البريد",
"nav_label": "التنقل",
"add_app": "التطبيقات",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Compartit",
"shared_account_hidden": "Amagat de la barra lateral. Torna'l a mostrar a Configuració › Disposició.",
"notifications_muted": "Notificacions silenciades",
"folder_options": "Opcions de {name}",
"mail": "Correu",
"nav_label": "Navegació",
"add_app": "Aplicacions",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Sdílené",
"shared_account_hidden": "Skryto z postranního panelu. Znovu zobrazíte v Nastavení › Vzhled.",
"notifications_muted": "Oznámení ztlumena",
"folder_options": "Možnosti pro {name}",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Delt",
"shared_account_hidden": "Skjult i sidepanelet. Vis den igen under Indstillinger › Layout.",
"notifications_muted": "Notifikationer slået fra",
"folder_options": "Indstillinger for {name}",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Geteilt",
"shared_account_hidden": "In der Seitenleiste ausgeblendet. Unter Einstellungen › Layout wieder einblenden.",
"notifications_muted": "Benachrichtigungen stummgeschaltet",
"folder_options": "Optionen für {name}",
"scheduled": "Geplant",
"unified_all_unread": "Alle ungelesenen",
"unified_all_starred": "Alle markierten",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Shared",
"shared_account_hidden": "Hidden from the sidebar. Turn it back on in Settings › Layout.",
"notifications_muted": "Notifications muted",
"folder_options": "Options for {name}",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Compartido",
"shared_account_hidden": "Oculta en la barra lateral. Vuelve a mostrarla en Configuración › Diseño.",
"notifications_muted": "Notificaciones silenciadas",
"folder_options": "Opciones de {name}",
"scheduled": "Programados",
"unified_all_unread": "Todo no leído",
"unified_all_starred": "Todo destacado",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Jagatud",
"shared_account_hidden": "Külgribal peidetud. Lülita see tagasi sisse menüüs Seaded › Paigutus.",
"notifications_muted": "Märguanded vaigistatud",
"folder_options": "{name}: valikud",
"mail": "Kirjad",
"nav_label": "Navigeerimine",
"add_app": "Rakendused",
+1
View File
@@ -155,6 +155,7 @@
"shared": "اشتراکی",
"shared_account_hidden": "از نوار کناری پنهان شد. از تنظیمات › چیدمان دوباره نمایش دهید.",
"notifications_muted": "اعلان‌ها بی‌صدا",
"folder_options": "گزینه‌های {name}",
"mail": "ایمیل",
"nav_label": "ناوبری",
"add_app": "برنامه‌ها",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Partagé",
"shared_account_hidden": "Masqué de la barre latérale. Réaffichez-le dans Paramètres › Mise en page.",
"notifications_muted": "Notifications désactivées",
"folder_options": "Options de {name}",
"scheduled": "Planifiés",
"unified_all_unread": "Tous les non lus",
"unified_all_starred": "Tous les favoris",
+1
View File
@@ -157,6 +157,7 @@
"shared": "משותף",
"shared_account_hidden": "הוסתר מסרגל הצד. אפשר להציג שוב בהגדרות › פריסה.",
"notifications_muted": "ההתראות מושתקות",
"folder_options": "אפשרויות עבור {name}",
"scheduled": "מתוכננים",
"unified_all_unread": "הכל שלא נקראו",
"unified_all_starred": "הכל מסומנים",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Megosztott",
"shared_account_hidden": "Elrejtve az oldalsávról. A Beállítások › Elrendezés alatt jelenítheted meg újra.",
"notifications_muted": "Értesítések némítva",
"folder_options": "{name} beállításai",
"mail": "Levelek",
"nav_label": "Navigáció",
"add_app": "Alkalmazások",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Condiviso",
"shared_account_hidden": "Nascosto dalla barra laterale. Puoi mostrarlo di nuovo in Impostazioni › Layout.",
"notifications_muted": "Notifiche disattivate",
"folder_options": "Opzioni per {name}",
"scheduled": "Programmate",
"unified_all_unread": "Tutti i non letti",
"unified_all_starred": "Tutti gli speciali",
+1
View File
@@ -158,6 +158,7 @@
"shared": "共有",
"shared_account_hidden": "サイドバーから非表示にしました。設定 › レイアウトで再表示できます。",
"notifications_muted": "通知ミュート中",
"folder_options": "{name} のオプション",
"scheduled": "予約済み",
"unified_all_unread": "すべての未読",
"unified_all_starred": "すべてのスター付き",
+1
View File
@@ -158,6 +158,7 @@
"shared": "공유됨",
"shared_account_hidden": "사이드바에서 숨겼습니다. 설정 › 레이아웃에서 다시 표시할 수 있습니다.",
"notifications_muted": "알림 꺼짐",
"folder_options": "{name} 옵션",
"scheduled": "예약됨",
"unified_all_unread": "모든 읽지 않음",
"unified_all_starred": "모든 별표",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Koplietots",
"shared_account_hidden": "Paslēpts no sānjoslas. Atkal parādiet sadaļā Iestatījumi › Izkārtojums.",
"notifications_muted": "Paziņojumi izslēgti",
"folder_options": "{name} opcijas",
"scheduled": "Ieplānots",
"unified_all_unread": "Visi nelasītie",
"unified_all_starred": "Visi ar zvaigzni",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Хуваалцсан",
"shared_account_hidden": "Хажуугийн самбараас нуусан. Тохиргоо › Байршил хэсгээс дахин харуулна уу.",
"notifications_muted": "Мэдэгдэл хаалттай",
"folder_options": "{name}-ийн сонголтууд",
"mail": "Мэйл",
"nav_label": "Навигаци",
"add_app": "Програмууд",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Delt",
"shared_account_hidden": "Skjult fra sidepanelet. Vis den igjen under Innstillinger › Oppsett.",
"notifications_muted": "Varsler dempet",
"folder_options": "Alternativer for {name}",
"mail": "E-post",
"nav_label": "Navigasjon",
"add_app": "Apper",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Gedeeld",
"shared_account_hidden": "Verborgen in de zijbalk. Zet het weer aan via Instellingen › Indeling.",
"notifications_muted": "Meldingen gedempt",
"folder_options": "Opties voor {name}",
"scheduled": "Gepland",
"unified_all_unread": "Alle ongelezen",
"unified_all_starred": "Alle met ster",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Udostępnione",
"shared_account_hidden": "Ukryto na pasku bocznym. Przywrócisz je w Ustawienia › Układ.",
"notifications_muted": "Powiadomienia wyciszone",
"folder_options": "Opcje folderu {name}",
"scheduled": "Zaplanowane",
"unified_all_unread": "Wszystkie nieprzeczytane",
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Compartilhado",
"shared_account_hidden": "Oculto da barra lateral. Mostre de novo em Configurações › Layout.",
"notifications_muted": "Notificações silenciadas",
"folder_options": "Opções de {name}",
"scheduled": "Agendados",
"unified_all_unread": "Tudo não lido",
"unified_all_starred": "Tudo com estrela",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Partajat",
"shared_account_hidden": "Ascuns din bara laterală. Îl poți afișa din nou în Setări › Aspect.",
"notifications_muted": "Notificări dezactivate",
"folder_options": "Opțiuni pentru {name}",
"mail": "E-mail",
"nav_label": "Navigare",
"add_app": "Aplicații",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Общие",
"shared_account_hidden": "Скрыто с боковой панели. Вернуть можно в Настройки › Макет.",
"notifications_muted": "Уведомления отключены",
"folder_options": "Параметры: {name}",
"scheduled": "Запланировано",
"unified_all_unread": "Все непрочитанные",
"unified_all_starred": "Все помеченные",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Zdieľané",
"shared_account_hidden": "Skryté z bočného panela. Znova zobrazíte v Nastavenia › Rozloženie.",
"notifications_muted": "Upozornenia stlmené",
"folder_options": "Možnosti pre {name}",
"mail": "Pošta",
"nav_label": "Navigácia",
"add_app": "Aplikácie",
+1
View File
@@ -256,6 +256,7 @@
"shared": "แชร์แล้ว",
"shared_account_hidden": "ซ่อนจากแถบด้านข้างแล้ว เปิดใช้งานอีกครั้งได้ที่ การตั้งค่า › รูปลักษณ์",
"notifications_muted": "ปิดเสียงการแจ้งเตือนแล้ว",
"folder_options": "ตัวเลือกสำหรับ {name}",
"mail": "จดหมาย",
"nav_label": "การนำทาง",
"add_app": "แอปพลิเคชัน",
+1
View File
@@ -155,6 +155,7 @@
"shared": "Paylaşılan",
"shared_account_hidden": "Kenar çubuğundan gizlendi. Ayarlar › Düzen bölümünden yeniden gösterebilirsiniz.",
"notifications_muted": "Bildirimler sessizde",
"folder_options": "{name} seçenekleri",
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar",
+1
View File
@@ -158,6 +158,7 @@
"shared": "Спільні",
"shared_account_hidden": "Приховано з бічної панелі. Повернути можна в Налаштування › Макет.",
"notifications_muted": "Сповіщення вимкнено",
"folder_options": "Параметри: {name}",
"scheduled": "Заплановано",
"unified_all_unread": "Усі непрочитані",
"unified_all_starred": "Усі позначені",
+1
View File
@@ -155,6 +155,7 @@
"shared": "共用",
"shared_account_hidden": "已從側邊欄隱藏。可在 設定 › 版面配置 中重新顯示。",
"notifications_muted": "通知已靜音",
"folder_options": "{name} 的選項",
"mail": "郵件",
"nav_label": "導覽",
"add_app": "應用程式",
+1
View File
@@ -158,6 +158,7 @@
"shared": "共享",
"shared_account_hidden": "已从侧边栏隐藏。可在 设置 › 布局 中重新显示。",
"notifications_muted": "通知已静音",
"folder_options": "{name} 的选项",
"scheduled": "已计划",
"unified_all_unread": "全部未读",
"unified_all_starred": "全部加星标",