mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: grey out folder and message actions that can't work offline or with nothing unread
This commit is contained in:
@@ -58,10 +58,10 @@ describe('message menu key hints', () => {
|
||||
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('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_read')).getByText('Shift + I')).toBeInTheDocument();
|
||||
expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
|
||||
});
|
||||
@@ -77,13 +77,38 @@ describe('message menu key hints', () => {
|
||||
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-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('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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/icons";
|
||||
import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
|
||||
import { useMessageActionShortcut } from "./message-action-shortcuts";
|
||||
import { useConnectionState } from "@/hooks/use-connection-state";
|
||||
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
@@ -142,6 +143,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,
|
||||
@@ -200,6 +207,11 @@ export function EmailContextMenu({
|
||||
}: 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 tSidebar = useTranslations("sidebar");
|
||||
@@ -360,7 +372,7 @@ export function EmailContextMenu({
|
||||
label={t("archive")}
|
||||
shortcut={shortcutOf("archive")}
|
||||
onClick={() => handleAction(onBatchArchive!)}
|
||||
disabled={!onBatchArchive}
|
||||
disabled={!onBatchArchive || !canChangeMail}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -373,7 +385,7 @@ export function EmailContextMenu({
|
||||
testId="ctx-delete"
|
||||
shortcut={shortcutOf("delete")}
|
||||
onClick={() => handleAction(onBatchDelete!)}
|
||||
disabled={!onBatchDelete}
|
||||
disabled={!onBatchDelete || !canChangeMail}
|
||||
destructive={!mountainView}
|
||||
/>
|
||||
</>
|
||||
@@ -386,6 +398,7 @@ export function EmailContextMenu({
|
||||
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))}
|
||||
/>
|
||||
</>
|
||||
@@ -394,7 +407,7 @@ export function EmailContextMenu({
|
||||
<>
|
||||
{/* Move to submenu */}
|
||||
{moveTree.length > 0 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to" disabled={!canChangeMail}>
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
@@ -403,6 +416,7 @@ export function EmailContextMenu({
|
||||
icon={FolderX}
|
||||
label={t("remove_from_folder")}
|
||||
testId="ctx-remove-from-folder"
|
||||
disabled={!canChangeMail}
|
||||
onClick={() => handleAction(onBatchRemoveFromFolder!)}
|
||||
/>
|
||||
)}
|
||||
@@ -422,7 +436,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>
|
||||
)}
|
||||
@@ -431,14 +445,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 ?? []}
|
||||
@@ -464,7 +478,7 @@ export function EmailContextMenu({
|
||||
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 && (
|
||||
@@ -483,26 +497,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}>
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))}
|
||||
</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} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,7 +529,7 @@ 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)}
|
||||
@@ -531,6 +545,7 @@ export function EmailContextMenu({
|
||||
onClose={onClose}
|
||||
label={label}
|
||||
>
|
||||
{offline && <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader>}
|
||||
{showBatchActions ? (
|
||||
<>
|
||||
<ContextMenuHeader>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { MessageActionKey } from "./message-actions";
|
||||
*/
|
||||
const KEYS: Partial<Record<MessageActionKey, { key: string; single: boolean }>> = {
|
||||
reply: { key: "R", single: true },
|
||||
reply_all: { key: "Shift+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 },
|
||||
@@ -25,7 +25,7 @@ export function messageActionShortcut(
|
||||
options: { unread?: boolean; singleKeys: boolean },
|
||||
): string | undefined {
|
||||
const entry = key === "read"
|
||||
? { key: options.unread ? "Shift+I" : "U", single: true }
|
||||
? { key: options.unread ? "Shift + I" : "U", single: true }
|
||||
: KEYS[key];
|
||||
if (!entry) return undefined;
|
||||
if (entry.single && !options.singleKeys) return undefined;
|
||||
|
||||
@@ -407,10 +407,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(
|
||||
@@ -424,7 +427,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} />}
|
||||
@@ -801,7 +804,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(
|
||||
@@ -810,7 +813,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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -5541,7 +5543,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}
|
||||
@@ -5581,7 +5583,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()}
|
||||
@@ -5651,7 +5653,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)) };
|
||||
}
|
||||
@@ -6455,6 +6455,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": "تعليم الكل كمقروء",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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é",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "علامتگذاری همه بهعنوان خواندهشده",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6456,6 +6456,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": "סימון הכול כנקרא",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "すべて既読にする",
|
||||
|
||||
@@ -6455,6 +6455,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": "모두 읽음으로 표시",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "Бүгдийг уншсан болгох",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "Отметить все как прочитанные",
|
||||
|
||||
@@ -6455,6 +6455,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": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด",
|
||||
|
||||
@@ -6455,6 +6455,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "Позначити всі як прочитані",
|
||||
|
||||
@@ -6467,6 +6467,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",
|
||||
|
||||
@@ -6455,6 +6455,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": "全部標示為已讀",
|
||||
|
||||
@@ -6455,6 +6455,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