diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 2bd4fae1e..927e2b174 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -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(); + 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(); + 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(); diff --git a/components/command-palette/__tests__/mail-palette-commands.test.ts b/components/command-palette/__tests__/mail-palette-commands.test.ts new file mode 100644 index 000000000..247954e94 --- /dev/null +++ b/components/command-palette/__tests__/mail-palette-commands.test.ts @@ -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}'`); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 058d4a448..8169769bd 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -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(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 && ( {command.source} )} - {command.shortcut && ( + {command.shortcut && hintKeys(command.shortcut).length > 0 && ( // Key hints only where there is a keyboard (R8). diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index cd153f9e8..802c8f994 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -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]); diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index 9b6eea288..6281846ca 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -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({ onBatchAddToGroup?.())} disabled={!onBatchAddToGroup} /> @@ -179,12 +183,12 @@ export function ContactContextMenu({ onClick={handle(handleCall)} /> )} - {onEdit && } + {onEdit && !readOnly && } {!inDetail && } {onAddToGroup && ( )} @@ -195,7 +199,7 @@ export function ContactContextMenu({ onClick={handle(onDuplicate)} /> )} - {onMoveTo && ( + {onMoveTo && !readOnly && ( )} {email && ( @@ -225,13 +229,18 @@ export function ContactContextMenu({ onClick={handle(handlePrint)} /> )} - - + {!readOnly && ( + <> + + + + )} ); } diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 77edb2cdc..66a3969e2 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -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 phone's row has no room for this label: the ⋮ fell onto a line of its own. */} - {isMobile ? ( + {readOnly ? null : isMobile ? ( @@ -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} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 6a11b7031..c3eef9c7a 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -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} /> )} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index a28c84ea1..83e2619d7 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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 = ) : ( { 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', () => { diff --git a/components/email/__tests__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx new file mode 100644 index 000000000..cc3c8f8f5 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx @@ -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> = {}) { + render( + , + ); +} + +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); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 5c8626586..0826f1069 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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({ {composerClient?.hasDelayedSend() && ( - + )} ([ + "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({ 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 && ( - + {renderMoveList(onBatchMoveToMailbox)} )} @@ -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 && ( - + {copyTargetsContent} )} @@ -408,14 +427,14 @@ export function EmailContextMenu({ const rulesItem = ( <> {/* Rules: filter rules made from the sender(s), saved as Sieve */} - + ); const tagItem = ( <> {/* Set tag submenu - the whole selection's tags (#1077) */} {onBatchToggleTag && ( - +
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 ( - + {renderMoveList(onMoveToMailbox)} ); case "copy_to_account": return ( - + {copyTargetsContent} ); case "tag": return ( - +
onSetTag?.(tagId)} scope={tagScope} />
); case "rules": - return ; + return ; } }; @@ -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 && {tMenu("offline_header")}} {showBatchActions ? ( <> diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts new file mode 100644 index 000000000..944488657 --- /dev/null +++ b/components/email/message-action-shortcuts.ts @@ -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> = { + 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]); +} diff --git a/components/email/rules-menu.tsx b/components/email/rules-menu.tsx index 9bf3075e9..1c27b8b0f 100644 --- a/components/email/rules-menu.tsx +++ b/components/email/rules-menu.tsx @@ -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 && } @@ -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 ( - + ); diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index 14a53e064..a035a9368 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -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(); 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'); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 5338bf98b..8d7c6ca00 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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)) && } {canShare(resource) && ( - { closeMenu(); setShareTargetId(resource.id); }} /> + { closeMenu(); setShareTargetId(resource.id); }} /> )} {resource.isDirectory && !resource.isShared && ( - { closeMenu(); fileInputRef.current?.click(); }} /> - { closeMenu(); folderInputRef.current?.click(); }} /> + { closeMenu(); fileInputRef.current?.click(); }} /> + { closeMenu(); folderInputRef.current?.click(); }} /> - { closeMenu(); setShowNewFolder(true); }} /> + { closeMenu(); setShowNewFolder(true); }} /> { 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 }, diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx index 34facf37d..57c2e135d 100644 --- a/components/files/file-details-panel.tsx +++ b/components/files/file-details-panel.tsx @@ -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 && ( )} {resource.isDirectory && !resource.isShared && ( diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 11a26656c..972e8cfad 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -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 = ; return ( @@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, handlers.onDownload?.(resource)} /> )} {!touch && handlers.canShare(resource) && ( - handlers.onShare(resource)} /> + handlers.onShare(resource)} /> )} (); const overflowMenu = useContextMenu(); const [searchOpen, setSearchOpen] = useState(false); @@ -159,7 +160,7 @@ export function FilesHeader({ )} {selection.canShare && ( - + )} {selection.canRename && ( diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index 58dc0b629..d47ea17a5 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -125,6 +125,7 @@ export function FilesSidebar({ onDropToTrash, }: FilesSidebarProps) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const newMenu = useContextMenu(); const itemMenu = useContextMenu(); const newButtonRef = useRef(null); @@ -274,10 +275,10 @@ export function FilesSidebar({ const createItems = (path: string, id: string | null) => ( <> - run(() => onCreateIn(path, id, "folder"))} /> - run(() => onCreateIn(path, id, "text"))} /> - run(() => onCreateIn(path, id, "files"))} /> - run(() => onCreateIn(path, id, "dir"))} /> + run(() => onCreateIn(path, id, "folder"))} /> + run(() => onCreateIn(path, id, "text"))} /> + run(() => onCreateIn(path, id, "files"))} /> + run(() => onCreateIn(path, id, "dir"))} /> ); @@ -455,11 +456,11 @@ export function FilesSidebar({ - { newMenu.closeContextMenu(); onUploadFiles(); }} /> - { newMenu.closeContextMenu(); onUploadFolder(); }} /> + { newMenu.closeContextMenu(); onUploadFiles(); }} /> + { newMenu.closeContextMenu(); onUploadFolder(); }} /> - { newMenu.closeContextMenu(); onNewFolder(); }} /> - { newMenu.closeContextMenu(); onNewTextFile(); }} /> + { newMenu.closeContextMenu(); onNewFolder(); }} /> + { newMenu.closeContextMenu(); onNewTextFile(); }} /> @@ -484,7 +485,7 @@ export function FilesSidebar({ Details act on the list and are left out here (R6). */} {(!folder.isShared || canShare(folder)) && } {canShare(folder) && ( - run(() => onShare(folder))} /> + run(() => onShare(folder))} /> )} {!folder.isShared && ( ({ 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( + , + ); +} + +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); + }); +}); diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 827d1d592..0e2f7d40d 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -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(); + 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 ? {tMenu("offline_header")} : null; const handleAction = (action: () => void) => { action(); @@ -198,13 +218,14 @@ export function MailboxContextMenu({ if (target.kind === "folders-section") { return ( + {offlineHeader} {!target.accountId && ( <> handleAction(onMarkAllFoldersRead!)} - disabled={!onMarkAllFoldersRead} + disabled={!onMarkAllFoldersRead || !canChangeMail} /> @@ -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" /> handleAction(onRefresh!)} - disabled={!onRefresh} + disabled={!onRefresh || offline} /> {accountItems(target.accountId)} @@ -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({ {renderShortenedPath(fullPath)} + {offlineHeader} handleAction(() => onMarkFolderRead?.(mailbox.id))} - disabled={!onMarkFolderRead || !canSetSeen} + disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread} /> {target.hasChildren && ( 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" /> 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" /> )} - + {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 && ( 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 /> handleAction(() => onDeleteFolder?.(mailbox.id))} - disabled={!onDeleteFolder || !canDelete} + disabled={!onDeleteFolder || !canDelete || offline} destructive testId="mailbox-delete" /> diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 707c92238..05401ea67 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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(); }} /> 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} /> diff --git a/hooks/use-connection-state.ts b/hooks/use-connection-state.ts new file mode 100644 index 000000000..e870307e5 --- /dev/null +++ b/hooks/use-connection-state.ts @@ -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)) }; +} diff --git a/lib/single-key-shortcuts.ts b/lib/single-key-shortcuts.ts index e3e8477d8..26cba959c 100644 --- a/lib/single-key-shortcuts.ts +++ b/lib/single-key-shortcuts.ts @@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store"; export function isSingleKeyShortcutOff(event: Pick): 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); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index f32489a25..b81ba1ca4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "تعليم الكل كمقروء", diff --git a/locales/ca/common.json b/locales/ca/common.json index e8e4d0d9b..917175ea0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 114ab9fb5..881a38f50 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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é", diff --git a/locales/da/common.json b/locales/da/common.json index a816ad784..39ae5e10d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 62da07ef4..cc4b019fb 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 403e15a98..d2b044706 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 17d23c4db..eba6ce6e1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 1f080febe..d08185398 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index ecbf78ac8..85ed455d3 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "علامت‌گذاری همه به‌عنوان خوانده‌شده", diff --git a/locales/fr/common.json b/locales/fr/common.json index e4c89a90f..6a5a5cee4 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index c793f2b7a..21d81edcf 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "סימון הכול כנקרא", diff --git a/locales/hu/common.json b/locales/hu/common.json index 8a9b57254..3526a9afa 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 63c28cc7d..3f09b59ec 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index d681766ef..72edc2761 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "すべて既読にする", diff --git a/locales/ko/common.json b/locales/ko/common.json index 89997f2f0..775759c04 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "모두 읽음으로 표시", diff --git a/locales/lv/common.json b/locales/lv/common.json index b3ed224a0..6664aab78 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index cdc06b0de..01ea5044a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Бүгдийг уншсан болгох", diff --git a/locales/nb/common.json b/locales/nb/common.json index d7f1f1c85..f769e595e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 43b52c8db..e1ebdcbea 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index c40de7f92..1cea8b667 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0f8a15ab4..d1872a431 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index d5114588b..12536002c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index cc8057645..8b37f2f0e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Отметить все как прочитанные", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9fe6333b8..1fde3ccd4 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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é", diff --git a/locales/th/common.json b/locales/th/common.json index c110d0c1f..58932e6b6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด", diff --git a/locales/tr/common.json b/locales/tr/common.json index f1492c2d2..9644ce52d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6d09ef802..43ce952bb 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Позначити всі як прочитані", diff --git a/locales/vi/common.json b/locales/vi/common.json index c1d349184..fe7b5db0a 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 90b9cacb8..8939c13cf 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "全部標示為已讀", diff --git a/locales/zh/common.json b/locales/zh/common.json index e3ab56df1..2f0879af8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "全部标为已读",