mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
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:
@@ -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}'`);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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]);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": "تعليم الكل كمقروء",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "علامتگذاری همه بهعنوان خواندهشده",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "סימון הכול כנקרא",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "すべて既読にする",
|
||||
|
||||
@@ -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": "모두 읽음으로 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Бүгдийг уншсан болгох",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отметить все как прочитанные",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Позначити всі як прочитані",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "全部標示為已讀",
|
||||
|
||||
@@ -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": "全部标为已读",
|
||||
|
||||
Reference in New Issue
Block a user