feat: edit attachments in their own program from the desktop app and take the saved file back into mail

This commit is contained in:
Linus Rath
2026-11-03 23:37:40 +01:00
parent 54bda3ef3d
commit e74248b37b
50 changed files with 2659 additions and 0 deletions
+2
View File
@@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host";
import { DeviceWipeHost } from "@/components/offline/device-wipe-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
import { EditedFilesHost } from "@/components/desktop/edited-files-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -92,6 +93,7 @@ export default async function LocaleLayout({
<ImpersonationReconciler />
<PushBindingsHost />
<DesktopOsHost />
{!IS_LITE && <EditedFilesHost />}
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
@@ -0,0 +1,221 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const policy = vi.hoisted(() => ({ decided: true, allowed: true }));
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
const openMailLink = vi.hoisted(() => vi.fn());
const handOffDraft = vi.hoisted(() => vi.fn());
const openDraftWindow = vi.hoisted(() => vi.fn());
const saveBlobAs = vi.hoisted(() => vi.fn(() => true));
const signOutGates = vi.hoisted(() => [] as ((account: string) => Promise<boolean>)[]);
const accounts = vi.hoisted(() => ({ list: [{ id: 'acct', cookieSlot: 2 }] as { id: string; cookieSlot: number }[], active: 'acct' as string | null }));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('@/lib/device-policy', () => ({ canDownload: () => policy.allowed, canExport: () => true }));
vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => policy.decided }));
vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink }));
vi.mock('@/lib/launch-handoff', () => ({ handOffDraft }));
vi.mock('@/lib/popout/open', () => ({ openDraftWindow }));
vi.mock('@/lib/download-guard', () => ({ saveBlobAs }));
vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } }));
vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } }));
vi.mock('@/lib/sign-out-cleanup', () => ({
addSignOutGate: (gate: (account: string) => Promise<boolean>) => {
signOutGates.push(gate);
return () => {};
},
onBeforeSignOut: () => () => {},
onFullSignOut: () => {},
}));
vi.mock('@/stores/account-store', () => {
const useAccountStore = (select: (s: { accounts: typeof accounts.list }) => unknown) => select({ accounts: accounts.list });
useAccountStore.getState = () => ({ accounts: accounts.list });
return { useAccountStore };
});
vi.mock('@/stores/auth-store', () => {
const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: accounts.active });
return { useAuthStore };
});
import { EditedFilesHost } from '../edited-files-host';
import { resetEditSessions, takeFilesForReply, useEditSessions } from '@/lib/desktop/edit-attachments';
const session = (over: Record<string, unknown> = {}) => ({
id: 's1', name: 'a.docx', type: 'x/y', account: 'acct', source: '{"kind":"mail","emailId":"e1","ownerAccountId":"owner"}', changed: true, version: 1, claimed: false, ...over,
});
let bridge: { edit: Record<string, ReturnType<typeof vi.fn>>; dialogs: { confirm: ReturnType<typeof vi.fn> }; os: { notify: ReturnType<typeof vi.fn> } };
function show(...sessions: ReturnType<typeof session>[]) {
bridge.edit.list.mockResolvedValue(sessions);
useEditSessions.setState({ sessions });
return render(<EditedFilesHost />);
}
beforeEach(() => {
policy.decided = true;
policy.allowed = true;
accounts.list = [{ id: 'acct', cookieSlot: 2 }];
accounts.active = 'acct';
signOutGates.length = 0;
vi.clearAllMocks();
saveBlobAs.mockReturnValue(true);
bridge = {
edit: {
canEdit: vi.fn(() => true),
begin: vi.fn(),
open: vi.fn(),
reopen: vi.fn(),
list: vi.fn(async () => []),
read: vi.fn(async () => ({ ok: true, name: 'a.docx', type: 'x/y', data: new Uint8Array([1]), hash: 'h' })),
taken: vi.fn(async () => {}),
discard: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
setSource: vi.fn(),
claim: vi.fn(),
onChange: vi.fn(() => () => {}),
},
dialogs: { confirm: vi.fn(async () => 0) },
os: { notify: vi.fn(async () => ({ shown: true, playSound: false })) },
};
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, ...bridge };
resetEditSessions();
});
afterEach(() => {
cleanup();
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('which offers show', () => {
it('a saved file nobody holds', () => {
show(session());
expect(screen.getByTestId('edited-file')).toBeTruthy();
});
it('nothing for an unchanged file or one a composer holds', () => {
show(session({ changed: false }), session({ id: 's2', claimed: true }));
expect(screen.queryByTestId('edited-file')).toBeNull();
});
it('nothing for an account that is not signed in here', () => {
show(session({ account: 'someone-else' }));
expect(screen.queryByTestId('edited-file')).toBeNull();
expect(bridge.os.notify).not.toHaveBeenCalled();
});
it('nothing in a browser', () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
useEditSessions.setState({ sessions: [session()] });
render(<EditedFilesHost />);
expect(screen.queryByTestId('edited-file')).toBeNull();
});
it('says it once per file on the OS, whatever the number of saves', async () => {
const { rerender } = show(session());
await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledTimes(1));
act(() => useEditSessions.setState({ sessions: [session({ version: 2 })] }));
rerender(<EditedFilesHost />);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(bridge.os.notify).toHaveBeenCalledTimes(1);
});
});
describe('a message\'s attachment', () => {
it('attaches to a reply on that message, with that login', async () => {
show(session());
fireEvent.click(screen.getByText('reply'));
await waitFor(() => expect(openMailLink).toHaveBeenCalled());
const [link, options] = openMailLink.mock.calls[0];
expect(link).toEqual({ kind: 'message', id: 'e1', slot: 2, ownerAccountId: 'owner', reply: true });
expect(options).toEqual({ replyText: '' });
const parked = takeFilesForReply('e1');
expect(parked.map((f) => f.name)).toEqual(['a.docx']);
// Only the composer's having it makes it taken.
expect(bridge.edit.taken).not.toHaveBeenCalled();
});
it('saves a copy through the guard, and only a saved copy counts as taken', async () => {
show(session());
fireEvent.click(screen.getByText('save_copy'));
await waitFor(() => expect(saveBlobAs).toHaveBeenCalled());
expect(saveBlobAs.mock.calls[0]).toEqual([expect.any(File), 'a.docx', { kind: 'attachment', subject: 'acct' }]);
expect(bridge.edit.taken).toHaveBeenCalledWith('s1', 'h');
});
it('a save the guard refuses stays an offer', async () => {
saveBlobAs.mockReturnValue(false);
show(session());
fireEvent.click(screen.getByText('save_copy'));
await waitFor(() => expect(saveBlobAs).toHaveBeenCalled());
expect(bridge.edit.taken).not.toHaveBeenCalled();
});
});
describe('other sources', () => {
it('a closed draft opens in its window, where its chip offers the update', () => {
show(session({ source: '{"kind":"draft","draftId":"d1","ownerAccountId":"owner"}' }));
expect(screen.queryByText('reply')).toBeNull();
fireEvent.click(screen.getByText('open_draft'));
expect(openDraftWindow).toHaveBeenCalledWith({ draftId: 'd1', localAccountId: 'acct', ownerAccountId: 'owner' });
});
it('a file whose message went can start a new one', async () => {
show(session({ source: '{"kind":"file"}' }));
expect(screen.queryByText('reply')).toBeNull();
expect(screen.queryByText('open_draft')).toBeNull();
fireEvent.click(screen.getByText('new_message'));
await waitFor(() => expect(handOffDraft).toHaveBeenCalled());
expect(handOffDraft.mock.calls[0][0].files[0].name).toBe('a.docx');
});
});
describe('discarding', () => {
it('asks first, with keeping as the default', async () => {
bridge.dialogs.confirm.mockResolvedValueOnce(1);
show(session());
fireEvent.click(screen.getByRole('button', { name: 'discard a.docx' }));
await waitFor(() => expect(bridge.dialogs.confirm).toHaveBeenCalled());
expect(bridge.dialogs.confirm.mock.calls[0][0]).toMatchObject({ defaultId: 1 });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(bridge.edit.discard).not.toHaveBeenCalled();
bridge.dialogs.confirm.mockResolvedValueOnce(0);
fireEvent.click(screen.getByRole('button', { name: 'discard a.docx' }));
await waitFor(() => expect(bridge.edit.discard).toHaveBeenCalledWith('s1'));
});
});
describe('the organisation', () => {
it('saying no ends a session and its copy, and says so', async () => {
policy.allowed = false;
show(session());
await waitFor(() => expect(bridge.edit.discard).toHaveBeenCalledWith('s1'));
expect(toast.error).toHaveBeenCalledWith('removed_policy');
});
it('not having said yet deletes nothing, and reads nothing either', async () => {
policy.decided = false;
show(session());
fireEvent.click(screen.getByText('save_copy'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('blocked_policy'));
expect(bridge.edit.discard).not.toHaveBeenCalled();
expect(bridge.edit.read).not.toHaveBeenCalled();
});
});
describe('signing out', () => {
it('asks only with edits nobody took, in the account going', async () => {
show(session({ account: 'acct' }), session({ id: 's2', account: 'other', changed: false }));
expect(signOutGates).toHaveLength(1);
bridge.dialogs.confirm.mockResolvedValueOnce(1);
await expect(signOutGates[0]('acct')).resolves.toBe(false);
bridge.dialogs.confirm.mockResolvedValueOnce(0);
await expect(signOutGates[0]('acct')).resolves.toBe(true);
bridge.dialogs.confirm.mockClear();
await expect(signOutGates[0]('other')).resolves.toBe(true);
expect(bridge.dialogs.confirm).not.toHaveBeenCalled();
});
});
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { FilePen, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments";
import { openMailLink } from "@/lib/desktop/mail-links";
import type { DesktopEditSession } from "@/lib/desktop/types";
import { saveBlobAs } from "@/lib/download-guard";
import { handOffDraft } from "@/lib/launch-handoff";
import { useAccountSubjects } from "@/lib/org/account-subjects";
import { openDraftWindow } from "@/lib/popout/open";
import { addSignOutGate } from "@/lib/sign-out-cleanup";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
function useSignedInAccounts(): Set<string> {
const accounts = useAccountStore((s) => s.accounts);
const active = useAuthStore((s) => s.activeAccountId);
return useMemo(() => new Set([...accounts.map((a) => a.id), ...(active ? [active] : [])]), [accounts, active]);
}
/**
* Attachments opened with Edit and saved in their program, with what can be done with them.
* A composer offers its own files on the chip, so this has what no composer holds. It stays
* until the user has decided: a save may come at any time, also while the app was closed.
* Only for accounts signed in here: someone else signing in doesn't see these.
* Main window only, and nothing at all in a browser.
*/
export function EditedFilesHost() {
const t = useTranslations("desktopEdit");
const router = useRouter();
const tRef = useRef(t);
tRef.current = t;
const sessions = useEditSessions((s) => s.sessions);
const accounts = useSignedInAccounts();
const [busy, setBusy] = useState<string | null>(null);
const available = !!getEditBridge();
useEffect(() => {
if (available) followEditSessions();
}, [available]);
const offers = useMemo(() => sessions.filter((s) => s.changed && !s.claimed && accounts.has(s.account)), [sessions, accounts]);
// Whoever is in the other program sees this. Once per file, not at every save.
const toldRef = useRef(new Set<string>());
useEffect(() => {
const os = getDesktopBridge()?.os;
for (const session of offers) {
if (toldRef.current.has(session.id)) continue;
toldRef.current.add(session.id);
void os?.notify({
id: `edit-${session.id}`,
title: tRef.current("changed_title", { name: session.name }),
body: tRef.current("changed_body"),
context: JSON.stringify({ editedFile: true }),
}).catch(() => {});
}
}, [offers]);
// The organisation's no ends a session, also one with a file open and edits in it.
useEffect(() => {
const bridge = getEditBridge();
if (!bridge) return;
const enforce = () => {
for (const session of useEditSessions.getState().sessions) {
if (!editForbidden(session.account)) continue;
void bridge.discard(session.id);
toast.error(tRef.current("removed_policy", { name: session.name }));
}
};
enforce();
const stops = [usePolicyStore.subscribe(enforce), useAccountSubjects.subscribe(enforce), useEditSessions.subscribe(enforce)];
return () => stops.forEach((stop) => stop());
}, []);
// Edits nobody took: asks before a sign-out the user started. A wipe doesn't.
useEffect(() => {
if (!getEditBridge()) return;
return addSignOutGate(async (accountId) => {
const waiting = useEditSessions.getState().sessions.filter((s) => s.account === accountId && s.changed);
const dialogs = getDesktopBridge()?.dialogs;
if (waiting.length === 0 || !dialogs) return true;
const buttons = [tRef.current("signout_confirm"), tRef.current("cancel")];
const choice = await dialogs.confirm({
type: "warning",
message: tRef.current("signout_title"),
detail: tRef.current("signout_detail"),
buttons,
defaultId: 1,
});
return choice === 0;
});
}, []);
if (!available || offers.length === 0) return null;
const withFile = async (session: DesktopEditSession, then: (file: File, hash: string) => void) => {
setBusy(session.id);
try {
const outcome = await readEditedFile(session);
if (outcome.ok) then(outcome.file, outcome.hash);
else if (outcome.reason === "policy") toast.error(t("blocked_policy"));
else if (outcome.reason === "busy") toast.error(t("read_busy", { name: session.name }));
else if (outcome.reason !== "locked") toast.error(t("read_failed", { name: session.name }));
} catch (error) {
console.error("Could not read an edited file:", error);
toast.error(t("read_failed", { name: session.name }));
} finally {
setBusy(null);
}
};
const attachToReply = (session: DesktopEditSession, emailId: string, ownerAccountId: string | undefined) => withFile(session, (file) => {
parkFilesForReply(emailId, [file]);
const slot = useAccountStore.getState().accounts.find((a) => a.id === session.account)?.cookieSlot;
openMailLink(
{ kind: "message", id: emailId, ...(slot !== undefined ? { slot } : {}), ownerAccountId, reply: true },
{ replyText: "" },
(path) => router.push(path),
);
});
const saveCopy = (session: DesktopEditSession) => withFile(session, (file, hash) => {
if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash);
});
const discard = async (session: DesktopEditSession) => {
const bridge = getDesktopBridge();
const choice = await bridge?.dialogs?.confirm({
type: "warning",
message: t("discard_title", { name: session.name }),
detail: t("discard_detail"),
buttons: [t("discard_confirm"), t("cancel")],
defaultId: 1,
});
if (choice === 0) await getEditBridge()?.discard(session.id);
};
return (
<section
aria-label={t("offers_label")}
data-testid="edited-files"
className="fixed bottom-4 end-4 z-50 flex max-h-[60vh] w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2 overflow-y-auto"
>
{offers.map((session) => {
const source = parseEditSource(session.source);
return (
<div key={session.id} data-testid="edited-file" className="flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg">
<div className="flex items-start gap-2">
<FilePen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="break-words text-sm font-medium text-foreground" dir="auto">{t("changed_title", { name: session.name })}</p>
<p className="text-xs text-muted-foreground">{t("changed_body")}</p>
</div>
<button
type="button"
onClick={() => void discard(session)}
title={t("discard")}
aria-label={`${t("discard")} ${session.name}`}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{source.kind === "mail" && (
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToReply(session, source.emailId, source.ownerAccountId)}>{t("reply")}</Button>
)}
{source.kind === "draft" && source.draftId && (
<Button size="sm" onClick={() => openDraftWindow({ draftId: source.draftId as string, localAccountId: session.account, ownerAccountId: source.ownerAccountId })}>{t("open_draft")}</Button>
)}
{(source.kind === "file" || (source.kind === "draft" && !source.draftId)) && (
<Button size="sm" disabled={busy === session.id} onClick={() => void withFile(session, (file) => handOffDraft({ files: [file] }))}>{t("new_message")}</Button>
)}
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveCopy(session)}>{t("save_copy")}</Button>
</div>
</div>
);
})}
</section>
);
}
@@ -55,6 +55,24 @@ describe('the right-click menu in the desktop app', () => {
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
});
it('Edit shows when the file can be edited and runs the action', async () => {
const onEdit = vi.fn();
const { props, popup } = await openMenu((items) => items.find((i) => i.label === 'edit')?.id ?? null, { onEdit, canEdit: () => true });
const labels = popup.mock.calls[0][0].flatMap((i: { label?: string }) => (i.label ? [i.label] : []));
expect(labels).toEqual(['open', 'save_as', 'edit', 'copy']);
await waitFor(() => expect(onEdit).toHaveBeenCalledWith(props.attachments[0]));
});
it('Edit stays out for a file it cannot take', async () => {
const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => false });
expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit');
});
it('Edit stays out with downloads off, whatever else says yes', async () => {
const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => true, downloadsOff: true });
expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit');
});
it('a browser keeps its own menu', async () => {
window.bulwarkDesktop = undefined;
renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() });
+48
View File
@@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input";
import {
X,
Paperclip,
FilePen,
Send,
Save,
Check,
@@ -178,6 +179,7 @@ import { noteOpenDraft } from "@/lib/desktop/window-restore";
import type { RescuedDraft } from "@/lib/popout/channel";
import { toUnicodeEmail } from "@/lib/idn";
import { setComposerFileTarget } from "@/lib/desktop/window-files";
import { useComposerEdits } from "@/hooks/use-composer-edits";
// A Pro tab, message or folder dragged across a composer tab is on its way to
// the pane or the split edge: those drags belong to the shell. There is no shell
@@ -480,6 +482,8 @@ interface EmailComposerProps {
type ComposerAttachment = {
file?: File;
// The desktop app's Edit: which session's file this is. See useComposerEdits.
editSessionId?: string;
// Login whose server holds `blobId`, set for parts of the original message
// on a forward - the message may go out through another account, which
// cannot resolve them. See rehomeForeignBlobs.
@@ -580,6 +584,7 @@ export function EmailComposer({
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tEdit = useTranslations('desktopEdit');
const tProofreading = useTranslations('proofreading');
const tOfflineDrafts = useTranslations('offlineDrafts');
const tOffline = useTranslations('offline');
@@ -2745,6 +2750,22 @@ export function EmailComposer({
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
// The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen.
const loadAttachmentForEdit = useCallback(async (att: ComposerAttachment): Promise<Blob> => {
if (att.file) return att.file;
if (!composerClient || !att.blobId) throw new Error('No content for this attachment');
return composerClient.fetchBlob(att.blobId, att.name, att.type);
}, [composerClient]);
const composerEdits = useComposerEdits<ComposerAttachment>({
attachments,
setAttachments,
addFiles,
load: loadAttachmentForEdit,
account: composerAccountId ?? '',
draftId,
ownerAccountId: draftAccountId,
repliesTo: mode === 'reply' || mode === 'replyAll' ? sourceEmailId : undefined,
ready: !!client,
});
useEffect(() => setComposerFileTarget(
(files) => void addFiles(files),
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
@@ -3707,6 +3728,9 @@ export function EmailComposer({
} = {}) => {
if (isSendingRef.current) return;
// Saved in another program and not on this message yet: not silently the older one.
if (!(await composerEdits.confirmSend())) return;
if (attachmentsRef.current.some(att => att.uploading)) {
isSendingRef.current = true;
setIsSending(true);
@@ -4242,6 +4266,7 @@ export function EmailComposer({
}
}
composerEdits.finish();
setTo([]);
setCc([]);
setBcc([]);
@@ -4483,6 +4508,7 @@ export function EmailComposer({
attachment.abortController?.abort();
}
setShowCloseDialog(false);
composerEdits.finish();
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
@@ -5391,6 +5417,28 @@ export function EmailComposer({
) : (
<div className="flex items-center gap-2 min-w-0">{label}</div>
)}
{composerEdits.hasUpdate(att) && !att.uploading && (
<button
type="button"
onClick={() => void composerEdits.update(att)}
className="-my-0.5 whitespace-nowrap text-xs font-medium text-primary hover:underline"
data-testid="composer-edit-update"
>
{tEdit('update')}
</button>
)}
{!att.uploading && !att.error && composerEdits.canEdit(att.name) && (
<button
type="button"
onClick={() => void composerEdits.startEdit(att)}
className={cn("-my-0.5 hover:text-foreground text-muted-foreground min-w-6 min-h-6 flex items-center justify-center", TOUCH_HIT_AREA)}
title={tEdit('edit')}
aria-label={tEdit('edit_named', { name: att.name })}
data-testid="composer-edit"
>
<FilePen className="w-3 h-3" />
</button>
)}
<button
onClick={() => removeAttachment(index)}
className={cn("ms-0.5 -my-0.5 hover:text-destructive min-w-6 min-h-6 flex items-center justify-center", TOUCH_HIT_AREA)}
+17
View File
@@ -14,6 +14,7 @@ import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { ReaderViewDialog } from "./reader-view-dialog";
import { MessagePrint, type MessagePrintFields } from "./message-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
@@ -3171,6 +3172,8 @@ export function EmailViewer({
const attachmentCardsRef = useRef<HTMLDivElement>(null);
const desktopClipboard = getDesktopBridge()?.clipboard;
const tDesktopUi = useTranslations('desktopUi.attachment');
const editAccount = editAccountOf(email);
const { canEdit: canEditAttachment, edit: editAttachment } = useEditAttachment(editAccount);
const readerAttachmentProps = {
attachments: effectiveAttachments,
thumbUrls: imageThumbUrls,
@@ -3179,6 +3182,20 @@ export function EmailViewer({
onDownload: handleEffectiveAttachmentDownload,
onDownloadAll: handleDownloadAllAttachments,
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
onEdit: (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !blobClient || !email) return;
const { blobId } = attachment;
void editAttachment({
key: `mail:${editAccount}:${email.id}:${blobId}`,
name: resolveAttachmentName(attachment),
type: attachment.type,
account: editAccount,
source: { kind: 'mail', emailId: email.id, ownerAccountId: blobAccountId },
load: () => blobClient.fetchBlobArrayBuffer(blobId, undefined, undefined, blobAccountId),
});
},
// Decrypted parts have no blob: they never go on disk this way. A message marked as phishing doesn't get opened in other programs.
canEdit: (attachment: EffectiveAttachment) => !!attachment.blobId && !!blobClient && !phishingMarked && canEditAttachment(resolveAttachmentName(attachment)),
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
+12
View File
@@ -58,6 +58,12 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
* has on an attachment; a browser has its own menu there and never gets this one.
*/
onCopyFile?: (attachment: T) => void;
/**
* The desktop app's Edit: opens the file in its program. `canEdit` says no where the file
* type or the organisation's rules keep it out.
*/
onEdit?: (attachment: T) => void;
canEdit?: (attachment: T) => boolean;
/** Whether a click on the attachment previews it instead of downloading it. */
opensPreview: (attachment: T) => boolean;
onOpen: (attachment: T) => void;
@@ -106,6 +112,8 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onDownloadAll,
onSaveAllToFolder,
onCopyFile,
onEdit,
canEdit,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
@@ -118,6 +126,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const tDevice = useTranslations("device_policy");
const tWindows = useTranslations("pwaWindows");
const tDesktop = useTranslations("desktopUi.attachment");
const tEdit = useTranslations("desktopEdit");
const menu = useContextMenu<T>();
const [desktopMenu, setDesktopMenu] = useState(false);
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
@@ -351,6 +360,9 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
{!downloadsOff && (
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
)}
{!downloadsOff && onEdit && canEdit?.(menuTarget) && (
<ContextMenuItem label={tEdit("edit")} onClick={() => { menu.closeContextMenu(); onEdit(menuTarget); }} />
)}
{!downloadsOff && onCopyFile && (
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
)}
@@ -51,6 +51,8 @@ import {
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
import { FilePen } from "@/components/icons";
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store";
@@ -589,6 +591,8 @@ export function ConversationEmailBody({
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
const editAccount = source.clientAccountId ?? editAccountOf(email);
const { canEdit: canEditAttachment, edit: editAttachment } = useEditAttachment(editAccount);
// Reported as phishing: a banner, links off, no cards (as in the reader).
const phishingMarked = usePhishingMarked(email);
const tPhishing = useTranslations('phishing_report');
@@ -1072,6 +1076,28 @@ export function ConversationEmailBody({
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
{blobClient && attachment.blobId && !phishingMarked && canEditAttachment(attachment.name) && (
<button
type="button"
title={t('desktopEdit.edit')}
aria-label={t('desktopEdit.edit_named', { name: stripBidiControls(attachment.name ?? '') })}
className="-ms-1.5 self-center rounded-lg p-2 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
const { blobId } = attachment;
void editAttachment({
key: `mail:${editAccount}:${email.id}:${blobId}`,
name: attachment.name ?? 'attachment',
type: attachment.type,
account: editAccount,
source: { kind: 'mail', emailId: email.id, ownerAccountId: accountId },
load: () => blobClient.fetchBlobArrayBuffer(blobId, undefined, undefined, accountId),
});
}}
>
<FilePen className="h-4 w-4" />
</button>
)}
<AttachmentPluginMenu emailId={email.id} attachment={attachment} className="-ms-1.5 self-center p-2 rounded-lg" />
</React.Fragment>
);
+1
View File
@@ -87,6 +87,7 @@ export {
IconDatabase as Database,
IconDownload as Download,
IconEdit as EditIcon,
IconFilePencil as FilePen,
IconDots as Ellipsis,
IconEraser as Eraser,
IconExternalLink as ExternalLink,
@@ -39,6 +39,7 @@ function useLockFacts() {
/** The app lock's section on the General page: it has to exist on any server. */
export function AppLockSettings() {
const t = useTranslations("appLock");
const tEdit = useTranslations("desktopEdit");
const { appName } = useConfig();
const { on, required, timeout, update } = useLockFacts();
const [pending, setPending] = useState<Pending>(null);
@@ -178,6 +179,10 @@ export function AppLockSettings() {
</>
)}
{on && getDesktopBridge()?.edit && (
<p className="text-xs text-muted-foreground" data-testid="app-lock-edit-note">{tEdit("lock_note")}</p>
)}
<Dialog
isOpen={pending !== null}
onClose={close}
+271
View File
@@ -0,0 +1,271 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const policy = vi.hoisted(() => ({ allowed: true }));
const toastError = vi.hoisted(() => vi.fn());
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn() } }));
vi.mock('@/hooks/use-device-policy', () => ({
useDevicePolicy: () => ({ noDownloads: !policy.allowed, noExport: false }),
}));
vi.mock('@/lib/device-policy', () => ({
canDownload: () => policy.allowed,
canExport: () => true,
deviceSubjectOf: () => null,
}));
vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true }));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: 'acct' }) } }));
import { useComposerEdits, type EditableAttachment } from '../use-composer-edits';
import { parkFilesForReply, readEditedFile, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments';
function fakeBridge() {
return {
canEdit: vi.fn((name: string) => !name.endsWith('.exe')),
begin: vi.fn(async () => ({ ok: true as const, ticket: 't1' })),
open: vi.fn(async () => ({ ok: true as const, id: 's1' })),
reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })),
list: vi.fn(async () => []),
read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })),
taken: vi.fn(async () => {}),
discard: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
setSource: vi.fn(),
claim: vi.fn(),
onChange: vi.fn(() => () => {}),
};
}
let bridge: ReturnType<typeof fakeBridge>;
let confirm: ReturnType<typeof vi.fn>;
const session = (over: Partial<{ id: string; name: string; source: string; changed: boolean; claimed: boolean }> = {}) => ({
id: 's1', name: 'a.docx', type: 'x/y', account: 'acct', source: '{"kind":"draft","draftId":"d1"}', changed: false, version: 0, claimed: true, ...over,
});
interface Setup {
start: EditableAttachment[];
draftId?: string | null;
repliesTo?: string;
/** What the composer's upload does with a file. */
upload?: (file: File) => Partial<EditableAttachment>;
}
function setup({ start, draftId = 'd1', repliesTo, upload = () => ({ blobId: 'new-blob' }) }: Setup) {
const load = vi.fn(async () => new Blob(['x']));
bridge.list.mockResolvedValue(useEditSessions.getState().sessions as never);
return renderHook(() => {
const [attachments, setAttachments] = useState<EditableAttachment[]>(start);
const addFiles = async (files: File[]) => {
setAttachments((list) => [...list, ...files.map((file) => ({ file, name: file.name, type: file.type, uploading: false, ...upload(file) }))]);
};
const edits = useComposerEdits<EditableAttachment>({ attachments, setAttachments, addFiles, load, account: 'acct', draftId, repliesTo, ready: true });
return { attachments, setAttachments, edits };
});
}
beforeEach(() => {
policy.allowed = true;
bridge = fakeBridge();
confirm = vi.fn(async () => 0);
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge, dialogs: { confirm } };
resetEditSessions();
toastError.mockClear();
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('updating a chip', () => {
const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'old-blob', editSessionId: 's1' };
it('takes the old file off only once the new one is up, and marks that version as taken', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true);
await act(() => result.current.edits.update(result.current.attachments[0]));
await waitFor(() => expect(result.current.attachments).toHaveLength(1));
const [only] = result.current.attachments;
expect(only.blobId).toBe('new-blob');
expect(only.file?.name).toBe('a.docx');
await waitFor(() => expect(only.editSessionId ?? result.current.attachments[0].editSessionId).toBe('s1'));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
// Stays held by the composer throughout: it's the same session.
expect(bridge.discard).not.toHaveBeenCalled();
});
it('keeps the old file and the offer when the upload fails', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip], upload: () => ({ error: true }) });
await act(() => result.current.edits.update(result.current.attachments[0]));
await waitFor(() => expect(result.current.attachments).toHaveLength(1));
expect(result.current.attachments[0].blobId).toBe('old-blob');
expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true);
expect(bridge.taken).not.toHaveBeenCalled();
});
it('says so when the program still holds the file', async () => {
bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never);
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
await act(() => result.current.edits.update(result.current.attachments[0]));
expect(toastError).toHaveBeenCalledWith('read_busy');
expect(result.current.attachments[0].blobId).toBe('old-blob');
});
it('refuses when the organisation has said no since', async () => {
policy.allowed = false;
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
await act(() => result.current.edits.update(result.current.attachments[0]));
expect(bridge.read).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('blocked_policy');
expect(result.current.attachments).toHaveLength(1);
});
});
describe('starting from a chip', () => {
it('fetches a server part, and the chip takes the session on', async () => {
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1' }] });
await act(() => result.current.edits.startEdit(result.current.attachments[0]));
expect(bridge.begin).toHaveBeenCalledWith('blob:acct:b1');
await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1'));
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
const [, file] = bridge.open.mock.calls[0] as unknown as [string, { source: string }];
expect(JSON.parse(file.source)).toEqual({ kind: 'draft', draftId: 'd1' });
});
it('a chip with a session goes back to that session', async () => {
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1', editSessionId: 's1' }] });
await act(() => result.current.edits.startEdit(result.current.attachments[0]));
expect(bridge.begin).toHaveBeenCalledWith('session:s1');
});
it('offers it only for what the shell and the organisation allow', () => {
const { result } = setup({ start: [] });
expect(result.current.edits.canEdit('a.docx')).toBe(true);
expect(result.current.edits.canEdit('a.exe')).toBe(false);
});
it('offers nothing under a policy', () => {
policy.allowed = false;
const { result } = setup({ start: [] });
expect(result.current.edits.canEdit('a.docx')).toBe(false);
});
});
describe('holding sessions', () => {
it('claims what is on a chip, follows the draft id, and lets go when it closes, which is no end', async () => {
const { result, rerender, unmount } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' }] });
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
expect(bridge.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'draft', draftId: 'd1' }));
rerender();
expect(result.current.attachments).toHaveLength(1);
unmount();
expect(bridge.claim).toHaveBeenLastCalledWith('s1', false);
expect(bridge.discard).not.toHaveBeenCalled();
});
it('a chip taken off: an unchanged copy goes, an edited one stays for the offers', async () => {
useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] });
const { result } = setup({ start: [
{ name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' },
{ name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' },
] });
act(() => result.current.setAttachments([]));
await waitFor(() => expect(bridge.discard).toHaveBeenCalledWith('s1'));
expect(bridge.discard).not.toHaveBeenCalledWith('s2');
expect(bridge.setSource).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' }));
});
it('a draft opened again finds its session by id and name', async () => {
useEditSessions.setState({ sessions: [session({ claimed: false, changed: true })] });
const { result } = setup({ start: [{ name: 'other.pdf', type: 'x/y', blobId: 'z' }, { name: 'a.docx', type: 'x/y', blobId: 'b' }] });
await waitFor(() => expect(result.current.attachments[1].editSessionId).toBe('s1'));
expect(result.current.attachments[0].editSessionId).toBeUndefined();
expect(result.current.edits.hasUpdate(result.current.attachments[1])).toBe(true);
});
it('another draft leaves it alone', async () => {
useEditSessions.setState({ sessions: [session({ claimed: false, source: '{"kind":"draft","draftId":"other"}' })] });
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.attachments[0].editSessionId).toBeUndefined();
});
});
describe('sending', () => {
const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' };
it('goes on without a question when nothing waits', async () => {
useEditSessions.setState({ sessions: [session()] });
const { result } = setup({ start: [chip] });
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
expect(confirm).not.toHaveBeenCalled();
});
it('asks before an older version goes out, with going back as the default', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
confirm.mockResolvedValueOnce(1);
await expect(result.current.edits.confirmSend()).resolves.toBe(false);
expect(confirm.mock.calls[0][0]).toMatchObject({ buttons: ['send_stale_send', 'send_stale_back'], defaultId: 1 });
confirm.mockResolvedValueOnce(0);
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
});
it('sent or discarded: an unchanged copy goes, edits nobody took stay and lose their draft', async () => {
useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] });
const { result } = setup({ start: [chip, { name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' }] });
act(() => result.current.edits.finish());
expect(bridge.discard).toHaveBeenCalledWith('s1');
expect(bridge.discard).not.toHaveBeenCalledWith('s2');
expect(bridge.claim).toHaveBeenCalledWith('s2', false);
expect(bridge.setSource).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' }));
});
});
describe('a reply from an offer', () => {
it('takes the waiting file, and the session comes with it', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
parkFilesForReply('e1', [outcome.file]);
const { result } = setup({ start: [], repliesTo: 'e1', draftId: null });
await waitFor(() => expect(result.current.attachments.map((a) => a.name)).toEqual(['a.docx']));
await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1'));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
});
it('a version counts as taken only once it is up', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
parkFilesForReply('e1', [outcome.file]);
const { result } = setup({ start: [], repliesTo: 'e1', draftId: null, upload: () => ({ uploading: true }) });
await waitFor(() => expect(result.current.attachments[0]?.editSessionId).toBe('s1'));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(bridge.taken).not.toHaveBeenCalled();
act(() => result.current.setAttachments((list) => list.map((att) => ({ ...att, uploading: false, blobId: 'up' }))));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
});
it('a reply to another message leaves it waiting', async () => {
parkFilesForReply('e1', [new File(['x'], 'a.docx')]);
const { result } = setup({ start: [], repliesTo: 'e2', draftId: null });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.attachments).toEqual([]);
});
});
describe('in a browser', () => {
it('nothing happens at all', () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] });
expect(result.current.edits.canEdit('a.docx')).toBe(false);
expect(result.current.attachments[0].editSessionId).toBeUndefined();
expect(() => result.current.edits.finish()).not.toThrow();
});
});
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { useTranslations } from "next-intl";
import { editFileInfo, followEditSessions, getEditBridge, parseEditSource, readEditedFile, takeFilesForReply, useEditSessions, type EditSource } from "@/lib/desktop/edit-attachments";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { useEditAttachment } from "@/hooks/use-edit-attachment";
import { toast } from "@/stores/toast-store";
/** What this needs from a composer's attachment. */
export interface EditableAttachment {
file?: File;
name: string;
type: string;
blobId?: string;
uploading?: boolean;
error?: boolean;
/** The Edit session this chip's file belongs to. Kept through every state change of the chip. */
editSessionId?: string;
}
interface Options<A extends EditableAttachment> {
attachments: A[];
setAttachments: Dispatch<SetStateAction<A[]>>;
addFiles: (files: File[]) => Promise<void>;
load: (attachment: A) => Promise<Blob>;
/** The local account the draft goes through. */
account: string;
draftId: string | null;
ownerAccountId?: string;
/** The message a reply answers: a file waiting for that reply lands here. */
repliesTo?: string;
/** Only once the composer can upload. */
ready: boolean;
}
/**
* A composer's part of Edit: its chips start sessions and hold them, so the offer to update
* sits on the chip rather than in the main window. A session goes when the draft is sent or
* discarded, unless it has edits nobody took: those stay, for the user to decide on.
*/
export function useComposerEdits<A extends EditableAttachment>({ attachments, setAttachments, addFiles, load, account, draftId, ownerAccountId, repliesTo, ready }: Options<A>) {
const t = useTranslations("desktopEdit");
const { canEdit, edit } = useEditAttachment(account);
const available = getEditBridge() !== null;
const sessions = useEditSessions((s) => s.sessions);
const instanceRef = useRef(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
const heldRef = useRef(new Set<string>());
const takenRef = useRef(new WeakSet<File>());
const latest = useRef({ attachments, sessions });
latest.current = { attachments, sessions };
useEffect(() => {
if (available) followEditSessions();
}, [available]);
// A file the user sent into a reply from an offer.
const repliedRef = useRef(false);
useEffect(() => {
if (!available || !ready || repliedRef.current || !repliesTo) return;
repliedRef.current = true;
const files = takeFilesForReply(repliesTo);
if (files.length > 0) void addFiles(files);
}, [available, ready, repliesTo, addFiles]);
// A file read back from a session: its chip takes the session on, and once it's up the
// version it holds is no longer a change.
useEffect(() => {
if (!available) return;
const untagged = attachments.some((att) => !att.editSessionId && editFileInfo(att.file));
if (untagged) {
setAttachments((list) => list.map((att) => {
const info = att.editSessionId ? undefined : editFileInfo(att.file);
return info ? { ...att, editSessionId: info.id } : att;
}));
}
for (const att of attachments) {
const info = editFileInfo(att.file);
if (!info || !att.file || !att.blobId || att.uploading || att.error || takenRef.current.has(att.file)) continue;
takenRef.current.add(att.file);
void getEditBridge()?.taken(info.id, info.hash);
}
}, [available, attachments, setAttachments]);
// A draft opened again finds its sessions by the draft's id and the file's name.
useEffect(() => {
if (!available || !draftId) return;
const mine = sessions.filter((session) => {
const source = parseEditSource(session.source);
return source.kind === "draft" && source.draftId === draftId && !session.claimed;
});
if (mine.length === 0) return;
setAttachments((list) => {
let changed = false;
const next = list.map((att) => {
if (att.editSessionId) return att;
const match = mine.find((session) => session.name === att.name && !list.some((other) => other.editSessionId === session.id));
if (!match) return att;
changed = true;
return { ...att, editSessionId: match.id };
});
return changed ? next : list;
});
}, [available, draftId, sessions, setAttachments]);
// Holds what's on a chip. One taken off a chip goes unless it has edits, which stay in the offers.
const heldIds = attachments.flatMap((att) => (att.editSessionId ? [att.editSessionId] : []));
const heldKey = [...new Set(heldIds)].sort().join("\n");
useEffect(() => {
const bridge = getEditBridge();
if (!bridge) return;
const now = new Set(heldKey ? heldKey.split("\n") : []);
for (const id of now) {
if (!heldRef.current.has(id)) bridge.claim(id, true);
}
for (const id of heldRef.current) {
if (now.has(id)) continue;
bridge.claim(id, false);
const session = latest.current.sessions.find((s) => s.id === id);
if (session?.changed) bridge.setSource(id, JSON.stringify({ kind: "file" } satisfies EditSource));
else if (session) void bridge.discard(id);
}
heldRef.current = now;
}, [heldKey]);
// Follows the draft's id, which changes with every save.
useEffect(() => {
const bridge = getEditBridge();
if (!bridge || !heldKey) return;
const source = JSON.stringify({ kind: "draft", draftId, ownerAccountId } satisfies EditSource);
for (const id of heldKey.split("\n")) bridge.setSource(id, source);
}, [heldKey, draftId, ownerAccountId]);
// Closing the composer isn't an end: the draft may be opened again.
useEffect(() => () => {
const bridge = getEditBridge();
for (const id of heldRef.current) bridge?.claim(id, false);
heldRef.current = new Set();
}, []);
const sessionOf = useCallback((att: A) => (att.editSessionId ? sessions.find((s) => s.id === att.editSessionId) : undefined), [sessions]);
/** Whether this chip has saved edits to take. */
const hasUpdate = useCallback((att: A) => sessionOf(att)?.changed === true, [sessionOf]);
const startEdit = useCallback(async (att: A) => {
const key = att.editSessionId
? `session:${att.editSessionId}`
: att.blobId ? `blob:${account}:${att.blobId}` : `composer:${instanceRef.current}:${att.name}`;
const result = await edit({
key,
name: att.name,
type: att.type,
account,
source: { kind: "draft", draftId, ownerAccountId },
load: () => load(att),
});
if (result.ok) {
setAttachments((list) => list.map((item) => (
(att.file ? item.file === att.file : item.blobId === att.blobId) ? { ...item, editSessionId: result.id } : item
)));
}
}, [account, draftId, edit, load, ownerAccountId, setAttachments]);
/** The edited file goes up first. Only then does the old one come off, so a failed upload loses nothing. */
const update = useCallback(async (att: A) => {
const id = att.editSessionId;
if (!id) return;
const outcome = await readEditedFile({ id, account });
if (!outcome.ok) {
toast.error(t(outcome.reason === "busy" ? "read_busy" : outcome.reason === "policy" ? "blocked_policy" : "read_failed", { name: att.name }));
return;
}
const { file } = outcome;
await addFiles([file]);
setAttachments((list) => {
const added = list.find((item) => item.file === file);
if (added?.blobId && !added.error) return list.filter((item) => item.file === file || item.editSessionId !== id);
return list.filter((item) => item.file !== file);
});
}, [account, addFiles, setAttachments, t]);
/** Edits on the way out in an older version: asks first. False: don't send. */
const confirmSend = useCallback(async (): Promise<boolean> => {
const { attachments: list, sessions: all } = latest.current;
const stale = list.flatMap((att) => {
const session = att.editSessionId ? all.find((s) => s.id === att.editSessionId) : undefined;
return session?.changed ? [session] : [];
});
const dialogs = getDesktopBridge()?.dialogs;
if (stale.length === 0 || !dialogs) return true;
const choice = await dialogs.confirm({
type: "warning",
message: t("send_stale_title", { name: stale[0].name }),
detail: t("send_stale_detail"),
buttons: [t("send_stale_send"), t("send_stale_back")],
defaultId: 1,
});
return choice === 0;
}, [t]);
/** The draft was sent or discarded. Reads no state of the component: this can run after it has gone. */
const finish = useCallback(() => {
const bridge = getEditBridge();
if (!bridge) return;
const { attachments: list, sessions: all } = latest.current;
const ids = new Set(list.flatMap((att) => (att.editSessionId ? [att.editSessionId] : [])));
for (const id of ids) {
heldRef.current.delete(id);
bridge.claim(id, false);
if (all.find((s) => s.id === id)?.changed) bridge.setSource(id, JSON.stringify({ kind: "file" } satisfies EditSource));
else void bridge.discard(id);
}
}, []);
return { canEdit, startEdit, hasUpdate, update, confirmSend, finish };
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { canEditFile, editAllowed, startAttachmentEdit, type EditStartResult, type StartEditInput } from "@/lib/desktop/edit-attachments";
import { deviceSubjectOf } from "@/lib/device-policy";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
const FAILURE_KEYS: Partial<Record<Exclude<EditStartResult, { ok: true }>["reason"], string>> = {
policy: "blocked_policy",
"no-app": "no_app",
type: "blocked_type",
"too-large": "too_large",
full: "too_many",
};
/** The local account a message or a draft belongs to, where a copy of its file is filed. */
export function editAccountOf(email: { sourceClientAccountId?: string | null } | null | undefined): string {
return deviceSubjectOf(email) ?? useAuthStore.getState().activeAccountId ?? "";
}
/**
* Edit, for whatever shows an attachment: whether to offer it, and starting it with the app's
* words for how it went. `account` is the local account the attachment belongs to; the
* hook follows that account's policy, so the item goes as soon as the answer turns to no.
*/
export function useEditAttachment(account: string) {
const t = useTranslations("desktopEdit");
const devices = useDevicePolicy(account || null);
const offered = !devices.noDownloads && !devices.noExport;
const canEdit = useCallback((name: string | null | undefined) => (
offered && !!account && canEditFile(name) && editAllowed(account)
), [offered, account]);
const edit = useCallback(async (input: StartEditInput): Promise<EditStartResult> => {
let result: EditStartResult;
try {
result = await startAttachmentEdit(input);
} catch (error) {
console.error("Could not open an attachment for editing:", error);
result = { ok: false, reason: "failed" };
}
if (result.ok) toast.success(t("opened", { name: input.name }));
// Locked: the lock screen is what's saying it.
else if (result.reason !== "locked") toast.error(t(FAILURE_KEYS[result.reason] ?? "failed"));
return result;
}, [t]);
return { canEdit, edit };
}
@@ -0,0 +1,267 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const policy = vi.hoisted(() => ({ decided: true, download: true, export: true }));
vi.mock('@/lib/device-policy', () => ({
canDownload: () => policy.download,
canExport: () => policy.export,
}));
vi.mock('@/lib/offline/offline-mode', () => ({
scopeDecided: () => policy.decided,
}));
import {
canEditFile,
editAllowed,
editFileInfo,
editForbidden,
followEditSessions,
parkFilesForReply,
parseEditSource,
readEditedFile,
resetEditSessions,
startAttachmentEdit,
takeFilesForReply,
useEditSessions,
type StartEditInput,
} from '../edit-attachments';
import { setAppLockCheck } from '../app-lock-state';
import { runWipeCleaners } from '@/lib/offline/wipe-registry';
import { runBeforeSignOut } from '@/lib/sign-out-cleanup';
function fakeBridge() {
return {
canEdit: vi.fn((name: string) => !name.endsWith('.exe')),
begin: vi.fn(async (_key: string) => ({ ok: true as const, ticket: 't1' } as { ok: true; ticket: string; open?: string } | { ok: false; reason: 'locked' })),
open: vi.fn(async () => ({ ok: true as const, id: 's1' })),
reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })),
list: vi.fn(async () => []),
read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h' })),
taken: vi.fn(async () => {}),
discard: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
setSource: vi.fn(),
claim: vi.fn(),
onChange: vi.fn(() => () => {}),
};
}
let bridge: ReturnType<typeof fakeBridge>;
const input = (over: Partial<StartEditInput> = {}): StartEditInput => ({
key: 'k',
name: 'a.docx',
type: 'x/y',
account: 'acct',
source: { kind: 'mail', emailId: 'e1' },
load: vi.fn(async () => new Uint8Array([9]).buffer),
...over,
});
beforeEach(() => {
policy.decided = true;
policy.download = true;
policy.export = true;
bridge = fakeBridge();
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge };
resetEditSessions();
setAppLockCheck(null);
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('the policy', () => {
it('takes both saving attachments and export', () => {
expect(editAllowed('acct')).toBe(true);
policy.download = false;
expect(editAllowed('acct')).toBe(false);
policy.download = true;
policy.export = false;
expect(editAllowed('acct')).toBe(false);
});
it('counts an unknown policy as no for starting, and as no reason to delete anything', () => {
policy.decided = false;
expect(editAllowed('acct')).toBe(false);
expect(editForbidden('acct')).toBe(false);
policy.decided = true;
policy.export = false;
expect(editForbidden('acct')).toBe(true);
});
});
describe('starting', () => {
it('asks main from the click, then fetches, then opens with what it fetched', async () => {
const order: string[] = [];
bridge.begin.mockImplementation(async () => {
order.push('begin');
return { ok: true, ticket: 't1' };
});
const start = input({ load: async () => {
order.push('load');
return new Uint8Array([7, 8]).buffer;
} });
bridge.open.mockImplementation(async () => {
order.push('open');
return { ok: true, id: 's1' };
});
expect(await startAttachmentEdit(start)).toEqual({ ok: true, id: 's1' });
expect(order).toEqual(['begin', 'load', 'open']);
const [ticket, file] = bridge.open.mock.calls[0] as unknown as [string, { data: Uint8Array; source: string; account: string; key: string }];
expect(ticket).toBe('t1');
expect([...file.data]).toEqual([7, 8]);
expect(file).toMatchObject({ account: 'acct', key: 'k' });
expect(JSON.parse(file.source)).toEqual({ kind: 'mail', emailId: 'e1' });
});
it('touches nothing when the organisation says no', async () => {
policy.download = false;
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' });
expect(bridge.begin).not.toHaveBeenCalled();
expect(start.load).not.toHaveBeenCalled();
});
it('stops when the answer changes while the bytes are on their way, as a guarded sink does', async () => {
const start = input({ load: async () => {
policy.export = false;
return new Uint8Array([1]).buffer;
} });
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' });
expect(bridge.open).not.toHaveBeenCalled();
});
it('stops when the app locks while the bytes are on their way', async () => {
const start = input({ load: async () => {
setAppLockCheck(() => true);
return new Uint8Array([1]).buffer;
} });
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' });
expect(bridge.open).not.toHaveBeenCalled();
});
it('opens the same file again and fetches nothing, so the edits stay', async () => {
bridge.begin.mockResolvedValue({ ok: true, ticket: 't2', open: 's9' });
const start = input();
await startAttachmentEdit(start);
expect(bridge.reopen).toHaveBeenCalledWith('t2', 's9');
expect(start.load).not.toHaveBeenCalled();
expect(bridge.open).not.toHaveBeenCalled();
});
it('passes on what main refuses', async () => {
bridge.begin.mockResolvedValue({ ok: false, reason: 'locked' });
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' });
expect(start.load).not.toHaveBeenCalled();
});
it('asks the shell which files may go, and answers no without a shell', () => {
expect(canEditFile('a.docx')).toBe(true);
expect(canEditFile('a.exe')).toBe(false);
expect(canEditFile(null)).toBe(false);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
expect(canEditFile('a.docx')).toBe(false);
});
it('does nothing in a browser', async () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'unavailable' });
expect(start.load).not.toHaveBeenCalled();
});
});
describe('reading a saved file back', () => {
it('refuses when the organisation has said no since', async () => {
policy.export = false;
expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'policy' });
expect(bridge.read).not.toHaveBeenCalled();
});
it('gives a file that remembers its session and version', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
expect(outcome.file.name).toBe('a.docx');
expect(outcome.file.type).toBe('x/y');
expect(new Uint8Array(await outcome.file.arrayBuffer())).toEqual(new Uint8Array([1, 2]));
expect(editFileInfo(outcome.file)).toEqual({ id: 's1', hash: 'h' });
expect(editFileInfo(new File([], 'other'))).toBeUndefined();
});
it('passes a held or lost file on as such', async () => {
bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never);
expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'busy' });
});
});
describe('a file on its way to a reply', () => {
it('waits for that message, once', () => {
const file = new File([], 'a.txt');
parkFilesForReply('e1', [file]);
expect(takeFilesForReply('e2')).toEqual([]);
expect(takeFilesForReply('e1')).toEqual([file]);
expect(takeFilesForReply('e1')).toEqual([]);
});
it('does not wait for ever', () => {
vi.useFakeTimers();
try {
parkFilesForReply('e1', [new File([], 'a.txt')]);
vi.advanceTimersByTime(3 * 60 * 1000);
expect(takeFilesForReply('e1')).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe('what main says', () => {
it('follows the list once per page', async () => {
const session = { id: 's1', name: 'a', type: '', account: 'x', source: '', changed: true, version: 1, claimed: false };
bridge.list.mockResolvedValue([session] as never);
followEditSessions();
followEditSessions();
expect(bridge.onChange).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(useEditSessions.getState().sessions).toEqual([session]));
(bridge.onChange.mock.calls[0] as unknown as [(s: unknown[]) => void])[0]([]);
expect(useEditSessions.getState().sessions).toEqual([]);
});
});
describe('where a source came from', () => {
it('reads what it wrote, and treats anything else as just a file', () => {
expect(parseEditSource(JSON.stringify({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' }))).toEqual({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' });
expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: 'd' }))).toEqual({ kind: 'draft', draftId: 'd' });
expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: null }))).toEqual({ kind: 'draft', draftId: null });
expect(parseEditSource(JSON.stringify({ kind: 'mail' }))).toEqual({ kind: 'file' });
expect(parseEditSource('not json')).toEqual({ kind: 'file' });
});
});
describe('the copies go with the account', () => {
it('on a wipe of an account, and of everything', async () => {
await runWipeCleaners('acct');
expect(bridge.clear).toHaveBeenCalledWith('acct');
bridge.clear.mockClear();
await runWipeCleaners(null);
expect(bridge.clear).toHaveBeenCalledWith(null);
});
it('at sign-out', async () => {
await runBeforeSignOut('acct');
expect(bridge.clear).toHaveBeenCalledWith('acct');
});
it("not when the app ended the session itself and keeps the account's data", async () => {
await runBeforeSignOut('acct', { keepLocalStore: true });
expect(bridge.clear).not.toHaveBeenCalled();
});
it('a cleaner with no shell is no failure', async () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
await expect(runWipeCleaners(null)).resolves.toBeUndefined();
});
});
+11
View File
@@ -228,6 +228,17 @@ describe('a toast\'s buttons', () => {
stop();
});
it('a toast for a saved attachment only brings the window up, and leaves the page where it is', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
const stop = setMailLinkOpener(opener);
await run({ type: 'notification', action: 'open', context: JSON.stringify({ editedFile: true }) });
expect(focus).toHaveBeenCalled();
expect(opener).not.toHaveBeenCalled();
expect(leaveFor).not.toHaveBeenCalled();
stop();
});
it('a click on a summary toast opens the Inbox of that login', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
+175
View File
@@ -0,0 +1,175 @@
/**
* "Edit" on an attachment, the page's side. The desktop app copies the file to a private
* folder, opens it in its default program and says when it was saved (the shell's
* features/os-edit.ts). What happens to a saved file is decided here and in what uses
* this: a composer offers "Update attachment" (hooks/use-composer-edits.ts), and the
* rest goes through the offers in components/desktop/edited-files-host.tsx.
*
* The organisation's word on saving attachments and exporting mail is per account and only
* known here, so it's asked at every step, as the local export does around its sink:
* before anything starts, after the bytes arrive, and before a saved file is read back.
* A policy that turns to no ends the session (the host watches), and one that isn't known
* yet counts as no for starting something, but deletes nothing.
*/
import { create } from 'zustand';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopEditBridge, DesktopEditOpenResult, DesktopEditSession } from '@/lib/desktop/types';
import { canDownload, canExport } from '@/lib/device-policy';
import { scopeDecided } from '@/lib/offline/offline-mode';
import { onDeviceWipe } from '@/lib/offline/wipe-registry';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
/** Where a session's file came from, kept by main for as long as the session lives. */
export type EditSource =
/** A message's attachment: offers a reply. */
| { kind: 'mail'; emailId: string; ownerAccountId?: string }
/** A draft's attachment: offers the update, in the composer holding it. */
| { kind: 'draft'; draftId: string | null; ownerAccountId?: string }
/** Its message went or it was taken off a draft: only the file is left. */
| { kind: 'file' };
export function parseEditSource(raw: string): EditSource {
try {
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown };
const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined;
if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId };
if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId };
} catch {
// Falls through: what's left is a file.
}
return { kind: 'file' };
}
export function getEditBridge(): DesktopEditBridge | null {
return getDesktopBridge()?.edit ?? null;
}
/** Nothing in the browser. Otherwise the shell's own list of what may be opened. */
export function canEditFile(name: string | null | undefined): boolean {
return !!name && getEditBridge()?.canEdit(name) === true;
}
/** Whether this account may keep a copy outside the app: saving attachments and export both have to be allowed. */
export function editAllowed(account: string): boolean {
return scopeDecided(account) && canDownload(account) && canExport(account);
}
/** A definite no. An unknown policy isn't one: nothing is deleted for that. */
export function editForbidden(account: string): boolean {
return scopeDecided(account) && !(canDownload(account) && canExport(account));
}
export type EditStartResult = DesktopEditOpenResult | { ok: false; reason: 'policy' | 'unavailable' | 'gesture' };
export interface StartEditInput {
/** One session per key: Edit on the same attachment opens the same file, with its edits. */
key: string;
name: string;
type: string;
account: string;
source: EditSource;
load: () => Promise<Blob | ArrayBuffer>;
}
/** Call it straight from the click: main only starts a session on a real one. */
export async function startAttachmentEdit(input: StartEditInput): Promise<EditStartResult> {
const edit = getEditBridge();
if (!edit) return { ok: false, reason: 'unavailable' };
if (!editAllowed(input.account)) return { ok: false, reason: 'policy' };
const begun = await edit.begin(input.key);
if (!begun.ok) return begun;
if (begun.open) return edit.reopen(begun.ticket, begun.open);
const loaded = await input.load();
// It can take a while to arrive, and the answer can change meanwhile.
if (!editAllowed(input.account)) return { ok: false, reason: 'policy' };
if (isAppLocked()) return { ok: false, reason: 'locked' };
const data = new Uint8Array(loaded instanceof Blob ? await loaded.arrayBuffer() : loaded);
return edit.open(begun.ticket, {
key: input.key,
name: input.name,
type: input.type,
data,
account: input.account,
source: JSON.stringify(input.source),
});
}
/** A file read back remembers which session it came from, so whichever composer it lands in takes the session on. */
const editFiles = new WeakMap<File, { id: string; hash: string }>();
export function editFileInfo(file: File | undefined): { id: string; hash: string } | undefined {
return file ? editFiles.get(file) : undefined;
}
export type EditReadOutcome =
| { ok: true; file: File; hash: string }
| { ok: false; reason: 'policy' | 'locked' | 'missing' | 'busy' | 'too-large' | 'unavailable' };
export async function readEditedFile(session: Pick<DesktopEditSession, 'id' | 'account'>): Promise<EditReadOutcome> {
const edit = getEditBridge();
if (!edit) return { ok: false, reason: 'unavailable' };
if (!editAllowed(session.account)) return { ok: false, reason: 'policy' };
const answer = await edit.read(session.id);
if (!answer.ok) return answer;
const bytes = answer.data;
const file = new File([new Uint8Array(bytes)], answer.name, { type: answer.type || 'application/octet-stream' });
editFiles.set(file, { id: session.id, hash: answer.hash });
return { ok: true, file, hash: answer.hash };
}
const REPLY_WAIT_MS = 2 * 60 * 1000;
const parkedReplies = new Map<string, { files: File[]; at: number }>();
/** The next reply composer opened on this message takes these. Waits here so every way of opening one works. */
export function parkFilesForReply(emailId: string, files: File[]): void {
parkedReplies.set(emailId, { files, at: Date.now() });
}
export function takeFilesForReply(emailId: string | undefined): File[] {
if (!emailId) return [];
const parked = parkedReplies.get(emailId);
parkedReplies.delete(emailId);
return parked && Date.now() - parked.at < REPLY_WAIT_MS ? parked.files : [];
}
interface EditSessionsState {
sessions: DesktopEditSession[];
}
/** What main says, per window. Empty while the app is locked. */
export const useEditSessions = create<EditSessionsState>(() => ({ sessions: [] }));
let listening = false;
/** Once per page, and for its life. Does nothing in a browser. */
export function followEditSessions(): void {
const edit = getEditBridge();
if (!edit || listening) return;
listening = true;
edit.onChange((sessions) => useEditSessions.setState({ sessions }));
void edit.list().then((sessions) => useEditSessions.setState({ sessions }), () => {});
}
/** For tests. */
export function resetEditSessions(): void {
listening = false;
parkedReplies.clear();
useEditSessions.setState({ sessions: [] });
}
const clear = async (account: string | null) => {
await getEditBridge()?.clear(account);
};
// The copies are of mail, so they go with the account. A wipe doesn't ask and neither does this;
// the question before a sign-out the user started is in the host. A sign-out the app decided
// on because it couldn't confirm the login (keepLocalStore) keeps them, as it keeps the store:
// nobody chose it, and these can be someone's only copy of an afternoon's work. While it lasts
// the host doesn't show them, since that account isn't signed in.
onDeviceWipe((account) => clear(account));
onBeforeSignOut((account, options) => (options.keepLocalStore ? Promise.resolve() : clear(account)));
onFullSignOut((options) => {
if (!options.keepLocalStore) void clear(null);
});
+2
View File
@@ -45,6 +45,8 @@ export interface MailNotificationContext {
accountId?: string;
slot?: number;
login?: string;
/** Not mail: an attachment saved in another program (components/desktop/edited-files-host.tsx). */
editedFile?: boolean;
}
/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */
+5
View File
@@ -101,6 +101,11 @@ async function handleNotification(action: string, rawContext: string, reply: str
}
if (await handleReminderCommand(action, rawContext, env)) return;
const context = parseContext(rawContext);
// A saved file's toast: the offer is on the main window, wherever it was.
if (context.editedFile) {
showWindow();
return;
}
if (action === 'reply') {
await openFromNotification(context, env, { text: reply ?? '' });
return;
+66
View File
@@ -604,6 +604,71 @@ export interface DesktopPrivacyBridge {
onChange: (listener: (state: DesktopPrivacyState) => void) => () => void;
}
/** An attachment copied to a private folder and open in its default program. */
export interface DesktopEditSession {
id: string;
name: string;
type: string;
/** The local account it belongs to. */
account: string;
/** What the page said it came from. Main only keeps it. */
source: string;
/** Saved differently from what the page last took. */
changed: boolean;
/** Goes up with each save. */
version: number;
/** A composer has it and offers the update itself. */
claimed: boolean;
}
export interface DesktopEditFile {
/** One session per key: Edit on the same attachment opens the same file. */
key: string;
name: string;
type: string;
data: Uint8Array;
account: string;
source: string;
}
export type DesktopEditBeginResult =
| { ok: true; ticket: string; /** A session for that key exists: reopen this one. */ open?: string }
| { ok: false; reason: 'locked' | 'gesture' };
export type DesktopEditOpenResult =
| { ok: true; id: string }
| { ok: false; reason: 'locked' | 'ticket' | 'type' | 'too-large' | 'full' | 'no-app' | 'failed' };
export type DesktopEditReadResult =
| { ok: true; name: string; type: string; data: Uint8Array; hash: string }
| { ok: false; reason: 'locked' | 'missing' | 'busy' | 'too-large' };
/**
* Edit on an attachment: a private copy opened in its default program, watched for saves.
* Main refuses file types that run something and anything while locked. It can't know the
* organisation's rules, so lib/desktop/edit-attachments.ts asks those at each step.
*/
export interface DesktopEditBridge {
/** Whether this file may go through Edit at all: the same rule main enforces. */
canEdit: (name: string) => boolean;
/** Straight from the click: main checks it was one. The ticket lasts long enough to fetch the bytes. */
begin: (key: string) => Promise<DesktopEditBeginResult>;
open: (ticket: string, file: DesktopEditFile) => Promise<DesktopEditOpenResult>;
reopen: (ticket: string, id: string) => Promise<DesktopEditOpenResult>;
/** Empty while locked. */
list: () => Promise<DesktopEditSession[]>;
read: (id: string) => Promise<DesktopEditReadResult>;
/** This version is where it should be, so it's no longer a change. */
taken: (id: string, hash: string) => Promise<void>;
discard: (id: string) => Promise<void>;
/** An account's copies, or with null all of this origin's. */
clear: (account: string | null) => Promise<void>;
setSource: (id: string, source: string) => void;
/** A composer holds it, or stopped. Ends by itself with the page. */
claim: (id: string, on: boolean) => void;
onChange: (listener: (sessions: DesktopEditSession[]) => void) => () => void;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
@@ -626,6 +691,7 @@ export interface BulwarkDesktopBridge {
appLock?: DesktopAppLockBridge;
localImport?: DesktopLocalImportBridge;
privacy?: DesktopPrivacyBridge;
edit?: DesktopEditBridge;
}
declare global {
+6
View File
@@ -81,6 +81,12 @@ export function openNewComposerWindow(localAccountId?: string | null): boolean {
return focused(openWindow(appPath('/popout/compose') + query({ account: localAccountId }), name, 'compose'));
}
/** A saved draft in a composer window of its own. The same draft twice focuses the same window. */
export function openDraftWindow(target: { draftId: string; localAccountId?: string | null; ownerAccountId?: string | null }): boolean {
const url = appPath('/popout/compose') + query({ draft: target.draftId, account: target.localAccountId, owner: target.ownerAccountId });
return focused(openWindow(url, `bulwark-compose-draft-${target.draftId.replace(/\W/g, '_')}`, 'compose'));
}
export interface ComposerWindow {
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
handOver(payload: ComposePopoutPayload): Promise<boolean>;
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "تعذّر نسخ الملف.",
"reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
},
"desktopEdit": {
"edit": "تعديل",
"edit_named": "تعديل {name}",
"opened": "{name} مفتوح في برنامجه. احفظ تغييراتك هناك، وسيُطلب منك تحديد ما يجب فعله بها.",
"offers_label": "المرفقات المعدّلة",
"changed_title": "تم تغيير {name}",
"changed_body": "حفظت تغييرات في برنامج آخر. ماذا تريد أن تفعل بها؟",
"reply": "إرفاق برد",
"new_message": "إرفاق برسالة جديدة",
"open_draft": "فتح المسودة",
"save_copy": "حفظ نسخة…",
"discard": "تجاهل التعديلات…",
"discard_title": "هل تريد تجاهل تعديلاتك على {name}؟",
"discard_detail": "تُحذف النسخة المعدّلة. ولا يتغير المرفق الأصلي.",
"discard_confirm": "تجاهل",
"cancel": "إلغاء",
"update": "تحديث المرفق",
"no_app": "لا يوجد برنامج على هذا الكمبيوتر يمكنه فتح هذا الملف.",
"blocked_type": "لا يمكن فتح هذا النوع من الملفات للتعديل، لأنه قد يعمل كبرنامج.",
"blocked_policy": "لا تسمح مؤسستك بحفظ المرفقات على هذا الجهاز.",
"removed_policy": "لم تعد مؤسستك تسمح بذلك، لذا حُذفت النسخة المعدّلة من {name}.",
"too_large": "هذا الملف كبير جدًا بحيث لا يمكن فتحه للتعديل.",
"too_many": "هناك ملفات كثيرة جدًا مفتوحة للتعديل. انتهِ من بعضها أولًا.",
"failed": "تعذر فتح الملف للتعديل.",
"read_busy": "ما زال برنامج آخر يستخدم {name}. أغلقه هناك وحاول مرة أخرى.",
"read_failed": "تعذرت قراءة النسخة المعدّلة من {name}.",
"send_stale_title": "يحتوي {name} على تعديلات ليست في هذه الرسالة",
"send_stale_detail": "إذا أرسلت الآن، فستُرسل الرسالة بالإصدار الأقدم. تبقى تعديلاتك على هذا الكمبيوتر إلى أن تقرر ما تفعله بها.",
"send_stale_send": "إرسال على أي حال",
"send_stale_back": "رجوع",
"signout_title": "هل تريد تسجيل الخروج وحذف تعديلاتك؟",
"signout_detail": "تُحذف عند تسجيل الخروج المرفقات التي عدّلتها ولم ترفقها أو تحفظها.",
"signout_confirm": "تسجيل الخروج",
"lock_note": "تبقى الملفات التي فتحتها باستخدام «تعديل» مفتوحة في برنامجها أثناء قفل التطبيق، وتظل قابلة للقراءة على هذا الكمبيوتر. ولا يمكن فتح ملفات جديدة حتى تفتح القفل."
},
"desktopReminders": {
"generic_title": "تذكير",
"snooze": "تأجيل {count} د",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "No s'ha pogut copiar el fitxer.",
"reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà."
},
"desktopEdit": {
"edit": "Edita",
"edit_named": "Edita {name}",
"opened": "{name} és obert al seu programa. Deseu-hi els canvis i se us preguntarà què fer-ne.",
"offers_label": "Adjunts editats",
"changed_title": "{name} ha canviat",
"changed_body": "Heu desat canvis en un altre programa. Què voleu fer-ne?",
"reply": "Adjunta a una resposta",
"new_message": "Adjunta a un missatge nou",
"open_draft": "Obre l’esborrany",
"save_copy": "Desa una còpia…",
"discard": "Descarta els canvis…",
"discard_title": "Voleu descartar els canvis a {name}?",
"discard_detail": "S’elimina la còpia editada. L’adjunt original no canvia.",
"discard_confirm": "Descarta",
"cancel": "Cancel·la",
"update": "Actualitza l’adjunt",
"no_app": "Cap programa d’aquest ordinador pot obrir aquest fitxer.",
"blocked_type": "Aquest tipus de fitxer no es pot obrir per editar-lo, perquè es podria executar com un programa.",
"blocked_policy": "La vostra organització no permet desar adjunts en aquest dispositiu.",
"removed_policy": "La vostra organització ja no ho permet, així que s’ha eliminat la còpia editada de {name}.",
"too_large": "Aquest fitxer és massa gran per obrir-lo i editar-lo.",
"too_many": "Hi ha massa fitxers oberts per editar. Primer acabeu-ne alguns.",
"failed": "No s’ha pogut obrir el fitxer per editar-lo.",
"read_busy": "Un altre programa encara fa servir {name}. Tanqueu-lo allà i torneu-ho a provar.",
"read_failed": "No s’ha pogut llegir la còpia editada de {name}.",
"send_stale_title": "{name} té canvis que no són en aquest missatge",
"send_stale_detail": "Si l’envieu ara, el missatge surt amb la versió anterior. Els canvis es queden en aquest ordinador fins que decidiu què fer-ne.",
"send_stale_send": "Envia igualment",
"send_stale_back": "Torna",
"signout_title": "Voleu tancar la sessió i eliminar els canvis?",
"signout_detail": "Els adjunts que heu editat però no heu adjuntat ni desat s’eliminen en tancar la sessió.",
"signout_confirm": "Tanca la sessió",
"lock_note": "Els fitxers oberts amb Edita continuen oberts al seu programa mentre l’aplicació està bloquejada, i continuen sent llegibles en aquest ordinador. No se’n poden obrir de nous fins que la desbloquegeu."
},
"desktopReminders": {
"generic_title": "Recordatori",
"snooze": "Posposa {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Soubor se nepodařilo zkopírovat.",
"reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní."
},
"desktopEdit": {
"edit": "Upravit",
"edit_named": "Upravit {name}",
"opened": "{name} je otevřený ve svém programu. Uložte tam změny a pak se vás zeptáme, co s nimi udělat.",
"offers_label": "Upravené přílohy",
"changed_title": "{name} se změnil",
"changed_body": "Uložili jste změny v jiném programu. Co s nimi chcete udělat?",
"reply": "Připojit k odpovědi",
"new_message": "Připojit k nové zprávě",
"open_draft": "Otevřít koncept",
"save_copy": "Uložit kopii…",
"discard": "Zahodit změny…",
"discard_title": "Zahodit změny v {name}?",
"discard_detail": "Upravená kopie se smaže. Původní příloha se nezmění.",
"discard_confirm": "Zahodit",
"cancel": "Zrušit",
"update": "Aktualizovat přílohu",
"no_app": "Tento soubor nedokáže otevřít žádný program na tomto počítači.",
"blocked_type": "Tento typ souboru nelze otevřít k úpravám, protože by se mohl spustit jako program.",
"blocked_policy": "Vaše organizace nepovoluje ukládat přílohy na toto zařízení.",
"removed_policy": "Vaše organizace už to nepovoluje, proto byla upravená kopie {name} smazána.",
"too_large": "Tento soubor je příliš velký, aby se dal otevřít k úpravám.",
"too_many": "K úpravám je otevřeno příliš mnoho souborů. Některé nejdřív dokončete.",
"failed": "Soubor se nepodařilo otevřít k úpravám.",
"read_busy": "{name} stále používá jiný program. Zavřete ho tam a zkuste to znovu.",
"read_failed": "Upravenou kopii {name} se nepodařilo přečíst.",
"send_stale_title": "{name} má změny, které v této zprávě nejsou",
"send_stale_detail": "Když teď odešlete, zpráva odejde se starší verzí. Vaše změny zůstanou na tomto počítači, dokud se nerozhodnete, co s nimi.",
"send_stale_send": "Přesto odeslat",
"send_stale_back": "Zpět",
"signout_title": "Odhlásit se a smazat změny?",
"signout_detail": "Přílohy, které jste upravili, ale nepřipojili ani neuložili, se při odhlášení smažou.",
"signout_confirm": "Odhlásit se",
"lock_note": "Soubory otevřené přes Upravit zůstanou otevřené ve svém programu, i když je aplikace zamčená, a zůstanou na tomto počítači čitelné. Nové se otevřou až po odemčení."
},
"desktopReminders": {
"generic_title": "Připomenutí",
"snooze": "Odložit o {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der."
},
"desktopEdit": {
"edit": "Rediger",
"edit_named": "Rediger {name}",
"opened": "{name} er åben i sit program. Gem dine ændringer der, så bliver du spurgt, hvad der skal ske med dem.",
"offers_label": "Redigerede vedhæftninger",
"changed_title": "{name} blev ændret",
"changed_body": "Du har gemt ændringer i et andet program. Hvad skal der ske med dem?",
"reply": "Vedhæft til et svar",
"new_message": "Vedhæft til en ny besked",
"open_draft": "Åbn kladde",
"save_copy": "Gem en kopi…",
"discard": "Kassér ændringer…",
"discard_title": "Kassér dine ændringer i {name}?",
"discard_detail": "Den redigerede kopi slettes. Den oprindelige vedhæftning ændres ikke.",
"discard_confirm": "Kassér",
"cancel": "Annuller",
"update": "Opdater vedhæftning",
"no_app": "Intet program på denne computer kan åbne filen.",
"blocked_type": "Denne filtype kan ikke åbnes til redigering, fordi den kan køre som et program.",
"blocked_policy": "Din organisation tillader ikke at gemme vedhæftninger på denne enhed.",
"removed_policy": "Din organisation tillader det ikke længere, så den redigerede kopi af {name} blev slettet.",
"too_large": "Filen er for stor til at åbne til redigering.",
"too_many": "Der er for mange filer åbne til redigering. Gør nogle af dem færdige først.",
"failed": "Filen kunne ikke åbnes til redigering.",
"read_busy": "Et andet program bruger stadig {name}. Luk den der, og prøv igen.",
"read_failed": "Den redigerede kopi af {name} kunne ikke læses.",
"send_stale_title": "{name} har ændringer, der ikke er i denne besked",
"send_stale_detail": "Hvis du sender nu, går beskeden af sted med den ældre version. Dine ændringer bliver på denne computer, indtil du beslutter, hvad der skal ske med dem.",
"send_stale_send": "Send alligevel",
"send_stale_back": "Tilbage",
"signout_title": "Log ud og slet dine ændringer?",
"signout_detail": "Vedhæftninger, du har redigeret, men hverken vedhæftet eller gemt, slettes, når du logger ud.",
"signout_confirm": "Log ud",
"lock_note": "Filer, du har åbnet med Rediger, forbliver åbne i deres program, mens appen er låst, og kan stadig læses på denne computer. Nye kan først åbnes, når du låser op."
},
"desktopReminders": {
"generic_title": "Påmindelse",
"snooze": "Udsæt {count} min.",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Die Datei konnte nicht kopiert werden.",
"reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort."
},
"desktopEdit": {
"edit": "Bearbeiten",
"edit_named": "{name} bearbeiten",
"opened": "{name} ist in seinem Programm geöffnet. Speichern Sie Ihre Änderungen dort, danach werden Sie gefragt, was damit geschehen soll.",
"offers_label": "Bearbeitete Anhänge",
"changed_title": "{name} wurde geändert",
"changed_body": "Sie haben Änderungen in einem anderen Programm gespeichert. Was soll damit geschehen?",
"reply": "An eine Antwort anhängen",
"new_message": "An eine neue Nachricht anhängen",
"open_draft": "Entwurf öffnen",
"save_copy": "Kopie speichern…",
"discard": "Änderungen verwerfen…",
"discard_title": "Ihre Änderungen an {name} verwerfen?",
"discard_detail": "Die bearbeitete Kopie wird gelöscht. Der ursprüngliche Anhang bleibt, wie er ist.",
"discard_confirm": "Verwerfen",
"cancel": "Abbrechen",
"update": "Anhang aktualisieren",
"no_app": "Kein Programm auf diesem Computer kann diese Datei öffnen.",
"blocked_type": "Dieser Dateityp lässt sich nicht zum Bearbeiten öffnen, weil er als Programm laufen könnte.",
"blocked_policy": "Ihre Organisation erlaubt nicht, Anhänge auf diesem Gerät zu speichern.",
"removed_policy": "Ihre Organisation erlaubt das nicht mehr, daher wurde die bearbeitete Kopie von {name} gelöscht.",
"too_large": "Diese Datei ist zu groß, um sie zum Bearbeiten zu öffnen.",
"too_many": "Es sind zu viele Dateien zum Bearbeiten geöffnet. Schließen Sie zuerst einige davon ab.",
"failed": "Die Datei konnte nicht zum Bearbeiten geöffnet werden.",
"read_busy": "Ein anderes Programm verwendet {name} noch. Schließen Sie die Datei dort und versuchen Sie es erneut.",
"read_failed": "Die bearbeitete Kopie von {name} konnte nicht gelesen werden.",
"send_stale_title": "{name} hat Änderungen, die nicht in dieser Nachricht sind",
"send_stale_detail": "Wenn Sie jetzt senden, geht die Nachricht mit der älteren Fassung hinaus. Ihre Änderungen bleiben auf diesem Computer, bis Sie entscheiden, was damit geschehen soll.",
"send_stale_send": "Trotzdem senden",
"send_stale_back": "Zurück",
"signout_title": "Abmelden und Ihre Änderungen löschen?",
"signout_detail": "Anhänge, die Sie bearbeitet, aber weder angehängt noch gespeichert haben, werden beim Abmelden gelöscht.",
"signout_confirm": "Abmelden",
"lock_note": "Dateien, die Sie mit „Bearbeiten“ geöffnet haben, bleiben in ihrem Programm geöffnet, während die App gesperrt ist, und bleiben auf diesem Computer lesbar. Neue lassen sich erst nach dem Entsperren öffnen."
},
"desktopReminders": {
"generic_title": "Erinnerung",
"snooze": "{count} Min. schlummern",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "The file could not be copied.",
"reply_lost": "Your typed reply could not be kept. Open the message and reply there."
},
"desktopEdit": {
"edit": "Edit",
"edit_named": "Edit {name}",
"opened": "{name} is open in its program. Save your changes there, and you'll be asked what to do with them.",
"offers_label": "Edited attachments",
"changed_title": "{name} was changed",
"changed_body": "You saved changes in another program. What should happen to them?",
"reply": "Attach to a reply",
"new_message": "Attach to a new message",
"open_draft": "Open draft",
"save_copy": "Save a copy…",
"discard": "Discard edits…",
"discard_title": "Discard your edits to {name}?",
"discard_detail": "The edited copy is deleted. The original attachment doesn't change.",
"discard_confirm": "Discard",
"cancel": "Cancel",
"update": "Update attachment",
"no_app": "No program on this computer can open this file.",
"blocked_type": "This type of file can't be opened for editing, because it could run as a program.",
"blocked_policy": "Your organisation doesn't allow saving attachments on this device.",
"removed_policy": "Your organisation no longer allows this, so the edited copy of {name} was deleted.",
"too_large": "This file is too large to open for editing.",
"too_many": "Too many files are open for editing. Finish with some of them first.",
"failed": "The file could not be opened for editing.",
"read_busy": "Another program is still using {name}. Close it there and try again.",
"read_failed": "The edited copy of {name} could not be read.",
"send_stale_title": "{name} has edits that aren't in this message",
"send_stale_detail": "If you send now, the message goes with the older version. Your edits stay on this computer until you decide what to do with them.",
"send_stale_send": "Send anyway",
"send_stale_back": "Go back",
"signout_title": "Sign out and delete your edits?",
"signout_detail": "Attachments you edited but didn't attach or save are deleted when you sign out.",
"signout_confirm": "Sign out",
"lock_note": "Files you opened with Edit stay open in their program while the app is locked, and they stay readable on this computer. New ones can't open until you unlock."
},
"desktopReminders": {
"generic_title": "Reminder",
"snooze": "Snooze {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "No se pudo copiar el archivo.",
"reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí."
},
"desktopEdit": {
"edit": "Editar",
"edit_named": "Editar {name}",
"opened": "{name} está abierto en su programa. Guarde los cambios allí y se le preguntará qué hacer con ellos.",
"offers_label": "Adjuntos editados",
"changed_title": "{name} ha cambiado",
"changed_body": "Ha guardado cambios en otro programa. ¿Qué quiere hacer con ellos?",
"reply": "Adjuntar a una respuesta",
"new_message": "Adjuntar a un mensaje nuevo",
"open_draft": "Abrir borrador",
"save_copy": "Guardar una copia…",
"discard": "Descartar los cambios…",
"discard_title": "¿Descartar los cambios en {name}?",
"discard_detail": "Se elimina la copia editada. El adjunto original no cambia.",
"discard_confirm": "Descartar",
"cancel": "Cancelar",
"update": "Actualizar adjunto",
"no_app": "Ningún programa de este equipo puede abrir este archivo.",
"blocked_type": "Este tipo de archivo no se puede abrir para editarlo, porque podría ejecutarse como un programa.",
"blocked_policy": "Su organización no permite guardar adjuntos en este dispositivo.",
"removed_policy": "Su organización ya no lo permite, así que se eliminó la copia editada de {name}.",
"too_large": "Este archivo es demasiado grande para abrirlo y editarlo.",
"too_many": "Hay demasiados archivos abiertos para editar. Termine primero con algunos.",
"failed": "No se pudo abrir el archivo para editarlo.",
"read_busy": "Otro programa sigue usando {name}. Ciérrelo allí e inténtelo de nuevo.",
"read_failed": "No se pudo leer la copia editada de {name}.",
"send_stale_title": "{name} tiene cambios que no están en este mensaje",
"send_stale_detail": "Si envía ahora, el mensaje sale con la versión anterior. Sus cambios se quedan en este equipo hasta que decida qué hacer con ellos.",
"send_stale_send": "Enviar de todos modos",
"send_stale_back": "Volver",
"signout_title": "¿Cerrar sesión y eliminar sus cambios?",
"signout_detail": "Los adjuntos que editó pero no adjuntó ni guardó se eliminan al cerrar sesión.",
"signout_confirm": "Cerrar sesión",
"lock_note": "Los archivos abiertos con Editar siguen abiertos en su programa mientras la aplicación está bloqueada, y siguen siendo legibles en este equipo. No se pueden abrir otros hasta que la desbloquee."
},
"desktopReminders": {
"generic_title": "Recordatorio",
"snooze": "Posponer {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Faili ei saanud kopeerida.",
"reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal."
},
"desktopEdit": {
"edit": "Muuda",
"edit_named": "Muuda faili {name}",
"opened": "{name} on oma programmis avatud. Salvesta muudatused seal ja sinult küsitakse, mida nendega teha.",
"offers_label": "Muudetud manused",
"changed_title": "{name} on muutunud",
"changed_body": "Salvestasid muudatused teises programmis. Mida nendega teha?",
"reply": "Lisa vastusele",
"new_message": "Lisa uuele kirjale",
"open_draft": "Ava mustand",
"save_copy": "Salvesta koopia…",
"discard": "Loobu muudatustest…",
"discard_title": "Kas loobuda faili {name} muudatustest?",
"discard_detail": "Muudetud koopia kustutatakse. Algne manus ei muutu.",
"discard_confirm": "Loobu",
"cancel": "Tühista",
"update": "Uuenda manust",
"no_app": "Ükski selle arvuti programm ei saa seda faili avada.",
"blocked_type": "Seda tüüpi faili ei saa muutmiseks avada, sest see võib käivituda programmina.",
"blocked_policy": "Sinu organisatsioon ei luba selles seadmes manuseid salvestada.",
"removed_policy": "Sinu organisatsioon ei luba seda enam, seega kustutati faili {name} muudetud koopia.",
"too_large": "See fail on muutmiseks avamiseks liiga suur.",
"too_many": "Muutmiseks on avatud liiga palju faile. Lõpeta kõigepealt mõned neist.",
"failed": "Faili ei saanud muutmiseks avada.",
"read_busy": "Teine programm kasutab faili {name} endiselt. Sulge see seal ja proovi uuesti.",
"read_failed": "Faili {name} muudetud koopiat ei saanud lugeda.",
"send_stale_title": "Failis {name} on muudatusi, mida selles kirjas pole",
"send_stale_detail": "Kui saadad praegu, läheb kiri vanema versiooniga. Sinu muudatused jäävad sellesse arvutisse, kuni otsustad, mida nendega teha.",
"send_stale_send": "Saada ikkagi",
"send_stale_back": "Tagasi",
"signout_title": "Kas logida välja ja kustutada muudatused?",
"signout_detail": "Manused, mida muutsid, kuid ei lisanud ega salvestanud, kustutatakse väljalogimisel.",
"signout_confirm": "Logi välja",
"lock_note": "Muutmiseks avatud failid jäävad oma programmis avatuks ka siis, kui rakendus on lukus, ja on selles arvutis endiselt loetavad. Uusi ei saa enne lukust avamist avada."
},
"desktopReminders": {
"generic_title": "Meeldetuletus",
"snooze": "Lükka {count} min edasi",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "رونوشت پرونده ممکن نشد.",
"reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
},
"desktopEdit": {
"edit": "ویرایش",
"edit_named": "ویرایش {name}",
"opened": "{name} در برنامه خودش باز است. تغییرات را همان‌جا ذخیره کنید تا از شما پرسیده شود با آنها چه شود.",
"offers_label": "پیوست‌های ویرایش‌شده",
"changed_title": "{name} تغییر کرد",
"changed_body": "تغییراتی را در برنامه دیگری ذخیره کردید. با آنها چه شود؟",
"reply": "پیوست به پاسخ",
"new_message": "پیوست به پیام جدید",
"open_draft": "باز کردن پیش‌نویس",
"save_copy": "ذخیره یک نسخه…",
"discard": "دور انداختن ویرایش‌ها…",
"discard_title": "ویرایش‌های {name} دور انداخته شود؟",
"discard_detail": "نسخه ویرایش‌شده حذف می‌شود. پیوست اصلی تغییر نمی‌کند.",
"discard_confirm": "دور انداختن",
"cancel": "انصراف",
"update": "به‌روزرسانی پیوست",
"no_app": "هیچ برنامه‌ای در این رایانه نمی‌تواند این فایل را باز کند.",
"blocked_type": "این نوع فایل را نمی‌توان برای ویرایش باز کرد، چون ممکن است مانند برنامه اجرا شود.",
"blocked_policy": "سازمان شما اجازه ذخیره پیوست‌ها در این دستگاه را نمی‌دهد.",
"removed_policy": "سازمان شما دیگر اجازه این کار را نمی‌دهد، بنابراین نسخه ویرایش‌شده {name} حذف شد.",
"too_large": "این فایل برای باز شدن جهت ویرایش خیلی بزرگ است.",
"too_many": "فایل‌های زیادی برای ویرایش باز است. ابتدا کار برخی از آنها را تمام کنید.",
"failed": "فایل برای ویرایش باز نشد.",
"read_busy": "برنامه دیگری هنوز از {name} استفاده می‌کند. آن را همان‌جا ببندید و دوباره امتحان کنید.",
"read_failed": "نسخه ویرایش‌شده {name} خوانده نشد.",
"send_stale_title": "{name} ویرایش‌هایی دارد که در این پیام نیست",
"send_stale_detail": "اگر اکنون بفرستید، پیام با نسخه قدیمی‌تر می‌رود. ویرایش‌های شما تا زمانی که تصمیم بگیرید در این رایانه می‌ماند.",
"send_stale_send": "با این حال بفرست",
"send_stale_back": "بازگشت",
"signout_title": "خروج از حساب و حذف ویرایش‌ها؟",
"signout_detail": "پیوست‌هایی که ویرایش کرده‌اید ولی پیوست یا ذخیره نکرده‌اید، هنگام خروج حذف می‌شوند.",
"signout_confirm": "خروج از حساب",
"lock_note": "فایل‌هایی که با «ویرایش» باز کرده‌اید تا وقتی برنامه قفل است در برنامه خود باز می‌مانند و در این رایانه قابل خواندن هستند. فایل‌های جدید تا باز کردن قفل باز نمی‌شوند."
},
"desktopReminders": {
"generic_title": "یادآوری",
"snooze": "تعویق {count} دقیقه",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Le fichier n'a pas pu être copié.",
"reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci."
},
"desktopEdit": {
"edit": "Modifier",
"edit_named": "Modifier {name}",
"opened": "{name} est ouvert dans son programme. Enregistrez vos modifications dans ce programme : on vous demandera ensuite quoi en faire.",
"offers_label": "Pièces jointes modifiées",
"changed_title": "{name} a été modifié",
"changed_body": "Vous avez enregistré des modifications dans un autre programme. Que voulez-vous en faire ?",
"reply": "Joindre à une réponse",
"new_message": "Joindre à un nouveau message",
"open_draft": "Ouvrir le brouillon",
"save_copy": "Enregistrer une copie…",
"discard": "Abandonner les modifications…",
"discard_title": "Abandonner vos modifications de {name} ?",
"discard_detail": "La copie modifiée est supprimée. La pièce jointe d’origine ne change pas.",
"discard_confirm": "Abandonner",
"cancel": "Annuler",
"update": "Mettre à jour la pièce jointe",
"no_app": "Aucun programme de cet ordinateur ne peut ouvrir ce fichier.",
"blocked_type": "Ce type de fichier ne peut pas être ouvert pour modification, car il pourrait s’exécuter comme un programme.",
"blocked_policy": "Votre organisation n’autorise pas l’enregistrement des pièces jointes sur cet appareil.",
"removed_policy": "Votre organisation ne l’autorise plus : la copie modifiée de {name} a été supprimée.",
"too_large": "Ce fichier est trop volumineux pour être ouvert et modifié.",
"too_many": "Trop de fichiers sont ouverts pour modification. Terminez-en quelques-uns d’abord.",
"failed": "Le fichier n’a pas pu être ouvert pour modification.",
"read_busy": "Un autre programme utilise encore {name}. Fermez-le dans ce programme et réessayez.",
"read_failed": "La copie modifiée de {name} n’a pas pu être lue.",
"send_stale_title": "{name} contient des modifications absentes de ce message",
"send_stale_detail": "Si vous envoyez maintenant, le message part avec l’ancienne version. Vos modifications restent sur cet ordinateur jusqu’à ce que vous décidiez quoi en faire.",
"send_stale_send": "Envoyer quand même",
"send_stale_back": "Revenir",
"signout_title": "Vous déconnecter et supprimer vos modifications ?",
"signout_detail": "Les pièces jointes modifiées, mais ni jointes ni enregistrées, sont supprimées à la déconnexion.",
"signout_confirm": "Se déconnecter",
"lock_note": "Les fichiers ouverts avec Modifier restent ouverts dans leur programme pendant que l’application est verrouillée, et restent lisibles sur cet ordinateur. Aucun nouveau fichier ne peut s’ouvrir avant le déverrouillage."
},
"desktopReminders": {
"generic_title": "Rappel",
"snooze": "Reporter de {count} min",
+35
View File
@@ -6959,6 +6959,41 @@
"copy_failed": "לא ניתן להעתיק את הקובץ.",
"reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
},
"desktopEdit": {
"edit": "עריכה",
"edit_named": "עריכת {name}",
"opened": "{name} פתוח בתוכנה שלו. שמרו שם את השינויים, ואז תישאלו מה לעשות בהם.",
"offers_label": "קבצים מצורפים שנערכו",
"changed_title": "{name} השתנה",
"changed_body": "שמרתם שינויים בתוכנה אחרת. מה לעשות בהם?",
"reply": "צירוף לתשובה",
"new_message": "צירוף להודעה חדשה",
"open_draft": "פתיחת הטיוטה",
"save_copy": "שמירת עותק…",
"discard": "ביטול העריכות…",
"discard_title": "לבטל את העריכות ב{name}?",
"discard_detail": "העותק הערוך יימחק. הקובץ המצורף המקורי לא ישתנה.",
"discard_confirm": "ביטול העריכות",
"cancel": "ביטול",
"update": "עדכון הקובץ המצורף",
"no_app": "אין במחשב זה תוכנה שיכולה לפתוח את הקובץ.",
"blocked_type": "אי אפשר לפתוח סוג קובץ זה לעריכה, כי הוא עלול לרוץ כתוכנה.",
"blocked_policy": "הארגון שלכם לא מאפשר לשמור קבצים מצורפים במכשיר זה.",
"removed_policy": "הארגון שלכם כבר לא מאפשר זאת, ולכן העותק הערוך של {name} נמחק.",
"too_large": "הקובץ גדול מדי לפתיחה לעריכה.",
"too_many": "יותר מדי קבצים פתוחים לעריכה. סיימו קודם עם חלק מהם.",
"failed": "לא ניתן היה לפתוח את הקובץ לעריכה.",
"read_busy": "תוכנה אחרת עדיין משתמשת ב{name}. סגרו אותו שם ונסו שוב.",
"read_failed": "לא ניתן היה לקרוא את העותק הערוך של {name}.",
"send_stale_title": "ב{name} יש עריכות שאינן בהודעה זו",
"send_stale_detail": "אם תשלחו עכשיו, ההודעה תצא עם הגרסה הישנה. העריכות יישארו במחשב זה עד שתחליטו מה לעשות בהן.",
"send_stale_send": "לשלוח בכל זאת",
"send_stale_back": "חזרה",
"signout_title": "להתנתק ולמחוק את העריכות?",
"signout_detail": "קבצים שערכתם אך לא צירפתם או שמרתם יימחקו בעת ההתנתקות.",
"signout_confirm": "התנתקות",
"lock_note": "קבצים שפתחתם בעזרת עריכה נשארים פתוחים בתוכנה שלהם בזמן שהאפליקציה נעולה, ונשארים קריאים במחשב זה. אי אפשר לפתוח חדשים עד לביטול הנעילה."
},
"desktopReminders": {
"generic_title": "תזכורת",
"snooze": "דחייה ב-{count} דק'",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "A fájlt nem sikerült másolni.",
"reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott."
},
"desktopEdit": {
"edit": "Szerkesztés",
"edit_named": "{name} szerkesztése",
"opened": "A(z) {name} meg van nyitva a programjában. Ott mentse a módosításokat, és megkérdezzük, mi legyen velük.",
"offers_label": "Szerkesztett mellékletek",
"changed_title": "A(z) {name} megváltozott",
"changed_body": "Módosításokat mentett egy másik programban. Mi legyen velük?",
"reply": "Csatolás válaszhoz",
"new_message": "Csatolás új üzenethez",
"open_draft": "Piszkozat megnyitása",
"save_copy": "Másolat mentése…",
"discard": "Módosítások elvetése…",
"discard_title": "Elveti a(z) {name} módosításait?",
"discard_detail": "A szerkesztett másolat törlődik. Az eredeti melléklet nem változik.",
"discard_confirm": "Elvetés",
"cancel": "Mégse",
"update": "Melléklet frissítése",
"no_app": "A számítógépen egyik program sem tudja megnyitni ezt a fájlt.",
"blocked_type": "Ez a fájltípus nem nyitható meg szerkesztésre, mert programként futhat.",
"blocked_policy": "A szervezete nem engedélyezi mellékletek mentését erre az eszközre.",
"removed_policy": "A szervezete ezt már nem engedélyezi, ezért a(z) {name} szerkesztett másolata törölve lett.",
"too_large": "Ez a fájl túl nagy ahhoz, hogy szerkesztésre megnyíljon.",
"too_many": "Túl sok fájl van megnyitva szerkesztésre. Előbb fejezzen be néhányat.",
"failed": "A fájlt nem sikerült megnyitni szerkesztésre.",
"read_busy": "Egy másik program még használja ezt: {name}. Zárja be ott, és próbálja újra.",
"read_failed": "A(z) {name} szerkesztett másolatát nem sikerült beolvasni.",
"send_stale_title": "A(z) {name} olyan módosításokat tartalmaz, amelyek nincsenek ebben az üzenetben",
"send_stale_detail": "Ha most küldi el, az üzenet a régebbi változattal megy el. A módosítások ezen a számítógépen maradnak, amíg nem dönt róluk.",
"send_stale_send": "Küldés így is",
"send_stale_back": "Vissza",
"signout_title": "Kijelentkezik, és törli a módosításokat?",
"signout_detail": "A szerkesztett, de nem csatolt és nem mentett mellékletek kijelentkezéskor törlődnek.",
"signout_confirm": "Kijelentkezés",
"lock_note": "A Szerkesztéssel megnyitott fájlok nyitva maradnak a programjukban, amíg az alkalmazás zárolva van, és olvashatók maradnak ezen a számítógépen. Újak csak a feloldás után nyithatók meg."
},
"desktopReminders": {
"generic_title": "Emlékeztető",
"snooze": "Halasztás {count} perccel",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Impossibile copiare il file.",
"reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì."
},
"desktopEdit": {
"edit": "Modifica",
"edit_named": "Modifica {name}",
"opened": "{name} è aperto nel suo programma. Salva lì le modifiche e ti verrà chiesto cosa farne.",
"offers_label": "Allegati modificati",
"changed_title": "{name} è stato modificato",
"changed_body": "Hai salvato delle modifiche in un altro programma. Cosa vuoi farne?",
"reply": "Allega a una risposta",
"new_message": "Allega a un nuovo messaggio",
"open_draft": "Apri bozza",
"save_copy": "Salva una copia…",
"discard": "Scarta le modifiche…",
"discard_title": "Scartare le modifiche a {name}?",
"discard_detail": "La copia modificata viene eliminata. L’allegato originale non cambia.",
"discard_confirm": "Scarta",
"cancel": "Annulla",
"update": "Aggiorna allegato",
"no_app": "Nessun programma di questo computer può aprire questo file.",
"blocked_type": "Questo tipo di file non può essere aperto per la modifica, perché potrebbe essere eseguito come programma.",
"blocked_policy": "La tua organizzazione non consente di salvare allegati su questo dispositivo.",
"removed_policy": "La tua organizzazione non lo consente più, quindi la copia modificata di {name} è stata eliminata.",
"too_large": "Questo file è troppo grande per essere aperto e modificato.",
"too_many": "Ci sono troppi file aperti per la modifica. Prima terminane alcuni.",
"failed": "Non è stato possibile aprire il file per la modifica.",
"read_busy": "Un altro programma sta ancora usando {name}. Chiudilo lì e riprova.",
"read_failed": "Non è stato possibile leggere la copia modificata di {name}.",
"send_stale_title": "{name} ha modifiche che non sono in questo messaggio",
"send_stale_detail": "Se invii ora, il messaggio parte con la versione precedente. Le modifiche restano su questo computer finché non decidi cosa farne.",
"send_stale_send": "Invia comunque",
"send_stale_back": "Torna indietro",
"signout_title": "Uscire ed eliminare le modifiche?",
"signout_detail": "Gli allegati modificati ma non allegati né salvati vengono eliminati quando esci.",
"signout_confirm": "Esci",
"lock_note": "I file aperti con Modifica restano aperti nel loro programma mentre l’app è bloccata, e restano leggibili su questo computer. Non se ne possono aprire altri finché non la sblocchi."
},
"desktopReminders": {
"generic_title": "Promemoria",
"snooze": "Rimanda di {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "ファイルをコピーできませんでした。",
"reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
},
"desktopEdit": {
"edit": "編集",
"edit_named": "{name} を編集",
"opened": "{name} を対応するプログラムで開きました。そこで変更を保存すると、どうするかを確認します。",
"offers_label": "編集した添付ファイル",
"changed_title": "{name} が変更されました",
"changed_body": "別のプログラムで変更を保存しました。どうしますか?",
"reply": "返信に添付",
"new_message": "新しいメッセージに添付",
"open_draft": "下書きを開く",
"save_copy": "コピーを保存…",
"discard": "編集内容を破棄…",
"discard_title": "{name} の編集内容を破棄しますか?",
"discard_detail": "編集したコピーは削除されます。元の添付ファイルは変わりません。",
"discard_confirm": "破棄",
"cancel": "キャンセル",
"update": "添付ファイルを更新",
"no_app": "このコンピューターには、このファイルを開けるプログラムがありません。",
"blocked_type": "この種類のファイルはプログラムとして実行される可能性があるため、編集用に開けません。",
"blocked_policy": "組織により、このデバイスへの添付ファイルの保存は許可されていません。",
"removed_policy": "組織で許可されなくなったため、{name} の編集済みコピーを削除しました。",
"too_large": "このファイルは大きすぎるため、編集用に開けません。",
"too_many": "編集用に開いているファイルが多すぎます。先にいくつか終わらせてください。",
"failed": "ファイルを編集用に開けませんでした。",
"read_busy": "別のプログラムが {name} をまだ使用しています。そちらで閉じてから、もう一度お試しください。",
"read_failed": "{name} の編集済みコピーを読み取れませんでした。",
"send_stale_title": "{name} には、このメッセージに含まれていない編集内容があります",
"send_stale_detail": "今送信すると、古いバージョンが送られます。編集内容は、どうするか決めるまでこのコンピューターに残ります。",
"send_stale_send": "このまま送信",
"send_stale_back": "戻る",
"signout_title": "サインアウトして編集内容を削除しますか?",
"signout_detail": "編集したものの添付も保存もしていないファイルは、サインアウト時に削除されます。",
"signout_confirm": "サインアウト",
"lock_note": "「編集」で開いたファイルは、アプリのロック中もそのプログラムで開いたままになり、このコンピューター上で読み取れる状態のままです。新しいファイルはロック解除まで開けません。"
},
"desktopReminders": {
"generic_title": "リマインダー",
"snooze": "{count} 分後に再通知",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "파일을 복사할 수 없습니다.",
"reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
},
"desktopEdit": {
"edit": "편집",
"edit_named": "{name} 편집",
"opened": "{name}이(가) 해당 프로그램에서 열렸습니다. 그곳에서 변경 내용을 저장하면 어떻게 할지 묻습니다.",
"offers_label": "편집한 첨부 파일",
"changed_title": "{name}이(가) 변경되었습니다",
"changed_body": "다른 프로그램에서 변경 내용을 저장했습니다. 어떻게 할까요?",
"reply": "답장에 첨부",
"new_message": "새 메시지에 첨부",
"open_draft": "임시보관함 열기",
"save_copy": "사본 저장…",
"discard": "편집 내용 버리기…",
"discard_title": "{name}의 편집 내용을 버릴까요?",
"discard_detail": "편집한 사본이 삭제됩니다. 원본 첨부 파일은 바뀌지 않습니다.",
"discard_confirm": "버리기",
"cancel": "취소",
"update": "첨부 파일 업데이트",
"no_app": "이 컴퓨터에는 이 파일을 열 수 있는 프로그램이 없습니다.",
"blocked_type": "이 파일 형식은 프로그램으로 실행될 수 있어 편집용으로 열 수 없습니다.",
"blocked_policy": "조직에서 이 기기에 첨부 파일을 저장하는 것을 허용하지 않습니다.",
"removed_policy": "조직에서 더 이상 허용하지 않아 {name}의 편집한 사본을 삭제했습니다.",
"too_large": "이 파일은 너무 커서 편집용으로 열 수 없습니다.",
"too_many": "편집용으로 열린 파일이 너무 많습니다. 먼저 일부를 마무리하세요.",
"failed": "파일을 편집용으로 열 수 없습니다.",
"read_busy": "다른 프로그램이 아직 {name}을(를) 사용 중입니다. 그곳에서 닫고 다시 시도하세요.",
"read_failed": "{name}의 편집한 사본을 읽을 수 없습니다.",
"send_stale_title": "{name}에 이 메시지에 없는 편집 내용이 있습니다",
"send_stale_detail": "지금 보내면 이전 버전으로 전송됩니다. 편집 내용은 어떻게 할지 결정할 때까지 이 컴퓨터에 남아 있습니다.",
"send_stale_send": "그래도 보내기",
"send_stale_back": "돌아가기",
"signout_title": "로그아웃하고 편집 내용을 삭제할까요?",
"signout_detail": "편집했지만 첨부하거나 저장하지 않은 파일은 로그아웃할 때 삭제됩니다.",
"signout_confirm": "로그아웃",
"lock_note": "편집으로 연 파일은 앱이 잠겨 있는 동안에도 해당 프로그램에서 계속 열려 있으며 이 컴퓨터에서 계속 읽을 수 있습니다. 잠금을 해제할 때까지 새 파일은 열 수 없습니다."
},
"desktopReminders": {
"generic_title": "알림",
"snooze": "{count}분 후 다시 알림",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Failu neizdevās nokopēt.",
"reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur."
},
"desktopEdit": {
"edit": "Rediģēt",
"edit_named": "Rediģēt {name}",
"opened": "{name} ir atvērts savā programmā. Saglabājiet izmaiņas tur, un jums jautās, ko ar tām darīt.",
"offers_label": "Rediģētie pielikumi",
"changed_title": "{name} ir mainīts",
"changed_body": "Jūs saglabājāt izmaiņas citā programmā. Ko ar tām darīt?",
"reply": "Pievienot atbildei",
"new_message": "Pievienot jaunam ziņojumam",
"open_draft": "Atvērt melnrakstu",
"save_copy": "Saglabāt kopiju…",
"discard": "Atmest izmaiņas…",
"discard_title": "Vai atmest {name} izmaiņas?",
"discard_detail": "Rediģētā kopija tiek izdzēsta. Sākotnējais pielikums nemainās.",
"discard_confirm": "Atmest",
"cancel": "Atcelt",
"update": "Atjaunināt pielikumu",
"no_app": "Neviena šī datora programma nevar atvērt šo failu.",
"blocked_type": "Šo faila veidu nevar atvērt rediģēšanai, jo tas var darboties kā programma.",
"blocked_policy": "Jūsu organizācija neļauj saglabāt pielikumus šajā ierīcē.",
"removed_policy": "Jūsu organizācija to vairs neļauj, tāpēc {name} rediģētā kopija ir izdzēsta.",
"too_large": "Šis fails ir pārāk liels, lai to atvērtu rediģēšanai.",
"too_many": "Rediģēšanai atvērts pārāk daudz failu. Vispirms pabeidziet dažus no tiem.",
"failed": "Failu neizdevās atvērt rediģēšanai.",
"read_busy": "Cita programma joprojām izmanto {name}. Aizveriet to tur un mēģiniet vēlreiz.",
"read_failed": "Neizdevās nolasīt {name} rediģēto kopiju.",
"send_stale_title": "{name} ir izmaiņas, kuru nav šajā ziņojumā",
"send_stale_detail": "Ja sūtīsiet tagad, ziņojums aizies ar vecāko versiju. Jūsu izmaiņas paliek šajā datorā, līdz izlemsiet, ko ar tām darīt.",
"send_stale_send": "Tomēr sūtīt",
"send_stale_back": "Atpakaļ",
"signout_title": "Vai izrakstīties un dzēst izmaiņas?",
"signout_detail": "Pielikumi, ko rediģējāt, bet nepievienojāt un nesaglabājāt, izrakstoties tiek izdzēsti.",
"signout_confirm": "Izrakstīties",
"lock_note": "Faili, kas atvērti ar Rediģēt, paliek atvērti savā programmā, kamēr lietotne ir bloķēta, un paliek lasāmi šajā datorā. Jaunus nevar atvērt, kamēr neatbloķēsiet."
},
"desktopReminders": {
"generic_title": "Atgādinājums",
"snooze": "Atlikt par {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Файлыг хуулж чадсангүй.",
"reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
},
"desktopEdit": {
"edit": "Засах",
"edit_named": "{name} засах",
"opened": "{name} өөрийн программд нээлттэй байна. Өөрчлөлтөө тэнд хадгалаарай, тэгвэл юу хийхийг асууна.",
"offers_label": "Зассан хавсралтууд",
"changed_title": "{name} өөрчлөгдлөө",
"changed_body": "Та өөр программд өөрчлөлт хадгалсан байна. Үүнийг яах вэ?",
"reply": "Хариунд хавсаргах",
"new_message": "Шинэ зурваст хавсаргах",
"open_draft": "Ноорог нээх",
"save_copy": "Хуулбар хадгалах…",
"discard": "Засваруудыг хаях…",
"discard_title": "{name}-ийн засваруудыг хаях уу?",
"discard_detail": "Зассан хуулбар устана. Анхны хавсралт өөрчлөгдөхгүй.",
"discard_confirm": "Хаях",
"cancel": "Цуцлах",
"update": "Хавсралтыг шинэчлэх",
"no_app": "Энэ компьютерийн ямар ч программ энэ файлыг нээж чадахгүй.",
"blocked_type": "Энэ төрлийн файл программ болж ажиллаж болзошгүй тул засахаар нээх боломжгүй.",
"blocked_policy": "Танай байгууллага энэ төхөөрөмжид хавсралт хадгалахыг зөвшөөрдөггүй.",
"removed_policy": "Танай байгууллага үүнийг зөвшөөрөхөө больсон тул {name}-ийн зассан хуулбарыг устгалаа.",
"too_large": "Энэ файл засахаар нээхэд хэт том байна.",
"too_many": "Засахаар хэт олон файл нээлттэй байна. Эхлээд заримыг нь дуусгаарай.",
"failed": "Файлыг засахаар нээж чадсангүй.",
"read_busy": "Өөр программ {name}-г ашигласаар байна. Тэнд хаагаад дахин оролдоно уу.",
"read_failed": "{name}-ийн зассан хуулбарыг уншиж чадсангүй.",
"send_stale_title": "{name}-д энэ зурваст ороогүй засвар байна",
"send_stale_detail": "Одоо илгээвэл зурвас хуучин хувилбартайгаа явна. Таны засварууд шийдвэр гаргах хүртэл энэ компьютерт үлдэнэ.",
"send_stale_send": "Ямар ч байсан илгээх",
"send_stale_back": "Буцах",
"signout_title": "Гарч, засваруудаа устгах уу?",
"signout_detail": "Зассан боловч хавсаргаагүй, хадгалаагүй хавсралтууд гарах үед устана.",
"signout_confirm": "Гарах",
"lock_note": "Засах-аар нээсэн файлууд апп түгжээтэй үед ч өөрийн программд нээлттэй хэвээр байх бөгөөд энэ компьютерт уншигдах боломжтой хэвээр байна. Түгжээг тайлах хүртэл шинээр нээх боломжгүй."
},
"desktopReminders": {
"generic_title": "Сануулга",
"snooze": "{count} минут хойшлуулах",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der."
},
"desktopEdit": {
"edit": "Rediger",
"edit_named": "Rediger {name}",
"opened": "{name} er åpen i programmet sitt. Lagre endringene der, så blir du spurt om hva som skal skje med dem.",
"offers_label": "Redigerte vedlegg",
"changed_title": "{name} ble endret",
"changed_body": "Du lagret endringer i et annet program. Hva skal skje med dem?",
"reply": "Legg ved et svar",
"new_message": "Legg ved en ny melding",
"open_draft": "Åpne utkast",
"save_copy": "Lagre en kopi…",
"discard": "Forkast endringer…",
"discard_title": "Vil du forkaste endringene i {name}?",
"discard_detail": "Den redigerte kopien slettes. Det opprinnelige vedlegget endres ikke.",
"discard_confirm": "Forkast",
"cancel": "Avbryt",
"update": "Oppdater vedlegg",
"no_app": "Ingen programmer på denne datamaskinen kan åpne filen.",
"blocked_type": "Denne filtypen kan ikke åpnes for redigering fordi den kan kjøre som et program.",
"blocked_policy": "Organisasjonen din tillater ikke lagring av vedlegg på denne enheten.",
"removed_policy": "Organisasjonen din tillater ikke dette lenger, så den redigerte kopien av {name} ble slettet.",
"too_large": "Filen er for stor til å åpnes for redigering.",
"too_many": "For mange filer er åpne for redigering. Gjør ferdig noen av dem først.",
"failed": "Filen kunne ikke åpnes for redigering.",
"read_busy": "Et annet program bruker fortsatt {name}. Lukk den der og prøv igjen.",
"read_failed": "Den redigerte kopien av {name} kunne ikke leses.",
"send_stale_title": "{name} har endringer som ikke er i denne meldingen",
"send_stale_detail": "Hvis du sender nå, går meldingen med den eldre versjonen. Endringene blir på denne datamaskinen til du bestemmer hva som skal skje med dem.",
"send_stale_send": "Send likevel",
"send_stale_back": "Tilbake",
"signout_title": "Vil du logge ut og slette endringene?",
"signout_detail": "Vedlegg du har redigert, men ikke lagt ved eller lagret, slettes når du logger ut.",
"signout_confirm": "Logg ut",
"lock_note": "Filer du åpnet med Rediger, forblir åpne i programmet sitt mens appen er låst, og kan fortsatt leses på denne datamaskinen. Nye kan ikke åpnes før du låser opp."
},
"desktopReminders": {
"generic_title": "Påminnelse",
"snooze": "Utsett {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Het bestand kon niet worden gekopieerd.",
"reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar."
},
"desktopEdit": {
"edit": "Bewerken",
"edit_named": "{name} bewerken",
"opened": "{name} is geopend in zijn programma. Sla uw wijzigingen daar op, dan wordt u gevraagd wat ermee moet gebeuren.",
"offers_label": "Bewerkte bijlagen",
"changed_title": "{name} is gewijzigd",
"changed_body": "U hebt wijzigingen opgeslagen in een ander programma. Wat moet daarmee gebeuren?",
"reply": "Bijvoegen bij een antwoord",
"new_message": "Bijvoegen bij een nieuw bericht",
"open_draft": "Concept openen",
"save_copy": "Kopie opslaan…",
"discard": "Wijzigingen weggooien…",
"discard_title": "Uw wijzigingen in {name} weggooien?",
"discard_detail": "De bewerkte kopie wordt verwijderd. De oorspronkelijke bijlage verandert niet.",
"discard_confirm": "Weggooien",
"cancel": "Annuleren",
"update": "Bijlage bijwerken",
"no_app": "Geen enkel programma op deze computer kan dit bestand openen.",
"blocked_type": "Dit type bestand kan niet worden geopend om te bewerken, omdat het als programma kan worden uitgevoerd.",
"blocked_policy": "Uw organisatie staat niet toe dat bijlagen op dit apparaat worden opgeslagen.",
"removed_policy": "Uw organisatie staat dit niet meer toe, dus de bewerkte kopie van {name} is verwijderd.",
"too_large": "Dit bestand is te groot om te openen en te bewerken.",
"too_many": "Er zijn te veel bestanden geopend om te bewerken. Rond er eerst een paar af.",
"failed": "Het bestand kon niet worden geopend om te bewerken.",
"read_busy": "Een ander programma gebruikt {name} nog. Sluit het daar en probeer het opnieuw.",
"read_failed": "De bewerkte kopie van {name} kon niet worden gelezen.",
"send_stale_title": "{name} heeft wijzigingen die niet in dit bericht staan",
"send_stale_detail": "Als u nu verzendt, gaat het bericht met de oudere versie mee. Uw wijzigingen blijven op deze computer tot u beslist wat ermee moet gebeuren.",
"send_stale_send": "Toch verzenden",
"send_stale_back": "Terug",
"signout_title": "Afmelden en uw wijzigingen verwijderen?",
"signout_detail": "Bijlagen die u hebt bewerkt maar niet hebt bijgevoegd of opgeslagen, worden bij het afmelden verwijderd.",
"signout_confirm": "Afmelden",
"lock_note": "Bestanden die u met Bewerken hebt geopend, blijven in hun programma geopend terwijl de app is vergrendeld, en blijven leesbaar op deze computer. Nieuwe kunnen pas na het ontgrendelen worden geopend."
},
"desktopReminders": {
"generic_title": "Herinnering",
"snooze": "{count} min uitstellen",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Nie udało się skopiować pliku.",
"reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej."
},
"desktopEdit": {
"edit": "Edytuj",
"edit_named": "Edytuj {name}",
"opened": "{name} jest otwarty w swoim programie. Zapisz tam zmiany, a zapytamy, co z nimi zrobić.",
"offers_label": "Edytowane załączniki",
"changed_title": "{name} został zmieniony",
"changed_body": "Zapisano zmiany w innym programie. Co z nimi zrobić?",
"reply": "Dołącz do odpowiedzi",
"new_message": "Dołącz do nowej wiadomości",
"open_draft": "Otwórz wersję roboczą",
"save_copy": "Zapisz kopię…",
"discard": "Odrzuć zmiany…",
"discard_title": "Odrzucić zmiany w {name}?",
"discard_detail": "Edytowana kopia zostanie usunięta. Oryginalny załącznik się nie zmieni.",
"discard_confirm": "Odrzuć",
"cancel": "Anuluj",
"update": "Zaktualizuj załącznik",
"no_app": "Żaden program na tym komputerze nie może otworzyć tego pliku.",
"blocked_type": "Tego typu pliku nie można otworzyć do edycji, ponieważ mógłby uruchomić się jako program.",
"blocked_policy": "Twoja organizacja nie zezwala na zapisywanie załączników na tym urządzeniu.",
"removed_policy": "Twoja organizacja już na to nie zezwala, więc edytowana kopia {name} została usunięta.",
"too_large": "Ten plik jest za duży, aby otworzyć go do edycji.",
"too_many": "Do edycji otwarto zbyt wiele plików. Najpierw zakończ pracę z niektórymi.",
"failed": "Nie udało się otworzyć pliku do edycji.",
"read_busy": "Inny program nadal używa {name}. Zamknij go tam i spróbuj ponownie.",
"read_failed": "Nie udało się odczytać edytowanej kopii {name}.",
"send_stale_title": "{name} ma zmiany, których nie ma w tej wiadomości",
"send_stale_detail": "Jeśli wyślesz teraz, wiadomość pójdzie ze starszą wersją. Zmiany zostaną na tym komputerze, dopóki nie zdecydujesz, co z nimi zrobić.",
"send_stale_send": "Wyślij mimo to",
"send_stale_back": "Wróć",
"signout_title": "Wylogować i usunąć zmiany?",
"signout_detail": "Załączniki, które edytowano, ale nie dołączono ani nie zapisano, zostaną usunięte przy wylogowaniu.",
"signout_confirm": "Wyloguj",
"lock_note": "Pliki otwarte przez Edytuj pozostają otwarte w swoim programie, gdy aplikacja jest zablokowana, i można je nadal odczytać na tym komputerze. Nowych nie można otworzyć przed odblokowaniem."
},
"desktopReminders": {
"generic_title": "Przypomnienie",
"snooze": "Odłóż o {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Não foi possível copiar o ficheiro.",
"reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela."
},
"desktopEdit": {
"edit": "Editar",
"edit_named": "Editar {name}",
"opened": "{name} está aberto no respetivo programa. Guarde lá as alterações e ser-lhe-á perguntado o que fazer com elas.",
"offers_label": "Anexos editados",
"changed_title": "{name} foi alterado",
"changed_body": "Guardou alterações noutro programa. O que pretende fazer com elas?",
"reply": "Anexar a uma resposta",
"new_message": "Anexar a uma nova mensagem",
"open_draft": "Abrir rascunho",
"save_copy": "Guardar uma cópia…",
"discard": "Rejeitar alterações…",
"discard_title": "Rejeitar as alterações a {name}?",
"discard_detail": "A cópia editada é eliminada. O anexo original não muda.",
"discard_confirm": "Rejeitar",
"cancel": "Cancelar",
"update": "Atualizar anexo",
"no_app": "Nenhum programa deste computador consegue abrir este ficheiro.",
"blocked_type": "Este tipo de ficheiro não pode ser aberto para edição, porque pode ser executado como programa.",
"blocked_policy": "A sua organização não permite guardar anexos neste dispositivo.",
"removed_policy": "A sua organização já não o permite, por isso a cópia editada de {name} foi eliminada.",
"too_large": "Este ficheiro é demasiado grande para ser aberto para edição.",
"too_many": "Há demasiados ficheiros abertos para edição. Termine primeiro alguns deles.",
"failed": "Não foi possível abrir o ficheiro para edição.",
"read_busy": "Outro programa ainda está a usar {name}. Feche-o lá e tente novamente.",
"read_failed": "Não foi possível ler a cópia editada de {name}.",
"send_stale_title": "{name} tem alterações que não estão nesta mensagem",
"send_stale_detail": "Se enviar agora, a mensagem segue com a versão anterior. As suas alterações ficam neste computador até decidir o que fazer com elas.",
"send_stale_send": "Enviar na mesma",
"send_stale_back": "Voltar",
"signout_title": "Terminar sessão e eliminar as alterações?",
"signout_detail": "Os anexos que editou, mas não anexou nem guardou, são eliminados ao terminar sessão.",
"signout_confirm": "Terminar sessão",
"lock_note": "Os ficheiros abertos com Editar continuam abertos no respetivo programa enquanto a aplicação está bloqueada, e continuam legíveis neste computador. Não é possível abrir outros até desbloquear."
},
"desktopReminders": {
"generic_title": "Lembrete",
"snooze": "Adiar {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Fișierul nu a putut fi copiat.",
"reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo."
},
"desktopEdit": {
"edit": "Editează",
"edit_named": "Editează {name}",
"opened": "{name} este deschis în programul său. Salvați modificările acolo și veți fi întrebat ce să faceți cu ele.",
"offers_label": "Atașamente editate",
"changed_title": "{name} a fost modificat",
"changed_body": "Ați salvat modificări în alt program. Ce să se întâmple cu ele?",
"reply": "Atașează la un răspuns",
"new_message": "Atașează la un mesaj nou",
"open_draft": "Deschide ciorna",
"save_copy": "Salvează o copie…",
"discard": "Renunță la modificări…",
"discard_title": "Renunțați la modificările din {name}?",
"discard_detail": "Copia editată este ștearsă. Atașamentul original nu se schimbă.",
"discard_confirm": "Renunță",
"cancel": "Anulează",
"update": "Actualizează atașamentul",
"no_app": "Niciun program de pe acest computer nu poate deschide acest fișier.",
"blocked_type": "Acest tip de fișier nu poate fi deschis pentru editare, deoarece ar putea rula ca program.",
"blocked_policy": "Organizația dvs. nu permite salvarea atașamentelor pe acest dispozitiv.",
"removed_policy": "Organizația dvs. nu mai permite acest lucru, așa că a fost ștearsă copia editată a {name}.",
"too_large": "Acest fișier este prea mare pentru a fi deschis pentru editare.",
"too_many": "Sunt prea multe fișiere deschise pentru editare. Terminați mai întâi cu câteva.",
"failed": "Fișierul nu a putut fi deschis pentru editare.",
"read_busy": "Alt program încă folosește {name}. Închideți-l acolo și încercați din nou.",
"read_failed": "Copia editată a {name} nu a putut fi citită.",
"send_stale_title": "{name} are modificări care nu sunt în acest mesaj",
"send_stale_detail": "Dacă trimiteți acum, mesajul pleacă cu versiunea mai veche. Modificările rămân pe acest computer până când decideți ce faceți cu ele.",
"send_stale_send": "Trimite oricum",
"send_stale_back": "Înapoi",
"signout_title": "Vă deconectați și ștergeți modificările?",
"signout_detail": "Atașamentele editate, dar neatașate și nesalvate, sunt șterse la deconectare.",
"signout_confirm": "Deconectare",
"lock_note": "Fișierele deschise cu Editează rămân deschise în programul lor cât timp aplicația este blocată și rămân lizibile pe acest computer. Altele noi nu se pot deschide până la deblocare."
},
"desktopReminders": {
"generic_title": "Memento",
"snooze": "Amână {count} min",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Не удалось скопировать файл.",
"reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
},
"desktopEdit": {
"edit": "Изменить",
"edit_named": "Изменить {name}",
"opened": "{name} открыт в своей программе. Сохраните там изменения, и вас спросят, что с ними сделать.",
"offers_label": "Изменённые вложения",
"changed_title": "{name} изменён",
"changed_body": "Вы сохранили изменения в другой программе. Что с ними сделать?",
"reply": "Вложить в ответ",
"new_message": "Вложить в новое письмо",
"open_draft": "Открыть черновик",
"save_copy": "Сохранить копию…",
"discard": "Отменить изменения…",
"discard_title": "Отменить изменения в {name}?",
"discard_detail": "Изменённая копия будет удалена. Исходное вложение не изменится.",
"discard_confirm": "Отменить изменения",
"cancel": "Отмена",
"update": "Обновить вложение",
"no_app": "Ни одна программа на этом компьютере не может открыть этот файл.",
"blocked_type": "Файл такого типа нельзя открыть для изменения: он может запуститься как программа.",
"blocked_policy": "Ваша организация не разрешает сохранять вложения на этом устройстве.",
"removed_policy": "Ваша организация больше этого не разрешает, поэтому изменённая копия {name} удалена.",
"too_large": "Этот файл слишком большой, чтобы открыть его для изменения.",
"too_many": "Для изменения открыто слишком много файлов. Сначала закончите с некоторыми из них.",
"failed": "Не удалось открыть файл для изменения.",
"read_busy": "Другая программа ещё использует {name}. Закройте его там и повторите попытку.",
"read_failed": "Не удалось прочитать изменённую копию {name}.",
"send_stale_title": "В {name} есть изменения, которых нет в этом письме",
"send_stale_detail": "Если отправить сейчас, письмо уйдёт со старой версией. Ваши изменения останутся на этом компьютере, пока вы не решите, что с ними делать.",
"send_stale_send": "Всё равно отправить",
"send_stale_back": "Назад",
"signout_title": "Выйти и удалить изменения?",
"signout_detail": "Вложения, которые вы изменили, но не вложили и не сохранили, при выходе удаляются.",
"signout_confirm": "Выйти",
"lock_note": "Файлы, открытые через «Изменить», остаются открытыми в своей программе, пока приложение заблокировано, и их по-прежнему можно прочитать на этом компьютере. Новые нельзя открыть, пока вы не разблокируете приложение."
},
"desktopReminders": {
"generic_title": "Напоминание",
"snooze": "Отложить на {count} мин",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Súbor sa nepodarilo skopírovať.",
"reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej."
},
"desktopEdit": {
"edit": "Upraviť",
"edit_named": "Upraviť {name}",
"opened": "{name} je otvorený vo svojom programe. Uložte tam zmeny a potom sa vás spýtame, čo s nimi urobiť.",
"offers_label": "Upravené prílohy",
"changed_title": "{name} sa zmenil",
"changed_body": "Uložili ste zmeny v inom programe. Čo s nimi chcete urobiť?",
"reply": "Pripojiť k odpovedi",
"new_message": "Pripojiť k novej správe",
"open_draft": "Otvoriť koncept",
"save_copy": "Uložiť kópiu…",
"discard": "Zahodiť zmeny…",
"discard_title": "Zahodiť zmeny v {name}?",
"discard_detail": "Upravená kópia sa odstráni. Pôvodná príloha sa nezmení.",
"discard_confirm": "Zahodiť",
"cancel": "Zrušiť",
"update": "Aktualizovať prílohu",
"no_app": "Tento súbor nedokáže otvoriť žiadny program v tomto počítači.",
"blocked_type": "Tento typ súboru nemožno otvoriť na úpravy, pretože by sa mohol spustiť ako program.",
"blocked_policy": "Vaša organizácia nepovoľuje ukladať prílohy do tohto zariadenia.",
"removed_policy": "Vaša organizácia to už nepovoľuje, preto bola upravená kópia {name} odstránená.",
"too_large": "Tento súbor je príliš veľký na otvorenie na úpravy.",
"too_many": "Na úpravy je otvorených príliš veľa súborov. Niektoré najprv dokončite.",
"failed": "Súbor sa nepodarilo otvoriť na úpravy.",
"read_busy": "{name} stále používa iný program. Zatvorte ho tam a skúste to znova.",
"read_failed": "Upravenú kópiu {name} sa nepodarilo prečítať.",
"send_stale_title": "{name} má zmeny, ktoré v tejto správe nie sú",
"send_stale_detail": "Ak odošlete teraz, správa odíde so staršou verziou. Vaše zmeny zostanú v tomto počítači, kým sa nerozhodnete, čo s nimi.",
"send_stale_send": "Napriek tomu odoslať",
"send_stale_back": "Späť",
"signout_title": "Odhlásiť sa a odstrániť zmeny?",
"signout_detail": "Prílohy, ktoré ste upravili, ale nepripojili ani neuložili, sa pri odhlásení odstránia.",
"signout_confirm": "Odhlásiť sa",
"lock_note": "Súbory otvorené cez Upraviť zostávajú otvorené vo svojom programe, kým je aplikácia zamknutá, a zostávajú v tomto počítači čitateľné. Nové sa otvoria až po odomknutí."
},
"desktopReminders": {
"generic_title": "Pripomienka",
"snooze": "Odložiť o {count} min",
+35
View File
@@ -6966,6 +6966,41 @@
"copy_failed": "คัดลอกไฟล์ไม่ได้",
"reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
},
"desktopEdit": {
"edit": "แก้ไข",
"edit_named": "แก้ไข {name}",
"opened": "{name} เปิดอยู่ในโปรแกรมของไฟล์นั้น บันทึกการเปลี่ยนแปลงที่นั่น แล้วระบบจะถามว่าจะทำอย่างไรกับการเปลี่ยนแปลงนั้น",
"offers_label": "ไฟล์แนบที่แก้ไขแล้ว",
"changed_title": "{name} มีการเปลี่ยนแปลง",
"changed_body": "คุณบันทึกการเปลี่ยนแปลงในโปรแกรมอื่น จะให้ทำอย่างไรกับการเปลี่ยนแปลงนั้น",
"reply": "แนบไปกับการตอบกลับ",
"new_message": "แนบไปกับข้อความใหม่",
"open_draft": "เปิดฉบับร่าง",
"save_copy": "บันทึกสำเนา…",
"discard": "ละทิ้งการแก้ไข…",
"discard_title": "ละทิ้งการแก้ไขใน {name} หรือไม่",
"discard_detail": "สำเนาที่แก้ไขจะถูกลบ ไฟล์แนบต้นฉบับจะไม่เปลี่ยนแปลง",
"discard_confirm": "ละทิ้ง",
"cancel": "ยกเลิก",
"update": "อัปเดตไฟล์แนบ",
"no_app": "ไม่มีโปรแกรมในคอมพิวเตอร์นี้ที่เปิดไฟล์นี้ได้",
"blocked_type": "เปิดไฟล์ประเภทนี้เพื่อแก้ไขไม่ได้ เพราะอาจทำงานเป็นโปรแกรมได้",
"blocked_policy": "องค์กรของคุณไม่อนุญาตให้บันทึกไฟล์แนบในอุปกรณ์นี้",
"removed_policy": "องค์กรของคุณไม่อนุญาตอีกต่อไป จึงลบสำเนาที่แก้ไขของ {name} แล้ว",
"too_large": "ไฟล์นี้ใหญ่เกินกว่าจะเปิดเพื่อแก้ไข",
"too_many": "มีไฟล์ที่เปิดเพื่อแก้ไขมากเกินไป โปรดจัดการบางไฟล์ให้เสร็จก่อน",
"failed": "เปิดไฟล์เพื่อแก้ไขไม่ได้",
"read_busy": "โปรแกรมอื่นยังใช้ {name} อยู่ โปรดปิดที่นั่นแล้วลองอีกครั้ง",
"read_failed": "อ่านสำเนาที่แก้ไขของ {name} ไม่ได้",
"send_stale_title": "{name} มีการแก้ไขที่ไม่ได้อยู่ในข้อความนี้",
"send_stale_detail": "หากส่งตอนนี้ ข้อความจะส่งไปพร้อมเวอร์ชันเก่า การแก้ไขของคุณจะอยู่ในคอมพิวเตอร์นี้จนกว่าคุณจะตัดสินใจ",
"send_stale_send": "ส่งต่อไป",
"send_stale_back": "ย้อนกลับ",
"signout_title": "ออกจากระบบและลบการแก้ไขหรือไม่",
"signout_detail": "ไฟล์แนบที่คุณแก้ไขแต่ยังไม่ได้แนบหรือบันทึกจะถูกลบเมื่อออกจากระบบ",
"signout_confirm": "ออกจากระบบ",
"lock_note": "ไฟล์ที่เปิดด้วยแก้ไขจะยังเปิดอยู่ในโปรแกรมของไฟล์นั้นขณะที่แอปถูกล็อก และยังอ่านได้ในคอมพิวเตอร์นี้ ไฟล์ใหม่จะเปิดไม่ได้จนกว่าคุณจะปลดล็อก"
},
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
"snooze": "เลื่อน {count} นาที",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Dosya kopyalanamadı.",
"reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın."
},
"desktopEdit": {
"edit": "Düzenle",
"edit_named": "{name} dosyasını düzenle",
"opened": "{name} kendi programında açık. Değişikliklerinizi orada kaydedin, ardından bunlarla ne yapılacağı size sorulur.",
"offers_label": "Düzenlenen ekler",
"changed_title": "{name} değişti",
"changed_body": "Başka bir programda değişiklik kaydettiniz. Bunlarla ne yapılsın?",
"reply": "Bir yanıta ekle",
"new_message": "Yeni bir iletiye ekle",
"open_draft": "Taslağı aç",
"save_copy": "Kopya kaydet…",
"discard": "Değişikliklerden vazgeç…",
"discard_title": "{name} üzerindeki değişikliklerden vazgeçilsin mi?",
"discard_detail": "Düzenlenen kopya silinir. Asıl ek değişmez.",
"discard_confirm": "Vazgeç",
"cancel": "İptal",
"update": "Eki güncelle",
"no_app": "Bu bilgisayardaki hiçbir program bu dosyayı açamıyor.",
"blocked_type": "Bu dosya türü düzenlemek için açılamaz, çünkü program olarak çalışabilir.",
"blocked_policy": "Kuruluşunuz bu cihaza ek kaydedilmesine izin vermiyor.",
"removed_policy": "Kuruluşunuz artık buna izin vermiyor, bu yüzden {name} dosyasının düzenlenen kopyası silindi.",
"too_large": "Bu dosya düzenlemek için açılamayacak kadar büyük.",
"too_many": "Düzenlemek için çok fazla dosya açık. Önce bazılarını bitirin.",
"failed": "Dosya düzenlemek için açılamadı.",
"read_busy": "{name} hâlâ başka bir program tarafından kullanılıyor. Orada kapatıp yeniden deneyin.",
"read_failed": "{name} dosyasının düzenlenen kopyası okunamadı.",
"send_stale_title": "{name} dosyasında bu iletide olmayan değişiklikler var",
"send_stale_detail": "Şimdi gönderirseniz ileti eski sürümle gider. Değişiklikleriniz, ne yapacağınıza karar verene kadar bu bilgisayarda kalır.",
"send_stale_send": "Yine de gönder",
"send_stale_back": "Geri dön",
"signout_title": "Oturum kapatılıp değişiklikler silinsin mi?",
"signout_detail": "Düzenleyip eklemediğiniz veya kaydetmediğiniz ekler oturumu kapattığınızda silinir.",
"signout_confirm": "Oturumu kapat",
"lock_note": "Düzenle ile açtığınız dosyalar uygulama kilitliyken kendi programlarında açık kalır ve bu bilgisayarda okunabilir olmayı sürdürür. Yenileri kilidi açana kadar açılamaz."
},
"desktopReminders": {
"generic_title": "Anımsatıcı",
"snooze": "{count} dk ertele",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "Не вдалося скопіювати файл.",
"reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
},
"desktopEdit": {
"edit": "Редагувати",
"edit_named": "Редагувати {name}",
"opened": "{name} відкрито у своїй програмі. Збережіть там зміни, і вас запитають, що з ними робити.",
"offers_label": "Відредаговані вкладення",
"changed_title": "{name} змінено",
"changed_body": "Ви зберегли зміни в іншій програмі. Що з ними зробити?",
"reply": "Вкласти у відповідь",
"new_message": "Вкласти в новий лист",
"open_draft": "Відкрити чернетку",
"save_copy": "Зберегти копію…",
"discard": "Відкинути зміни…",
"discard_title": "Відкинути зміни в {name}?",
"discard_detail": "Відредаговану копію буде видалено. Початкове вкладення не зміниться.",
"discard_confirm": "Відкинути",
"cancel": "Скасувати",
"update": "Оновити вкладення",
"no_app": "Жодна програма на цьому комп’ютері не може відкрити цей файл.",
"blocked_type": "Файл такого типу не можна відкрити для редагування, бо він може запуститися як програма.",
"blocked_policy": "Ваша організація не дозволяє зберігати вкладення на цьому пристрої.",
"removed_policy": "Ваша організація більше цього не дозволяє, тому відредаговану копію {name} видалено.",
"too_large": "Цей файл завеликий, щоб відкрити його для редагування.",
"too_many": "Для редагування відкрито забагато файлів. Спершу завершіть роботу з деякими з них.",
"failed": "Не вдалося відкрити файл для редагування.",
"read_busy": "Інша програма ще використовує {name}. Закрийте його там і спробуйте ще раз.",
"read_failed": "Не вдалося прочитати відредаговану копію {name}.",
"send_stale_title": "У {name} є зміни, яких немає в цьому листі",
"send_stale_detail": "Якщо надіслати зараз, лист піде зі старішою версією. Ваші зміни залишаться на цьому комп’ютері, доки ви не вирішите, що з ними робити.",
"send_stale_send": "Усе одно надіслати",
"send_stale_back": "Назад",
"signout_title": "Вийти й видалити зміни?",
"signout_detail": "Вкладення, які ви відредагували, але не вклали й не зберегли, під час виходу видаляються.",
"signout_confirm": "Вийти",
"lock_note": "Файли, відкриті через «Редагувати», залишаються відкритими у своїй програмі, поки застосунок заблоковано, і їх усе ще можна прочитати на цьому комп’ютері. Нові не можна відкрити, доки ви не розблокуєте застосунок."
},
"desktopReminders": {
"generic_title": "Нагадування",
"snooze": "Відкласти на {count} хв",
+35
View File
@@ -6970,6 +6970,41 @@
"copy_failed": "Không thể sao chép tệp.",
"reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó."
},
"desktopEdit": {
"edit": "Chỉnh sửa",
"edit_named": "Chỉnh sửa {name}",
"opened": "{name} đang mở trong chương trình của nó. Hãy lưu thay đổi ở đó, rồi bạn sẽ được hỏi muốn làm gì với chúng.",
"offers_label": "Tệp đính kèm đã chỉnh sửa",
"changed_title": "{name} đã thay đổi",
"changed_body": "Bạn đã lưu thay đổi trong một chương trình khác. Bạn muốn làm gì với chúng?",
"reply": "Đính kèm vào thư trả lời",
"new_message": "Đính kèm vào thư mới",
"open_draft": "Mở bản nháp",
"save_copy": "Lưu một bản sao…",
"discard": "Bỏ thay đổi…",
"discard_title": "Bỏ các thay đổi của bạn trong {name}?",
"discard_detail": "Bản sao đã chỉnh sửa sẽ bị xóa. Tệp đính kèm gốc không thay đổi.",
"discard_confirm": "Bỏ",
"cancel": "Hủy",
"update": "Cập nhật tệp đính kèm",
"no_app": "Không có chương trình nào trên máy tính này mở được tệp này.",
"blocked_type": "Không thể mở loại tệp này để chỉnh sửa vì nó có thể chạy như một chương trình.",
"blocked_policy": "Tổ chức của bạn không cho phép lưu tệp đính kèm trên thiết bị này.",
"removed_policy": "Tổ chức của bạn không còn cho phép việc này, nên bản sao đã chỉnh sửa của {name} đã bị xóa.",
"too_large": "Tệp này quá lớn để mở chỉnh sửa.",
"too_many": "Có quá nhiều tệp đang mở để chỉnh sửa. Hãy hoàn tất một vài tệp trước.",
"failed": "Không thể mở tệp để chỉnh sửa.",
"read_busy": "Một chương trình khác vẫn đang dùng {name}. Hãy đóng nó ở đó rồi thử lại.",
"read_failed": "Không đọc được bản sao đã chỉnh sửa của {name}.",
"send_stale_title": "{name} có thay đổi không nằm trong thư này",
"send_stale_detail": "Nếu gửi ngay, thư sẽ đi với phiên bản cũ hơn. Thay đổi của bạn vẫn ở trên máy tính này cho đến khi bạn quyết định.",
"send_stale_send": "Vẫn gửi",
"send_stale_back": "Quay lại",
"signout_title": "Đăng xuất và xóa thay đổi của bạn?",
"signout_detail": "Các tệp bạn đã chỉnh sửa nhưng chưa đính kèm hoặc lưu sẽ bị xóa khi đăng xuất.",
"signout_confirm": "Đăng xuất",
"lock_note": "Các tệp đã mở bằng Chỉnh sửa vẫn mở trong chương trình của chúng khi ứng dụng bị khóa và vẫn đọc được trên máy tính này. Không thể mở tệp mới cho đến khi bạn mở khóa."
},
"desktopReminders": {
"generic_title": "Lời nhắc",
"snooze": "Báo lại sau {count} phút",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "無法複製檔案。",
"reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
},
"desktopEdit": {
"edit": "編輯",
"edit_named": "編輯 {name}",
"opened": "{name} 已在其程式中開啟。請在那裡儲存變更,之後會詢問你要如何處理這些變更。",
"offers_label": "已編輯的附件",
"changed_title": "{name} 已變更",
"changed_body": "你在其他程式中儲存了變更。要如何處理?",
"reply": "附加到回覆",
"new_message": "附加到新郵件",
"open_draft": "開啟草稿",
"save_copy": "儲存複本…",
"discard": "捨棄編輯…",
"discard_title": "要捨棄對 {name} 的編輯嗎?",
"discard_detail": "編輯後的複本會被刪除。原始附件不會改變。",
"discard_confirm": "捨棄",
"cancel": "取消",
"update": "更新附件",
"no_app": "這部電腦上沒有程式能開啟此檔案。",
"blocked_type": "此類檔案可能以程式方式執行,因此無法開啟進行編輯。",
"blocked_policy": "你的組織不允許在此裝置上儲存附件。",
"removed_policy": "你的組織已不再允許此操作,因此 {name} 的編輯複本已被刪除。",
"too_large": "此檔案太大,無法開啟進行編輯。",
"too_many": "開啟編輯的檔案太多。請先處理完其中一些。",
"failed": "無法開啟檔案進行編輯。",
"read_busy": "其他程式仍在使用 {name}。請在那裡關閉後再試一次。",
"read_failed": "無法讀取 {name} 的編輯複本。",
"send_stale_title": "{name} 有未包含在此郵件中的編輯",
"send_stale_detail": "如果現在傳送,郵件將使用舊版本。你的編輯會保留在這部電腦上,直到你決定如何處理。",
"send_stale_send": "仍要傳送",
"send_stale_back": "返回",
"signout_title": "要登出並刪除你的編輯嗎?",
"signout_detail": "已編輯但未附加或儲存的附件會在登出時刪除。",
"signout_confirm": "登出",
"lock_note": "應用程式鎖定期間,透過「編輯」開啟的檔案仍在其程式中保持開啟,並且在這部電腦上仍可讀取。解鎖前無法開啟新檔案。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分鐘後再提醒",
+35
View File
@@ -6958,6 +6958,41 @@
"copy_failed": "无法复制文件。",
"reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
},
"desktopEdit": {
"edit": "编辑",
"edit_named": "编辑 {name}",
"opened": "{name} 已在其程序中打开。请在那里保存更改,之后会询问你如何处理这些更改。",
"offers_label": "已编辑的附件",
"changed_title": "{name} 已更改",
"changed_body": "你在其他程序中保存了更改。要如何处理?",
"reply": "附加到回复",
"new_message": "附加到新邮件",
"open_draft": "打开草稿",
"save_copy": "保存副本…",
"discard": "放弃编辑…",
"discard_title": "要放弃对 {name} 的编辑吗?",
"discard_detail": "编辑后的副本将被删除。原始附件不会改变。",
"discard_confirm": "放弃",
"cancel": "取消",
"update": "更新附件",
"no_app": "这台电脑上没有程序能打开此文件。",
"blocked_type": "此类文件可能作为程序运行,因此无法打开进行编辑。",
"blocked_policy": "你的组织不允许在此设备上保存附件。",
"removed_policy": "你的组织已不再允许此操作,因此 {name} 的编辑副本已被删除。",
"too_large": "此文件太大,无法打开进行编辑。",
"too_many": "打开编辑的文件太多。请先处理完其中一些。",
"failed": "无法打开文件进行编辑。",
"read_busy": "其他程序仍在使用 {name}。请在那里关闭后重试。",
"read_failed": "无法读取 {name} 的编辑副本。",
"send_stale_title": "{name} 有未包含在此邮件中的编辑",
"send_stale_detail": "如果现在发送,邮件将使用旧版本。你的编辑会保留在这台电脑上,直到你决定如何处理。",
"send_stale_send": "仍然发送",
"send_stale_back": "返回",
"signout_title": "要退出登录并删除你的编辑吗?",
"signout_detail": "已编辑但未附加或保存的附件会在退出登录时删除。",
"signout_confirm": "退出登录",
"lock_note": "应用锁定期间,通过“编辑”打开的文件仍在其程序中保持打开,并且在这台电脑上仍可读取。解锁前无法打开新文件。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分钟后再提醒",