Merge branch 'feat/do-menu-audit' into feat/do-menu-fixes

# Conflicts:
#	components/email/email-context-menu.tsx
#	components/mail/mail-app.tsx
This commit is contained in:
Linus Rath
2026-11-04 06:19:07 +01:00
55 changed files with 929 additions and 77 deletions
@@ -119,6 +119,41 @@ describe('command palette', () => {
expect(hint('search')).toBe('');
});
it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
const hint = (label: string) => {
const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!;
return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' ');
};
useSettingsStore.setState({ singleKeyShortcuts: false });
try {
render(<CommandPaletteHost />);
pressCtrlK();
expect(hint('compose')).toBe('');
expect(hint('shortcuts')).toBe('');
} finally {
useSettingsStore.setState({ singleKeyShortcuts: true });
}
});
it('names the account Sign out leaves, as the account menu does', async () => {
const { useAccountStore } = await import('@/stores/account-store');
const before = useAccountStore.getState().accounts;
useAccountStore.setState({ accounts: [
{ id: 'a1', email: 'one@example.org', username: 'one@example.org' },
{ id: 'a2', email: 'two@example.org', username: 'two@example.org' },
] } as never);
useAuthStore.setState({ activeAccountId: 'a2' } as never);
try {
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument();
expect(screen.queryByText('sign_out')).toBeNull();
} finally {
useAccountStore.setState({ accounts: before } as never);
}
});
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render(<CommandPaletteHost />);
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
// Mail registers its palette commands in mail-app.tsx, which is too big to render
// here. Each message action with a key in the shortcuts help is in the palette too,
// under the key the help names.
const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8');
const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')"));
describe('Mail commands in the palette', () => {
it.each([
['reply', 'r'],
['reply-all', 'a'],
['forward', 'f'],
['star', 's'],
['archive', 'e'],
['delete', '#'],
['mark-unread', 'u'],
['mark-read', 'Shift + I'],
['spam', '!'],
])('has %s, shown with %s', (id, key) => {
const at = block.indexOf(`id: '${id}'`);
expect(at).toBeGreaterThan(-1);
const entry = block.slice(at, block.indexOf('}', at));
expect(entry).toContain(`shortcut: '${key}'`);
});
});
+17 -4
View File
@@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
@@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// A touch screen has no keyboard to use the shortcuts with (R8).
const touch = useMediaQuery("(pointer: coarse)");
// With single-key shortcuts off, "r" or "#" does nothing: no hint for it.
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false);
const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key));
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current });
@@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
run: () => { void useAuthStore.getState().switchAccount(entry.id); },
});
}
list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } });
// It signs out of the open account only, as the account menu's entry says.
const current = accounts.find((entry) => entry.id === activeAccountId);
const currentName = current ? toUnicodeEmail(current.email || current.username) : "";
list.push({
id: "sign-out",
group: account,
icon: LogOut,
label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"),
run: () => { void useAuthStore.getState().logout(); },
});
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
@@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
{command.source && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
{command.shortcut && (
{command.shortcut && hintKeys(command.shortcut).length > 0 && (
// Key hints only where there is a keyboard (R8).
<span className="flex-shrink-0 flex items-center gap-1 pointer-coarse:hidden" aria-hidden="true">
{shortcutAlternatives(command.shortcut).map((key) => (
{hintKeys(command.shortcut).map((key) => (
<kbd key={key} className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">{key}</kbd>
))}
</span>
@@ -182,7 +182,7 @@ describe('ContactsApp', () => {
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
@@ -190,11 +190,27 @@ describe('ContactsApp', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => {
const shared = { ...alice, addressBookIds: { 'ab-shared': true } };
renderContacts([shared, bob]);
act(() => {
useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never });
});
fireEvent.contextMenu(screen.getByText('Alice Smith'));
expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull();
expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
fireEvent.contextMenu(screen.getByText('Bob Jones'));
expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument();
});
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
+20 -11
View File
@@ -65,6 +65,8 @@ interface ContactContextMenuProps {
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
/** The contact is in an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
export function ContactContextMenu({
@@ -88,8 +90,10 @@ export function ContactContextMenu({
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
@@ -134,7 +138,7 @@ export function ContactContextMenu({
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
label={tMenu("contacts_bulk_add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{onEdit && !readOnly && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
label={tMenu("contacts_add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
{onMoveTo && !readOnly && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
{!readOnly && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
shortcut="Del"
onClick={handle(onDelete)}
destructive
/>
</>
)}
</ContextMenu>
);
}
+5 -2
View File
@@ -35,6 +35,8 @@ interface ContactDetailProps {
onPrint?: () => void;
isMobile?: boolean;
className?: string;
/** In an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
// Contexts and phone features the contact form offers, shown with the same
@@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
@@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</a>
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
{isMobile ? (
{readOnly ? null : isMobile ? (
<ToolbarButton label={t("form.edit_title")} onClick={onEdit} className="touch-manipulation">
<Pencil />
</ToolbarButton>
@@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
readOnly={readOnly}
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
+4
View File
@@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la
interface ContactListProps {
contacts: ContactCard[];
/** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */
isContactReadOnly?: (contact: ContactCard) => boolean;
selectedContactId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
@@ -144,6 +146,7 @@ interface ContactListProps {
export function ContactList({
contacts,
isContactReadOnly,
selectedContactId,
searchQuery,
onSearchChange,
@@ -788,6 +791,7 @@ export function ContactList({
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
readOnly={isContactReadOnly?.(contextMenu.data) ?? false}
/>
)}
</div>
+5
View File
@@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
// A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete.
const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {})
.some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]);
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
@@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined}
isMobile={isMobile}
readOnly={selectedContact ? isContactReadOnly(selectedContact) : false}
/>
);
}
@@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
) : (
<ContactList
contacts={displayedContacts}
isContactReadOnly={isContactReadOnly}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
@@ -327,6 +327,8 @@ describe('composer-send-menu', () => {
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
expect(follows(schedule, plugin)).toBe(true);
// It opens a dialog, and shows the key that opens it from the composer.
expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
@@ -0,0 +1,114 @@
import { render, screen, within } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
import { EmailContextMenu } from '../email-context-menu';
import { messageActionShortcut } from '../message-action-shortcuts';
const email = {
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
hasAttachment: false,
size: 100,
} as unknown as Email;
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function renderMenu(props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onDelete={vi.fn()}
onToggleStar={vi.fn()}
onMarkAsRead={vi.fn()}
onMarkAsSpam={vi.fn()}
{...props}
/>,
);
}
const row = (name: string) => screen.getByText(name).closest('button') as HTMLElement;
describe('message menu key hints', () => {
afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true }));
it('shows the keys the list runs each action with', () => {
renderMenu();
expect(within(row('reply')).getByText('R')).toBeInTheDocument();
expect(within(row('reply_all')).getByText('Shift + R')).toBeInTheDocument();
expect(within(row('forward')).getByText('F')).toBeInTheDocument();
expect(within(row('star')).getByText('S')).toBeInTheDocument();
expect(within(row('mark_read')).getByText('Shift + I')).toBeInTheDocument();
expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
});
it('leaves single keys out when they are turned off, and keeps Del', () => {
useSettingsStore.setState({ singleKeyShortcuts: false });
renderMenu();
expect(within(row('reply')).queryByText('R')).toBeNull();
expect(within(row('star')).queryByText('S')).toBeNull();
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
});
it('shows them on a selection too', () => {
renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchDelete: vi.fn(), onBatchMarkAsRead: vi.fn(), onBatchMarkAsSpam: vi.fn() });
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-mark-read')).getByText('Shift + I')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-spam')).getByText('!')).toBeInTheDocument();
});
it('matches the read state: U marks a read message unread', () => {
expect(messageActionShortcut('read', { unread: false, singleKeys: true })).toBe('U');
expect(messageActionShortcut('read', { unread: true, singleKeys: true })).toBe('Shift + I');
expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined();
});
});
describe('message menu offline', () => {
afterEach(async () => {
const { useOfflineStore } = await import('@/lib/offline/state');
useOfflineStore.setState({ browserOffline: false, enabled: [] });
});
it('greys out changes that would fail without a connection, and says why', async () => {
const { useOfflineStore } = await import('@/lib/offline/state');
useOfflineStore.setState({ browserOffline: true, enabled: [] });
renderMenu();
expect(screen.getByText('offline_header')).toBeInTheDocument();
expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId('ctx-star') as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId('ctx-mark-read') as HTMLButtonElement).disabled).toBe(true);
// Writing a reply still works: the composer keeps the draft.
expect(row('reply')).not.toBeDisabled();
});
it('leaves them on while online', () => {
renderMenu();
expect(screen.queryByText('offline_header')).toBeNull();
expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(false);
});
});
+3 -1
View File
@@ -56,6 +56,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml, sanitizePluginBodyHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -592,6 +593,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tMenu = useTranslations('menuAudit');
const tCommon = useTranslations('common');
const tWindows = useTranslations('pwaWindows');
const tQuote = useTranslations('quote_header');
@@ -5814,7 +5816,7 @@ export function EmailComposer({
<ContextMenu ref={sendMenu.menuRef} isOpen={showSendMenu} position={sendMenu.contextMenu.position} onClose={closeSendMenu}>
{composerClient?.hasDelayedSend() && (
<ContextMenuItem icon={CalendarClock} label={t('schedule_send')} onClick={openScheduleDialog} testId="composer-schedule-send" />
<ContextMenuItem icon={CalendarClock} label={tMenu('schedule_send')} shortcut={formatShortcutCombo('Mod + Shift + Enter')} onClick={openScheduleDialog} testId="composer-schedule-send" />
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
+34 -12
View File
@@ -34,6 +34,8 @@ import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { useMessageActionShortcut } from "./message-action-shortcuts";
import { useConnectionState } from "@/hooks/use-connection-state";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
import { RulesContextSubMenu } from "./rules-menu";
@@ -143,6 +145,12 @@ interface EmailContextMenuProps {
label?: string;
}
/** Entries that change the message on the server, or queue the change offline. */
const CHANGES_MAIL = new Set<MessageActionKey>([
"archive", "delete", "read", "remove_from_folder", "star", "pin", "spam",
"reschedule", "cancel_send", "cancel_and_edit",
]);
export function EmailContextMenu({
email,
position,
@@ -201,6 +209,12 @@ export function EmailContextMenu({
label,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const shortcutOf = useMessageActionShortcut();
// Offline, a change to a message waits in the outbox only where the account keeps
// its mail on this device; otherwise it would fail, so it's greyed out. Rules and
// copies to another account always need the server.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const archiveActionEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
const isUnread = !email.keywords?.$seen;
@@ -340,8 +354,9 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Archive}
label={t("archive")}
shortcut={shortcutOf("archive")}
onClick={() => handleAction(onBatchArchive!)}
disabled={!onBatchArchive}
disabled={!onBatchArchive || !canChangeMail}
/>
</>
);
@@ -352,8 +367,9 @@ export function EmailContextMenu({
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
shortcut={shortcutOf("delete")}
onClick={() => handleAction(onBatchDelete!)}
disabled={!onBatchDelete}
disabled={!onBatchDelete || !canChangeMail}
destructive={!mountainView}
/>
</>
@@ -365,6 +381,8 @@ export function EmailContextMenu({
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
shortcut={shortcutOf("read", isUnread)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))}
/>
</>
@@ -373,7 +391,7 @@ export function EmailContextMenu({
<>
{/* Move to submenu */}
{canMoveAnywhere && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to" disabled={!canChangeMail}>
{renderMoveList(onBatchMoveToMailbox)}
</ContextMenuSubMenu>
)}
@@ -382,6 +400,7 @@ export function EmailContextMenu({
icon={FolderX}
label={t("remove_from_folder")}
testId="ctx-remove-from-folder"
disabled={!canChangeMail}
onClick={() => handleAction(onBatchRemoveFromFolder!)}
/>
)}
@@ -399,7 +418,7 @@ export function EmailContextMenu({
<>
{/* Copy to another connected account, keeping the originals */}
{onCopyToAccount && copyTrees.length > 0 && (
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to">
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to" disabled={offline}>
{copyTargetsContent}
</ContextMenuSubMenu>
)}
@@ -408,14 +427,14 @@ export function EmailContextMenu({
const rulesItem = (
<>
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} disabled={offline} />
</>
);
const tagItem = (
<>
{/* Set tag submenu - the whole selection's tags (#1077) */}
{onBatchToggleTag && (
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag">
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag" disabled={!canChangeMail}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={batchTagIds ?? []}
@@ -439,8 +458,9 @@ export function EmailContextMenu({
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
shortcut={shortcutOf("spam")}
onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)}
disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam}
disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail}
destructive={!isInJunkFolder && !mountainView}
/>
{batchPhishing.available && (
@@ -459,26 +479,26 @@ export function EmailContextMenu({
switch (entry.key) {
case "move":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
{renderMoveList(onMoveToMailbox)}
</ContextMenuSubMenu>
);
case "copy_to_account":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={offline}>
{copyTargetsContent}
</ContextMenuSubMenu>
);
case "tag":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
<div className="w-56 max-w-[18rem]">
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} scope={tagScope} />
</div>
</ContextMenuSubMenu>
);
case "rules":
return <RulesContextSubMenu key={entry.key} anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />;
return <RulesContextSubMenu key={entry.key} anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} disabled={offline} />;
}
};
@@ -491,8 +511,9 @@ export function EmailContextMenu({
icon={entry.icon}
label={entry.label}
testId={entry.testId}
disabled={entry.disabled}
disabled={entry.disabled || (!canChangeMail && CHANGES_MAIL.has(entry.key))}
destructive={entry.destructive}
shortcut={shortcutOf(entry.key, isUnread)}
onClick={() => handleAction(entry.run)}
/>
);
@@ -506,6 +527,7 @@ export function EmailContextMenu({
onClose={onClose}
label={label}
>
{offline && <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader>}
{showBatchActions ? (
<>
<ContextMenuHeader>
@@ -0,0 +1,39 @@
"use client";
import { useCallback } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import type { MessageActionKey } from "./message-actions";
/**
* The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts),
* as the message menus show them next to the entries. The native Message menu shows the
* same ones. A single key goes when the user turned single-key shortcuts off, since it
* does nothing then.
*/
const KEYS: Partial<Record<MessageActionKey, { key: string; single: boolean }>> = {
reply: { key: "R", single: true },
reply_all: { key: "Shift + R", single: true },
forward: { key: "F", single: true },
archive: { key: "E", single: true },
delete: { key: "Del", single: false },
star: { key: "S", single: true },
spam: { key: "!", single: true },
};
export function messageActionShortcut(
key: MessageActionKey,
options: { unread?: boolean; singleKeys: boolean },
): string | undefined {
const entry = key === "read"
? { key: options.unread ? "Shift + I" : "U", single: true }
: KEYS[key];
if (!entry) return undefined;
if (entry.single && !options.singleKeys) return undefined;
return entry.key;
}
/** `messageActionShortcut` with the user's single-key setting. */
export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined {
const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false);
return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]);
}
+6 -3
View File
@@ -410,10 +410,13 @@ export function RulesContextSubMenu({
anchor,
senderEmails,
onClose,
disabled = false,
}: {
anchor: Email;
senderEmails: Email[];
onClose: () => void;
/** Rules are saved on the server: greyed out offline. */
disabled?: boolean;
}) {
const t = useTranslations("context_menu");
const accountEmails = useMemo(
@@ -427,7 +430,7 @@ export function RulesContextSubMenu({
icon={Filter}
label={t("rules.title")}
testId="ctx-rules"
disabled={availability === "cross_account"}
disabled={disabled || availability === "cross_account"}
hint={availability === "cross_account" ? t("rules.cross_account") : undefined}
>
{target && <ContextRulesContent anchor={anchor} senderEmails={senderEmails} target={target} onDone={onClose} />}
@@ -804,7 +807,7 @@ function FolderRulesContent({
* saved rules over the mail already in the folder (#1166). Hidden for shared
* folders and accounts without Sieve.
*/
export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onClose: () => void }) {
export function FolderRulesSubMenu({ mailbox, onClose, disabled = false }: { mailbox: Mailbox; onClose: () => void; disabled?: boolean }) {
const t = useTranslations("context_menu");
const client = useAuthStore((s) => s.client);
const target = useMemo(
@@ -813,7 +816,7 @@ export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onC
);
if (!target || !target.supportsSieve) return null;
return (
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules">
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules" disabled={disabled}>
<FolderRulesContent mailbox={mailbox} target={target} onDone={onClose} />
</ContextMenuSubMenu>
);
@@ -221,7 +221,7 @@ describe('FileBrowser', () => {
renderBrowser({ resources: [resource(OWN[1])], allFolders: [shared] });
fireEvent.contextMenu(within(tree()).getByText('Documents'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'share' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'files_share' }));
expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1');
});
@@ -230,7 +230,7 @@ describe('FileBrowser', () => {
renderBrowser();
await screen.findAllByText('Team');
fireEvent.contextMenu(within(tree()).getByText('Team'));
expect(screen.queryByRole('menuitem', { name: 'share' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'files_share' })).toBeNull();
});
it('hides dot files unless the setting shows them', () => {
@@ -284,7 +284,7 @@ describe('FileBrowser', () => {
it('offers new and upload entries on My files in the sidebar', () => {
renderBrowser();
fireEvent.contextMenu(within(tree()).getByText('my_files'));
for (const name of ['open', 'new_folder', 'new_text_file', 'upload_files', 'upload_folder']) {
for (const name of ['open', 'files_new_folder', 'files_new_text_file', 'files_upload_files', 'files_upload_folder']) {
expect(screen.getByRole('menuitem', { name })).toBeInTheDocument();
}
});
@@ -457,7 +457,7 @@ describe('FileBrowser', () => {
}
render(<Probe />);
const files = groups.find((g) => g.label === 'files');
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash']));
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['files_upload_files', 'files_new_folder', 'trash']));
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
+10 -8
View File
@@ -222,7 +222,9 @@ export function FileBrowser({
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const newFileLabel = newFileFormats.length > 0 ? t("new_file") : t("new_text_file");
const tMenu = useTranslations("menuAudit");
// Menu entries that open a dialog end in "…".
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
const validateFileName = useCallback((name: string) => {
const problem = fileNameProblem(name, fileNameRules);
@@ -951,7 +953,7 @@ export function FileBrowser({
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
)}
{resource.isDirectory && !resource.isShared && (
<ContextMenuItem
@@ -992,10 +994,10 @@ export function FileBrowser({
const newItems = (
<>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel} onClick={() => { closeMenu(); setShowNewTextFile(true); }} />
</>
);
@@ -1035,9 +1037,9 @@ export function FileBrowser({
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
+2 -1
View File
@@ -56,6 +56,7 @@ export function FileDetailsPanel({
iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile,
}: FileDetailsPanelProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
@@ -147,7 +148,7 @@ export function FileDetailsPanel({
)}
{canShare && (
<Button size="sm" variant="outline" className="h-8 gap-1.5" onClick={onShare}>
<Share2 className="w-4 h-4" />{t("share")}
<Share2 className="w-4 h-4" />{tMenu("files_share_button")}
</Button>
)}
{resource.isDirectory && !resource.isShared && (
+2 -1
View File
@@ -223,6 +223,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
note?: string;
}) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const touchProps = useTouchPress(touch, selecting, resource, handlers);
const icon = <ResourceIcon resource={resource} size="sm" options={iconOptions} />;
return (
@@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
<RowAction icon={Download} label={t("download")} onClick={() => handlers.onDownload?.(resource)} />
)}
{!touch && handlers.canShare(resource) && (
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
<RowAction icon={Share2} label={tMenu("files_share_button")} onClick={() => handlers.onShare(resource)} />
)}
<IconButton
data-item-action
+2 -1
View File
@@ -120,6 +120,7 @@ export function FilesHeader({
panelToggle,
}: FilesHeaderProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const sortMenu = useContextMenu<null>();
const overflowMenu = useContextMenu<Crumb[]>();
const [searchOpen, setSearchOpen] = useState(false);
@@ -159,7 +160,7 @@ export function FilesHeader({
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
<ToolButton icon={Share2} label={tMenu("files_share_button")} onClick={selection.onShare} compact />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
+10 -9
View File
@@ -125,6 +125,7 @@ export function FilesSidebar({
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const newMenu = useContextMenu<null>();
const itemMenu = useContextMenu<MenuTarget>();
const newButtonRef = useRef<HTMLButtonElement>(null);
@@ -274,10 +275,10 @@ export function FilesSidebar({
const createItems = (path: string, id: string | null) => (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
</>
);
@@ -455,11 +456,11 @@ export function FilesSidebar({
</nav>
<ContextMenu ref={newMenu.menuRef} isOpen={newMenu.contextMenu.isOpen} position={newMenu.contextMenu.position} onClose={newMenu.closeContextMenu}>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
</ContextMenu>
<ContextMenu ref={itemMenu.menuRef} isOpen={itemMenu.contextMenu.isOpen} position={itemMenu.contextMenu.position} onClose={itemMenu.closeContextMenu}>
@@ -484,7 +485,7 @@ export function FilesSidebar({
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => run(() => onShare(folder))} />
)}
{!folder.isShared && (
<ContextMenuItem
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// A folder menu greys out what can't work: nothing to mark read, or no connection
// for what needs the server.
vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => null }));
vi.mock('@/lib/offline/offline-mode', () => ({ offlineAllowed: () => true }));
const { MailboxContextMenu, unreadInTree } = await import('../mailbox-context-menu');
const { useOfflineStore } = await import('@/lib/offline/state');
const { useAuthStore } = await import('@/stores/auth-store');
const box = (id: string, unread: number, parentId: string | null = null) =>
({ id, name: id, parentId, sortOrder: 1, totalEmails: 5, unreadEmails: unread, myRights: {} }) as unknown as Mailbox;
function renderMenu(mailbox: Mailbox, mailboxes: Mailbox[], hasChildren = false) {
render(
<MailboxContextMenu
target={{ kind: 'mailbox', mailbox, hasChildren }}
position={{ x: 0, y: 0 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
onMarkFolderRead={vi.fn()}
onMarkFolderTreeRead={vi.fn()}
onCreateSubfolder={vi.fn()}
onRenameFolder={vi.fn()}
onDeleteFolder={vi.fn()}
onEmptyFolder={vi.fn()}
onImportEmail={vi.fn()}
onRefresh={vi.fn()}
/>,
);
}
const item = (name: string) => screen.getByRole('menuitem', { name }) as HTMLButtonElement;
describe('folder menu states', () => {
afterEach(() => {
useOfflineStore.setState({ browserOffline: false, downServers: [], enabled: [] });
});
it('greys out Mark folder as read when nothing in it is unread', () => {
const read = box('read', 0);
renderMenu(read, [read]);
expect(item('mark_folder_read').disabled).toBe(true);
});
it('keeps it for a folder with unread mail, and the tree entry for unread mail below', () => {
const parent = box('parent', 0);
const child = box('child', 3, 'parent');
renderMenu(parent, [parent, child], true);
expect(item('mark_folder_read').disabled).toBe(true);
expect(item('mark_folder_tree_read').disabled).toBe(false);
expect(unreadInTree(parent, [parent, child])).toBe(3);
});
it('offline, greys out what needs the server and says why', () => {
useOfflineStore.setState({ browserOffline: true });
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(screen.getByText('offline_header')).toBeInTheDocument();
for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) {
expect(item(name).disabled).toBe(true);
}
// No mail kept on this device: marking read would fail too.
expect(item('mark_folder_read').disabled).toBe(true);
});
it('offline with the mail kept on this device, marking read still works', () => {
useAuthStore.setState({ activeAccountId: 'acc' } as never);
useOfflineStore.setState({ browserOffline: true, enabled: ['acc'] });
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(item('mark_folder_read').disabled).toBe(false);
expect(item('rename').disabled).toBe(true);
});
});
+38 -14
View File
@@ -31,6 +31,7 @@ import {
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useConnectionState } from "@/hooks/use-connection-state";
interface Position {
x: number;
@@ -45,6 +46,20 @@ export type MailboxContextTarget =
| { kind: "folders-section"; accountId?: string };
const PATH_SEPARATOR = " › ";
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
let total = 0;
const seen = new Set<string>();
const visit = (box: Mailbox): boolean => {
if (seen.has(box.id)) return true;
seen.add(box.id);
if (typeof box.unreadEmails !== "number") return false;
total += box.unreadEmails;
return mailboxes.filter((child) => child.parentId === box.id).every(visit);
};
return visit(mailbox) ? total : undefined;
}
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
@@ -147,6 +162,11 @@ export function MailboxContextMenu({
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
// Folders, rules and sharing need the server. Marking read waits in the outbox
// where the account keeps its mail on this device.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
const handleAction = (action: () => void) => {
action();
@@ -198,13 +218,14 @@ export function MailboxContextMenu({
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
{offlineHeader}
{!target.accountId && (
<>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
disabled={!onMarkAllFoldersRead || !canChangeMail}
/>
<ContextMenuSeparator />
</>
@@ -213,14 +234,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
</ContextMenu>
@@ -238,6 +259,8 @@ export function MailboxContextMenu({
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const nothingUnread = mailbox.unreadEmails === 0;
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
// Sharing needs the mail:share extension (handler present) and, on a folder
// someone else shared with us, their permission to re-share it.
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
@@ -252,19 +275,20 @@ export function MailboxContextMenu({
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
{offlineHeader}
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow}
/>
)}
@@ -274,14 +298,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
{onShareFolder && (
@@ -289,11 +313,11 @@ export function MailboxContextMenu({
icon={Users}
label={t("share")}
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
disabled={!canShare}
disabled={!canShare || offline}
testId="mailbox-share"
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
{notifications && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
@@ -313,7 +337,7 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
{accountItems(target.sharedAccountId)}
@@ -326,7 +350,7 @@ export function MailboxContextMenu({
icon={Upload}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
disabled={!onImportEmail || !canAddItems || offline}
/>
{!exportOff && (
<ContextMenuItem
@@ -341,7 +365,7 @@ export function MailboxContextMenu({
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{/* Destructive entries last (R6). */}
@@ -351,14 +375,14 @@ export function MailboxContextMenu({
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
+10 -4
View File
@@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { openMailImport } from "@/stores/mail-transfer-store";
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload } from "@/components/icons";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -2335,6 +2335,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); },
};
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
// "Mark folder as read" is greyed out with nothing unread in the open folder.
const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0;
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
useEffect(() => {
@@ -4813,6 +4815,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
},
{
id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's',
when: hasPaletteEmail, run: () => void handleToggleStar(),
},
{
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
@@ -5542,7 +5548,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClick={() => { close(); void handleManualRefresh(); }}
/>
<MailFolderMenuItems
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onPicked={close}
@@ -5582,7 +5588,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
totalCount={searchQuery || !isFilterEmpty(searchFilters) ? undefined : mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails}
onRefresh={handleManualRefresh}
isRefreshing={isManualRefreshing}
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onCreateFolder={() => handleCreateFolderFromContextMenu()}
@@ -5652,7 +5658,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
folderRole={conversationRole}
onRefresh={handleManualRefresh}
refreshDisabled={!client || isManualRefreshing}
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? folderMenuMarkRead : undefined}
onMarkAllFoldersRead={folderMenuMarkAllRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
/>
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useSyncExternalStore } from "react";
import { serverKey, useOfflineStore } from "@/lib/offline/state";
import { offlineAllowed } from "@/lib/offline/offline-mode";
import { useAuthStore } from "@/stores/auth-store";
function subscribeOnline(callback: () => void): () => void {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
export interface ConnectionState {
/** No network, or the open account's mail server can't be reached. */
offline: boolean;
/**
* Changes to messages still work: online, or the account keeps its mail on this
* device and the change waits for the connection. Folders, rules and sharing
* always need the server.
*/
canChangeMail: boolean;
}
/** What the menus go by to grey out what can't work without a connection. */
export function useConnectionState(): ConnectionState {
const account = useAuthStore((s) => s.activeAccountId);
const server = useAuthStore((s) => s.serverUrl);
const navigatorOffline = useSyncExternalStore(
subscribeOnline,
() => navigator.onLine === false,
() => false,
);
const storeOffline = useOfflineStore((s) => s.browserOffline || (!!server && s.downServers.includes(serverKey(server))));
const keepsMail = useOfflineStore((s) => !!account && s.enabled.includes(account));
const offline = navigatorOffline || storeOffline;
return { offline, canChangeMail: !offline || (keepsMail && offlineAllowed(account)) };
}
+9
View File
@@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store";
export function isSingleKeyShortcutOff(event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">): boolean {
return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false;
}
/**
* A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key
* shortcut, so it does nothing while those are off. Arrows aren't characters.
*/
export function isSingleKeyHint(combo: string): boolean {
const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim();
return [...key].length === 1 && !/[←→↑↓]/.test(key);
}
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "الحساب",
"switch_to": "التبديل إلى {account}"
},
"menuAudit": {
"offline_header": "أنت غير متصل. بعض الإجراءات تحتاج إلى اتصال.",
"files_new_folder": "مجلد جديد…",
"files_new_file": "ملف جديد…",
"files_new_text_file": "ملف نصي جديد…",
"files_upload_files": "رفع الملفات…",
"files_upload_folder": "رفع مجلد…",
"files_share": "مشاركة…",
"files_share_button": "مشاركة",
"contacts_add_to_group": "إضافة إلى مجموعة…",
"contacts_bulk_add_to_group": "إضافة إلى مجموعة…",
"schedule_send": "جدولة الإرسال…"
},
"notification_center": {
"title": "الإشعارات",
"mark_all_read": "تعليم الكل كمقروء",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Compte",
"switch_to": "Canvia a {account}"
},
"menuAudit": {
"offline_header": "No teniu connexió. Algunes accions la necessiten.",
"files_new_folder": "Carpeta nova…",
"files_new_file": "Fitxer nou…",
"files_new_text_file": "Fitxer de text nou…",
"files_upload_files": "Puja fitxers…",
"files_upload_folder": "Puja una carpeta…",
"files_share": "Comparteix…",
"files_share_button": "Comparteix",
"contacts_add_to_group": "Afegeix al grup…",
"contacts_bulk_add_to_group": "Afegeix al grup…",
"schedule_send": "Programa l'enviament…"
},
"notification_center": {
"title": "Notificacions",
"mark_all_read": "Marca-ho tot com a llegit",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Účet",
"switch_to": "Přepnout na {account}"
},
"menuAudit": {
"offline_header": "Jste offline. Některé akce potřebují připojení.",
"files_new_folder": "Nová složka…",
"files_new_file": "Nový soubor…",
"files_new_text_file": "Nový textový soubor…",
"files_upload_files": "Nahrát soubory…",
"files_upload_folder": "Nahrát složku…",
"files_share": "Sdílet…",
"files_share_button": "Sdílet",
"contacts_add_to_group": "Přidat do skupiny…",
"contacts_bulk_add_to_group": "Přidat do skupiny…",
"schedule_send": "Naplánovat odeslání…"
},
"notification_center": {
"title": "Oznámení",
"mark_all_read": "Označit vše jako přečtené",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konto",
"switch_to": "Skift til {account}"
},
"menuAudit": {
"offline_header": "Du er offline. Nogle handlinger kræver forbindelse.",
"files_new_folder": "Ny mappe…",
"files_new_file": "Ny fil…",
"files_new_text_file": "Ny tekstfil…",
"files_upload_files": "Upload filer…",
"files_upload_folder": "Upload mappe…",
"files_share": "Del…",
"files_share_button": "Del",
"contacts_add_to_group": "Tilføj til gruppe…",
"contacts_bulk_add_to_group": "Tilføj til gruppe…",
"schedule_send": "Planlæg afsendelse…"
},
"notification_center": {
"title": "Notifikationer",
"mark_all_read": "Markér alle som læst",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konto",
"switch_to": "Zu {account} wechseln"
},
"menuAudit": {
"offline_header": "Sie sind offline. Manche Aktionen brauchen eine Verbindung.",
"files_new_folder": "Neuer Ordner…",
"files_new_file": "Neue Datei…",
"files_new_text_file": "Neue Textdatei…",
"files_upload_files": "Dateien hochladen…",
"files_upload_folder": "Ordner hochladen…",
"files_share": "Teilen…",
"files_share_button": "Teilen",
"contacts_add_to_group": "Zur Gruppe hinzufügen…",
"contacts_bulk_add_to_group": "Zur Gruppe hinzufügen…",
"schedule_send": "Senden planen…"
},
"notification_center": {
"title": "Benachrichtigungen",
"mark_all_read": "Alle als gelesen markieren",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Account",
"switch_to": "Switch to {account}"
},
"menuAudit": {
"offline_header": "You're offline. Some actions need a connection.",
"files_new_folder": "New folder…",
"files_new_file": "New file…",
"files_new_text_file": "New text file…",
"files_upload_files": "Upload files…",
"files_upload_folder": "Upload folder…",
"files_share": "Share…",
"files_share_button": "Share",
"contacts_add_to_group": "Add to group…",
"contacts_bulk_add_to_group": "Add to group…",
"schedule_send": "Schedule send…"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Mark all read",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Cuenta",
"switch_to": "Cambiar a {account}"
},
"menuAudit": {
"offline_header": "No tiene conexión. Algunas acciones la necesitan.",
"files_new_folder": "Nueva carpeta…",
"files_new_file": "Nuevo archivo…",
"files_new_text_file": "Nuevo archivo de texto…",
"files_upload_files": "Subir archivos…",
"files_upload_folder": "Subir carpeta…",
"files_share": "Compartir…",
"files_share_button": "Compartir",
"contacts_add_to_group": "Añadir al grupo…",
"contacts_bulk_add_to_group": "Agregar al grupo…",
"schedule_send": "Programar envío…"
},
"notification_center": {
"title": "Notificaciones",
"mark_all_read": "Marcar todo como leído",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konto",
"switch_to": "Lülitu kontole {account}"
},
"menuAudit": {
"offline_header": "Olete võrguühenduseta. Mõni toiming vajab ühendust.",
"files_new_folder": "Uus kaust…",
"files_new_file": "Uus fail…",
"files_new_text_file": "Uus tekstifail…",
"files_upload_files": "Laadi failid üles…",
"files_upload_folder": "Laadi kaust üles…",
"files_share": "Jaga…",
"files_share_button": "Jaga",
"contacts_add_to_group": "Lisa gruppi…",
"contacts_bulk_add_to_group": "Lisa gruppi…",
"schedule_send": "Ajasta saatmine…"
},
"notification_center": {
"title": "Märguanded",
"mark_all_read": "Märgi kõik loetuks",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "حساب",
"switch_to": "رفتن به {account}"
},
"menuAudit": {
"offline_header": "آفلاین هستید. برخی کارها به اتصال نیاز دارند.",
"files_new_folder": "پوشه جدید…",
"files_new_file": "فایل جدید…",
"files_new_text_file": "فایل متنی جدید…",
"files_upload_files": "بارگذاری فایل‌ها…",
"files_upload_folder": "بارگذاری پوشه…",
"files_share": "اشتراک‌گذاری…",
"files_share_button": "اشتراک‌گذاری",
"contacts_add_to_group": "افزودن به گروه…",
"contacts_bulk_add_to_group": "افزودن به گروه…",
"schedule_send": "زمان‌بندی ارسال…"
},
"notification_center": {
"title": "اعلان‌ها",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Compte",
"switch_to": "Passer à {account}"
},
"menuAudit": {
"offline_header": "Vous êtes hors ligne. Certaines actions ont besoin d’une connexion.",
"files_new_folder": "Nouveau dossier…",
"files_new_file": "Nouveau fichier…",
"files_new_text_file": "Nouveau fichier texte…",
"files_upload_files": "Téléverser des fichiers…",
"files_upload_folder": "Téléverser un dossier…",
"files_share": "Partager…",
"files_share_button": "Partager",
"contacts_add_to_group": "Ajouter au groupe…",
"contacts_bulk_add_to_group": "Ajouter au groupe…",
"schedule_send": "Planifier l’envoi…"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Tout marquer comme lu",
+13
View File
@@ -6463,6 +6463,19 @@
"group_account": "חשבון",
"switch_to": "מעבר אל {account}"
},
"menuAudit": {
"offline_header": "אין חיבור. חלק מהפעולות צריכות חיבור.",
"files_new_folder": "תיקיה חדשה…",
"files_new_file": "קובץ חדש…",
"files_new_text_file": "קובץ טקסט חדש…",
"files_upload_files": "העלה קבצים…",
"files_upload_folder": "העלה תיקייה…",
"files_share": "שתף…",
"files_share_button": "שתף",
"contacts_add_to_group": "הוסף לקבוצה…",
"contacts_bulk_add_to_group": "הוסף לקבוצה…",
"schedule_send": "תזמן שליחה…"
},
"notification_center": {
"title": "התראות",
"mark_all_read": "סימון הכול כנקרא",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Fiók",
"switch_to": "Váltás erre: {account}"
},
"menuAudit": {
"offline_header": "Nincs kapcsolat. Néhány művelethez kapcsolat kell.",
"files_new_folder": "Új mappa…",
"files_new_file": "Új fájl…",
"files_new_text_file": "Új szöveges fájl…",
"files_upload_files": "Fájlok feltöltése…",
"files_upload_folder": "Mappa feltöltése…",
"files_share": "Megosztás…",
"files_share_button": "Megosztás",
"contacts_add_to_group": "Hozzáadás a csoporthoz…",
"contacts_bulk_add_to_group": "Hozzáadás a csoporthoz…",
"schedule_send": "Késleltetett küldés…"
},
"notification_center": {
"title": "Értesítések",
"mark_all_read": "Összes megjelölése olvasottként",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Account",
"switch_to": "Passa a {account}"
},
"menuAudit": {
"offline_header": "Sei offline. Alcune azioni richiedono una connessione.",
"files_new_folder": "Nuova cartella…",
"files_new_file": "Nuovo file…",
"files_new_text_file": "Nuovo file di testo…",
"files_upload_files": "Carica file…",
"files_upload_folder": "Carica cartella…",
"files_share": "Condividi…",
"files_share_button": "Condividi",
"contacts_add_to_group": "Aggiungi al gruppo…",
"contacts_bulk_add_to_group": "Aggiungi al gruppo…",
"schedule_send": "Programma invio…"
},
"notification_center": {
"title": "Notifiche",
"mark_all_read": "Segna tutte come lette",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "アカウント",
"switch_to": "{account} に切り替え"
},
"menuAudit": {
"offline_header": "オフラインです。一部の操作には接続が必要です。",
"files_new_folder": "新しいフォルダー…",
"files_new_file": "新規ファイル…",
"files_new_text_file": "新規テキストファイル…",
"files_upload_files": "ファイルをアップロード…",
"files_upload_folder": "フォルダをアップロード…",
"files_share": "共有…",
"files_share_button": "共有",
"contacts_add_to_group": "グループに追加…",
"contacts_bulk_add_to_group": "グループに追加…",
"schedule_send": "送信を予約…"
},
"notification_center": {
"title": "通知",
"mark_all_read": "すべて既読にする",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "계정",
"switch_to": "{account}(으)로 전환"
},
"menuAudit": {
"offline_header": "오프라인 상태입니다. 일부 작업에는 연결이 필요합니다.",
"files_new_folder": "새 폴더…",
"files_new_file": "새 파일…",
"files_new_text_file": "새 텍스트 파일…",
"files_upload_files": "파일 업로드…",
"files_upload_folder": "폴더 업로드…",
"files_share": "공유…",
"files_share_button": "공유",
"contacts_add_to_group": "그룹에 추가…",
"contacts_bulk_add_to_group": "그룹에 추가…",
"schedule_send": "보내기 예약…"
},
"notification_center": {
"title": "알림",
"mark_all_read": "모두 읽음으로 표시",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konts",
"switch_to": "Pārslēgties uz {account}"
},
"menuAudit": {
"offline_header": "Jūs esat bezsaistē. Dažām darbībām vajag savienojumu.",
"files_new_folder": "Jauna mape…",
"files_new_file": "Jauns fails…",
"files_new_text_file": "Jauns teksta fails…",
"files_upload_files": "Augšupielādēt failus…",
"files_upload_folder": "Augšupielādēt mapi…",
"files_share": "Kopīgot…",
"files_share_button": "Kopīgot",
"contacts_add_to_group": "Pievienot grupai…",
"contacts_bulk_add_to_group": "Pievienot grupai…",
"schedule_send": "Ieplānot sūtīšanu…"
},
"notification_center": {
"title": "Paziņojumi",
"mark_all_read": "Atzīmēt visus kā lasītus",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Бүртгэл",
"switch_to": "{account} руу шилжих"
},
"menuAudit": {
"offline_header": "Та офлайн байна. Зарим үйлдэлд холболт хэрэгтэй.",
"files_new_folder": "Шинэ хавтас…",
"files_new_file": "Шинэ файл…",
"files_new_text_file": "Шинэ текст файл…",
"files_upload_files": "Файл байршуулах…",
"files_upload_folder": "Хавтас байршуулах…",
"files_share": "Хуваалцах…",
"files_share_button": "Хуваалцах",
"contacts_add_to_group": "Бүлэгт нэмэх…",
"contacts_bulk_add_to_group": "Бүлэгт нэмэх…",
"schedule_send": "Хуваарь илгээх…"
},
"notification_center": {
"title": "Мэдэгдэл",
"mark_all_read": "Бүгдийг уншсан болгох",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konto",
"switch_to": "Bytt til {account}"
},
"menuAudit": {
"offline_header": "Du er frakoblet. Noen handlinger trenger tilkobling.",
"files_new_folder": "Ny mappe…",
"files_new_file": "Ny fil…",
"files_new_text_file": "Ny tekstfil…",
"files_upload_files": "Last opp filer…",
"files_upload_folder": "Last opp mappe…",
"files_share": "Del…",
"files_share_button": "Del",
"contacts_add_to_group": "Legg til i gruppe…",
"contacts_bulk_add_to_group": "Legg til i gruppe…",
"schedule_send": "Planlegg sending…"
},
"notification_center": {
"title": "Varsler",
"mark_all_read": "Merk alle som lest",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Account",
"switch_to": "Overschakelen naar {account}"
},
"menuAudit": {
"offline_header": "U bent offline. Sommige acties hebben een verbinding nodig.",
"files_new_folder": "Nieuwe map…",
"files_new_file": "Nieuw bestand…",
"files_new_text_file": "Nieuw tekstbestand…",
"files_upload_files": "Bestanden uploaden…",
"files_upload_folder": "Map uploaden…",
"files_share": "Delen…",
"files_share_button": "Delen",
"contacts_add_to_group": "Aan groep toevoegen…",
"contacts_bulk_add_to_group": "Aan groep toevoegen…",
"schedule_send": "Verzenden plannen…"
},
"notification_center": {
"title": "Meldingen",
"mark_all_read": "Alles als gelezen markeren",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Konto",
"switch_to": "Przełącz na {account}"
},
"menuAudit": {
"offline_header": "Jesteś offline. Niektóre działania wymagają połączenia.",
"files_new_folder": "Nowy folder…",
"files_new_file": "Nowy plik…",
"files_new_text_file": "Nowy plik tekstowy…",
"files_upload_files": "Prześlij pliki…",
"files_upload_folder": "Prześlij folder…",
"files_share": "Udostępnij…",
"files_share_button": "Udostępnij",
"contacts_add_to_group": "Dodaj do grupy…",
"contacts_bulk_add_to_group": "Dodaj do grupy…",
"schedule_send": "Zaplanuj wysyłkę…"
},
"notification_center": {
"title": "Powiadomienia",
"mark_all_read": "Oznacz wszystkie jako przeczytane",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Conta",
"switch_to": "Mudar para {account}"
},
"menuAudit": {
"offline_header": "Está offline. Algumas ações precisam de ligação.",
"files_new_folder": "Nova pasta…",
"files_new_file": "Novo arquivo…",
"files_new_text_file": "Novo arquivo de texto…",
"files_upload_files": "Carregar arquivos…",
"files_upload_folder": "Carregar pasta…",
"files_share": "Compartilhar…",
"files_share_button": "Compartilhar",
"contacts_add_to_group": "Adicionar ao grupo…",
"contacts_bulk_add_to_group": "Adicionar ao grupo…",
"schedule_send": "Agendar envio…"
},
"notification_center": {
"title": "Notificações",
"mark_all_read": "Marcar todas como lidas",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Cont",
"switch_to": "Comută la {account}"
},
"menuAudit": {
"offline_header": "Sunteți offline. Unele acțiuni au nevoie de conexiune.",
"files_new_folder": "Dosar nou…",
"files_new_file": "Fișier nou…",
"files_new_text_file": "Fișier text nou…",
"files_upload_files": "Încărcați fișiere…",
"files_upload_folder": "Încărcare folder…",
"files_share": "Distribuie…",
"files_share_button": "Distribuie",
"contacts_add_to_group": "Adaugă la grup…",
"contacts_bulk_add_to_group": "Adaugă la grup…",
"schedule_send": "Programează trimiterea…"
},
"notification_center": {
"title": "Notificări",
"mark_all_read": "Marchează toate ca citite",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Аккаунт",
"switch_to": "Переключиться на {account}"
},
"menuAudit": {
"offline_header": "Нет подключения. Для некоторых действий нужна сеть.",
"files_new_folder": "Новая папка…",
"files_new_file": "Новый файл…",
"files_new_text_file": "Новый текстовый файл…",
"files_upload_files": "Загрузить файлы…",
"files_upload_folder": "Загрузить папку…",
"files_share": "Поделиться…",
"files_share_button": "Поделиться",
"contacts_add_to_group": "Добавить в группу…",
"contacts_bulk_add_to_group": "Добавить в группу…",
"schedule_send": "Запланировать отправку…"
},
"notification_center": {
"title": "Уведомления",
"mark_all_read": "Отметить все как прочитанные",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Účet",
"switch_to": "Prepnúť na {account}"
},
"menuAudit": {
"offline_header": "Ste offline. Niektoré akcie potrebujú pripojenie.",
"files_new_folder": "Nový priečinok…",
"files_new_file": "Nový súbor…",
"files_new_text_file": "Nový textový súbor…",
"files_upload_files": "Nahrať súbory…",
"files_upload_folder": "Nahrať priečinok…",
"files_share": "Zdieľať…",
"files_share_button": "Zdieľať",
"contacts_add_to_group": "Pridať do skupiny…",
"contacts_bulk_add_to_group": "Pridať do skupiny…",
"schedule_send": "Naplánovať odoslanie…"
},
"notification_center": {
"title": "Oznámenia",
"mark_all_read": "Označiť všetko ako prečítané",
+13
View File
@@ -98,6 +98,19 @@
"group_account": "บัญชี",
"switch_to": "สลับไปที่ {account}"
},
"menuAudit": {
"offline_header": "คุณออฟไลน์อยู่ บางการทำงานต้องใช้การเชื่อมต่อ",
"files_new_folder": "โฟลเดอร์ใหม่…",
"files_new_file": "ไฟล์ใหม่…",
"files_new_text_file": "ไฟล์ข้อความใหม่…",
"files_upload_files": "อัปโหลดไฟล์…",
"files_upload_folder": "อัปโหลดโฟลเดอร์…",
"files_share": "แชร์…",
"files_share_button": "แชร์",
"contacts_add_to_group": "เพิ่มในกลุ่ม…",
"contacts_bulk_add_to_group": "เพิ่มในกลุ่ม…",
"schedule_send": "กำหนดเวลาส่ง…"
},
"notification_center": {
"title": "การแจ้งเตือน",
"mark_all_read": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Hesap",
"switch_to": "{account} hesabına geç"
},
"menuAudit": {
"offline_header": "Çevrimdışısınız. Bazı işlemler bağlantı gerektirir.",
"files_new_folder": "Yeni Klasör…",
"files_new_file": "Yeni Dosya…",
"files_new_text_file": "Yeni Metin Dosyası…",
"files_upload_files": "Dosya Yükle…",
"files_upload_folder": "Klasör Yükle…",
"files_share": "Paylaş…",
"files_share_button": "Paylaş",
"contacts_add_to_group": "Gruba ekle…",
"contacts_bulk_add_to_group": "Gruba ekle…",
"schedule_send": "Göndermeyi zamanla…"
},
"notification_center": {
"title": "Bildirimler",
"mark_all_read": "Tümünü okundu işaretle",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "Обліковий запис",
"switch_to": "Перейти до {account}"
},
"menuAudit": {
"offline_header": "Немає з’єднання. Для деяких дій потрібна мережа.",
"files_new_folder": "Нова папка…",
"files_new_file": "Новий файл…",
"files_new_text_file": "Новий текстовий файл…",
"files_upload_files": "Завантажити файли…",
"files_upload_folder": "Завантажити папку…",
"files_share": "Поділитися…",
"files_share_button": "Поділитися",
"contacts_add_to_group": "Додати до групи…",
"contacts_bulk_add_to_group": "Додати до групи…",
"schedule_send": "Запланувати надсилання…"
},
"notification_center": {
"title": "Сповіщення",
"mark_all_read": "Позначити всі як прочитані",
+13
View File
@@ -6474,6 +6474,19 @@
"group_account": "Tài khoản",
"switch_to": "Chuyển sang {account}"
},
"menuAudit": {
"offline_header": "Bạn đang ngoại tuyến. Một số thao tác cần kết nối.",
"files_new_folder": "Thư mục mới…",
"files_new_file": "Tệp mới…",
"files_new_text_file": "Tệp văn bản mới…",
"files_upload_files": "Tải tệp lên…",
"files_upload_folder": "Tải lên thư mục…",
"files_share": "Chia sẻ…",
"files_share_button": "Chia sẻ",
"contacts_add_to_group": "Thêm vào nhóm…",
"contacts_bulk_add_to_group": "Thêm vào nhóm…",
"schedule_send": "Lên lịch gửi…"
},
"notification_center": {
"title": "Thông báo",
"mark_all_read": "Đánh dấu tất cả đã đọc",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "帳戶",
"switch_to": "切換到 {account}"
},
"menuAudit": {
"offline_header": "您已離線。部分操作需要連線。",
"files_new_folder": "新增資料夾…",
"files_new_file": "新增檔案…",
"files_new_text_file": "新增文字檔案…",
"files_upload_files": "上傳檔案…",
"files_upload_folder": "上傳資料夾…",
"files_share": "分享…",
"files_share_button": "分享",
"contacts_add_to_group": "新增至群組…",
"contacts_bulk_add_to_group": "新增至群組…",
"schedule_send": "排程寄送…"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部標示為已讀",
+13
View File
@@ -6462,6 +6462,19 @@
"group_account": "账户",
"switch_to": "切换到 {account}"
},
"menuAudit": {
"offline_header": "您已离线。部分操作需要联网。",
"files_new_folder": "新建文件夹…",
"files_new_file": "新建文件…",
"files_new_text_file": "新建文本文件…",
"files_upload_files": "上传文件…",
"files_upload_folder": "上传文件夹…",
"files_share": "共享…",
"files_share_button": "共享",
"contacts_add_to_group": "加入群组…",
"contacts_bulk_add_to_group": "添加到群组…",
"schedule_send": "计划发送…"
},
"notification_center": {
"title": "通知",
"mark_all_read": "全部标为已读",