feat: undo marking read or unread, stars and tags from the toast or with Ctrl+Z

This commit is contained in:
Linus Rath
2026-10-29 17:53:47 +01:00
parent 4af4404962
commit ee17c0dbaf
34 changed files with 447 additions and 64 deletions
+12 -7
View File
@@ -34,7 +34,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { toast } from "@/stores/toast-store";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { SearchChips } from "@/components/search/search-chips";
@@ -193,8 +193,6 @@ export function EmailList({
selectedEmailKeys,
selectAllEmails: _selectAllEmails,
clearSelection,
batchMarkAsRead,
batchSetTag,
batchDelete,
batchMoveToMailbox,
batchArchive,
@@ -225,8 +223,6 @@ export function EmailList({
selectedEmailKeys: state.selectedEmailKeys,
selectAllEmails: state.selectAllEmails,
clearSelection: state.clearSelection,
batchMarkAsRead: state.batchMarkAsRead,
batchSetTag: state.batchSetTag,
batchDelete: state.batchDelete,
batchMoveToMailbox: state.batchMoveToMailbox,
batchArchive: state.batchArchive,
@@ -564,8 +560,13 @@ export function EmailList({
// The store reports a refused message (a read-only shared folder) through
// its error; the rest of the selection is marked.
// A toast with Undo (and Ctrl+Z) puts the messages back as they were.
const markSelectionRead = (read: boolean) => client
? runBatchEmailAction(() => batchMarkAsRead(client, read), { error: tNotifications('error_updating') })
? markSelectionReadWithUndo(client, read, {
success: tNotifications(read ? 'emails_marked_read' : 'emails_marked_unread', { count: selectedEmailKeys.size }),
error: tNotifications('error_updating'),
labels: undo,
})
: Promise.resolve();
const handleBatchMarkAsRead = async (read: boolean) => {
@@ -598,7 +599,11 @@ export function EmailList({
const handleBatchToggleTag = async (tagId: string) => {
if (!client) return;
try {
await batchSetTag(client, tagId, !selectionTags.all.includes(tagId));
const add = !selectionTags.all.includes(tagId);
await setSelectionTagWithUndo(client, tagId, add, {
describe: (count) => tNotifications(add ? 'emails_tagged' : 'tag_removed_count', { count }),
labels: undo,
});
} catch (error) {
console.error("Failed to tag emails:", error);
toast.error(tNotifications('error_updating'));
+17 -8
View File
@@ -24,7 +24,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, restoreBatchesForSelection , type CrossAccountMoveReceipt } from "@/stores/email-store";
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { toast } from "@/stores/toast-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -130,11 +130,9 @@ export function MvListToolbar({
clearSelection,
batchArchive,
batchDelete,
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
batchMoveToMailbox,
batchSetTag,
isUnifiedView,
unifiedRole,
} = useEmailStore(useShallow((state) => ({
@@ -148,11 +146,9 @@ export function MvListToolbar({
clearSelection: state.clearSelection,
batchArchive: state.batchArchive,
batchDelete: state.batchDelete,
batchMarkAsRead: state.batchMarkAsRead,
batchMarkAsSpam: state.batchMarkAsSpam,
batchUndoSpam: state.batchUndoSpam,
batchMoveToMailbox: state.batchMoveToMailbox,
batchSetTag: state.batchSetTag,
isUnifiedView: state.isUnifiedView,
unifiedRole: state.unifiedRole,
})));
@@ -223,6 +219,14 @@ export function MvListToolbar({
}
};
// With a toast that offers Undo (and Ctrl+Z), as in the default toolbar.
const markSelectionRead = (activeClient: NonNullable<typeof client>, read: boolean) =>
markSelectionReadWithUndo(activeClient, read, {
success: tNotifications(read ? "emails_marked_read" : "emails_marked_unread", { count: selectionCount }),
error: tNotifications("error_updating"),
labels: undo,
});
const handleSelectAllToggle = () => {
if (disabled) return;
if (hasSelection) {
@@ -347,7 +351,12 @@ export function MvListToolbar({
const handleToggleTag = (tagId: string) => {
const add = !appliedTags.includes(tagId);
void run(async () => {
if (client) await batchSetTag(client, tagId, add);
if (client) {
await setSelectionTagWithUndo(client, tagId, add, {
describe: (count) => tNotifications(add ? "emails_tagged" : "tag_removed_count", { count }),
labels: undo,
});
}
});
};
@@ -509,13 +518,13 @@ export function MvListToolbar({
<ToolbarButton
icon={MailOpen}
label={tBatch("mark_read")}
onClick={() => run(async () => { if (client) await batchMarkAsRead(client, true); })}
onClick={() => run(async () => { if (client) await markSelectionRead(client, true); })}
disabled={isProcessing}
/>
<ToolbarButton
icon={Mail}
label={tBatch("mark_unread")}
onClick={() => run(async () => { if (client) await batchMarkAsRead(client, false); })}
onClick={() => run(async () => { if (client) await markSelectionRead(client, false); })}
disabled={isProcessing}
/>
<div className="w-px h-6 bg-border mx-1 shrink-0" />
+60 -19
View File
@@ -47,7 +47,7 @@ import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
import { runBatchEmailAction, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { keywordRestorePoints, markSelectionReadWithUndo, recordQuietUndo, runBatchEmailAction, toastWithUndo, undoKeywordAction, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
@@ -492,7 +492,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
deleteMailbox,
batchDelete,
batchArchive,
batchMarkAsRead,
batchMarkAsSpam,
batchUndoSpam,
accountMailboxes,
@@ -582,7 +581,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
deleteMailbox: state.deleteMailbox,
batchDelete: state.batchDelete,
batchArchive: state.batchArchive,
batchMarkAsRead: state.batchMarkAsRead,
batchMarkAsSpam: state.batchMarkAsSpam,
batchUndoSpam: state.batchUndoSpam,
accountMailboxes: state.accountMailboxes,
@@ -871,6 +869,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
toast.error(t('notifications.error_updating'));
};
// A message the user marks read or unread, or stars: no toast, but Ctrl+Z
// puts it back and a screen reader hears what happened. Not for the
// automatic mark-as-read on opening a message.
const markReadWithUndo = async (email: Email, read: boolean): Promise<void> => {
if (!client) return;
const points = keywordRestorePoints([email], ['$seen'], { $seen: read });
if (!(await markAsRead(client, email.id, read, emailKeyFor(email)))) return;
recordQuietUndo(
t(read ? 'notifications.email_marked_read' : 'notifications.email_marked_unread'),
undoKeywordAction(client, points, undo),
);
};
const toggleStarWithUndo = async (email: Email): Promise<void> => {
if (!client) return;
const wasStarred = email.keywords?.$flagged === true;
const points = keywordRestorePoints([email], ['$flagged']);
if (!(await toggleStar(client, email.id, emailKeyFor(email)))) return;
recordQuietUndo(
t(wasStarred ? 'notifications.email_unstarred' : 'notifications.email_starred'),
undoKeywordAction(client, points, undo),
);
};
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
@@ -989,18 +1010,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isScheduledView) return;
if (!client) return;
if (shortcutsTargetSelection) {
await runBatchEmailAction(() => batchMarkAsRead(client, false), { error: t('notifications.error_updating') });
await markSelectionReadWithUndo(client, false, {
success: t('notifications.emails_marked_unread', { count: selectedEmailKeys.size }),
error: t('notifications.error_updating'),
labels: undo,
});
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, false, emailKeyFor(selectedEmail)).catch(toastUpdateFailure);
await markReadWithUndo(selectedEmail, false).catch(toastUpdateFailure);
}
},
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (shortcutsTargetSelection) {
await runBatchEmailAction(() => batchMarkAsRead(client, true), { error: t('notifications.error_updating') });
await markSelectionReadWithUndo(client, true, {
success: t('notifications.emails_marked_read', { count: selectedEmailKeys.size }),
error: t('notifications.error_updating'),
labels: undo,
});
} else if (selectedEmail) {
await markAsRead(client, selectedEmail.id, true, emailKeyFor(selectedEmail)).catch(toastUpdateFailure);
await markReadWithUndo(selectedEmail, true).catch(toastUpdateFailure);
}
},
onToggleSpam: async () => {
@@ -3089,7 +3118,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!client || !selectedEmail) return;
try {
await toggleStar(client, selectedEmail.id, emailKeyFor(selectedEmail));
await toggleStarWithUndo(selectedEmail);
} catch (error) {
console.error("Failed to toggle star:", error);
toast.error(t('notifications.error_updating'));
@@ -3190,7 +3219,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// call site covered only the unified view, so a tag set on a directly
// selected shared folder was written to the reaching account and silently
// dropped by the server. (#281)
await patchEmailKeywords(client, emailId, changes, emailKeyFor(email));
const names = Object.keys(changes);
const points = keywordRestorePoints([email], names);
if (await patchEmailKeywords(client, emailId, changes, emailKeyFor(email)) && names.length > 0) {
// Ctrl+Z puts the tags back as they were.
const added = Object.values(changes).some(Boolean);
recordQuietUndo(t(added ? 'notifications.email_tagged' : 'notifications.tag_removed'), undoKeywordAction(client, points, undo));
}
// Refresh tag counts
fetchTagCounts(client);
@@ -4280,12 +4315,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{
id: 'mark-unread', label: t('context_menu.mark_unread'), icon: Mail, shortcut: 'u',
when: () => !!paletteEmail?.keywords?.$seen,
run: () => { if (client && paletteEmail) void markAsRead(client, paletteEmail.id, false, emailKeyFor(paletteEmail)).catch(toastUpdateFailure); },
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, false).catch(toastUpdateFailure); },
},
{
id: 'mark-read', label: t('context_menu.mark_read'), icon: MailOpen, shortcut: 'Shift + I',
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
run: () => { if (client && paletteEmail) void markAsRead(client, paletteEmail.id, true, emailKeyFor(paletteEmail)).catch(toastUpdateFailure); },
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
},
{
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
@@ -5245,14 +5280,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}}
onMarkAsRead={async (email, read) => {
if (client) {
await markAsRead(client, email.id, read, emailKeyFor(email)).catch(toastUpdateFailure);
}
await markReadWithUndo(email, read).catch(toastUpdateFailure);
}}
onToggleStar={async (email) => {
if (client) {
await toggleStar(client, email.id, emailKeyFor(email)).catch(toastUpdateFailure);
}
await toggleStarWithUndo(email).catch(toastUpdateFailure);
}}
onTogglePinned={async (email) => {
await handleTogglePinned(email);
@@ -5427,11 +5458,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMoveToMailbox: (mailboxId: string) => leaving(() => handleMoveToMailbox(target.id, mailboxId, emailKeyFor(target)))(),
onForwardAsAttachment: () => void handleForwardAsAttachment(target),
onMarkAsRead: client ? (read: boolean) => {
const run = () => markAsRead(client, target.id, read, emailKeyFor(target)).catch(toastUpdateFailure);
const run = () => markReadWithUndo(target, read).catch(toastUpdateFailure);
// Marked unread, the conversation is done with for now.
if (read) void run(); else leaving(run)();
} : undefined,
onToggleStar: client ? () => void toggleStar(client, target.id, emailKeyFor(target)).catch(toastUpdateFailure) : undefined,
onToggleStar: client ? () => void toggleStarWithUndo(target).catch(toastUpdateFailure) : undefined,
onTogglePinned: () => void handleTogglePinned(target),
onSetTag: (tagId: string | null) => void handleSetTag(target.id, tagId, emailKeyFor(target)),
onMarkAsSpam: conversationSpamApplicable && !conversationInJunk ? leaving(() => handleMarkAsSpam(target)) : undefined,
@@ -5470,6 +5501,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onMarkAsRead={async (emailId, read, opts) => {
if (client) {
const emailKey = selectedEmail?.id === emailId ? emailKeyFor(selectedEmail) : undefined;
// Asked for (not on opening): Ctrl+Z can put it back.
const points = !opts?.automatic && selectedEmail?.id === emailId
? keywordRestorePoints([selectedEmail], ['$seen'], { $seen: read })
: [];
try {
await markAsRead(client, emailId, read, emailKey);
} catch (error) {
@@ -5482,6 +5517,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const nowRead = !!useEmailStore.getState().emails
.find((e) => (emailKey ? emailKeyFor(e) === emailKey : e.id === emailId))?.keywords?.$seen;
if (nowRead !== read) return; // a plugin stopped it
if (!opts?.automatic) {
recordQuietUndo(
t(read ? 'notifications.email_marked_read' : 'notifications.email_marked_unread'),
undoKeywordAction(client, points, undo),
);
}
// Marking the open message unread returns to the list
// (Gmail-style, gated on returnToListAfterAction). Staying
// in the reading pane would just re-mark it read on view.
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
keywordRestorePoints, markSelectionReadWithUndo, recordQuietUndo, setSelectionTagWithUndo, undoKeywordAction,
type UndoLabels,
} from '../email-action-toast';
import { clearUndoHistory, undoHistory, undoLatest } from '../undo-history';
import { useEmailStore } from '@/stores/email-store';
import { useToastStore } from '@/stores/toast-store';
import type { Email } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
const labels: UndoLabels = { undo: 'Undo', done: 'Moved back', failed: "Couldn't undo", undone: 'Undone' };
const client = {} as IJMAPClient;
const email = (id: string, keywords: Record<string, boolean> = {}) => ({ id, keywords, mailboxIds: { inbox: true } }) as unknown as Email;
let patchEmailKeywords: ReturnType<typeof vi.fn>;
beforeEach(() => {
clearUndoHistory();
useToastStore.setState({ toasts: [] });
patchEmailKeywords = vi.fn().mockResolvedValue(true);
useEmailStore.setState({
emails: [email('read', { $seen: true }), email('unread'), email('tagged', { '$label:red': true })],
threadEmailsCache: new Map(),
selectedEmailKeys: new Set(['read', 'unread']),
error: null,
patchEmailKeywords: patchEmailKeywords as never,
});
});
describe('keyword restore points', () => {
it('keeps the values the keywords have now, leaving out messages already in the wanted state', () => {
const points = keywordRestorePoints(useEmailStore.getState().emails, ['$seen'], { $seen: true });
expect(points).toEqual([
{ id: 'unread', key: 'unread', changes: { $seen: false } },
{ id: 'tagged', key: 'tagged', changes: { $seen: false } },
]);
});
});
describe('undo for read, flag and tag changes', () => {
it('marks the selection read with a toast whose Undo puts back only what changed', async () => {
const batchMarkAsRead = vi.fn().mockResolvedValue(true);
useEmailStore.setState({ batchMarkAsRead: batchMarkAsRead as never });
await markSelectionReadWithUndo(client, true, { success: '2 emails marked as read', error: 'Failed', labels });
expect(batchMarkAsRead).toHaveBeenCalledWith(client, true);
const shown = useToastStore.getState().toasts.at(-1)!;
expect(shown.title).toBe('2 emails marked as read');
expect(shown.action?.label).toBe('Undo');
// Ctrl+Z (the history) or the toast: the same Undo.
undoLatest();
await vi.waitFor(() => expect(patchEmailKeywords).toHaveBeenCalledTimes(1));
expect(patchEmailKeywords).toHaveBeenCalledWith(client, 'unread', { $seen: false }, 'unread');
await vi.waitFor(() => expect(useToastStore.getState().toasts.map((t) => t.title)).toContain('Undone'));
});
it('keeps an Undo for a tag put on the selection, without a toast', async () => {
const batchSetTag = vi.fn().mockResolvedValue(true);
useEmailStore.setState({ batchSetTag: batchSetTag as never, selectedEmailKeys: new Set(['unread', 'tagged']) });
await setSelectionTagWithUndo(client, 'red', true, { describe: (n) => `${n} emails tagged`, labels });
expect(batchSetTag).toHaveBeenCalledWith(client, 'red', true);
expect(useToastStore.getState().toasts).toHaveLength(0);
expect(undoHistory().map((e) => e.label)).toEqual(['1 emails tagged']);
undoLatest();
await vi.waitFor(() => expect(patchEmailKeywords).toHaveBeenCalledTimes(1));
// Only the message that got the tag loses it again.
expect(patchEmailKeywords).toHaveBeenCalledWith(client, 'unread', { '$label:red': false, '$color:red': false }, 'unread');
});
it('keeps no Undo when a plugin stopped the tag change', async () => {
useEmailStore.setState({ batchSetTag: vi.fn().mockResolvedValue(false) as never });
await setSelectionTagWithUndo(client, 'red', true, { describe: () => 'tagged', labels });
expect(undoHistory()).toHaveLength(0);
});
it('says when the undo failed', async () => {
patchEmailKeywords.mockRejectedValue(new Error('read-only'));
vi.spyOn(console, 'error').mockImplementation(() => {});
recordQuietUndo('Email starred', undoKeywordAction(client, [{ id: 'x', key: 'x', changes: { $flagged: false } }], labels));
undoLatest();
await vi.waitFor(() => expect(useToastStore.getState().toasts.map((t) => t.title)).toContain("Couldn't undo"));
});
});
+123 -1
View File
@@ -1,9 +1,12 @@
import { toast } from "@/stores/toast-store";
import { useEmailStore, type EmailRestoreBatch, type CrossAccountMoveReceipt } from "@/stores/email-store";
import { useEmailStore, resolveSelectedEmails, type EmailRestoreBatch, type CrossAccountMoveReceipt } from "@/stores/email-store";
import type { ToastAction } from "@/components/ui/toast";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Email } from "@/lib/jmap/types";
import { linkUndoToast, recordUndo, runUndo } from "@/lib/undo-history";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { emailKeyFor, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { announce } from "@/lib/announce";
/** How long a toast with Undo stays, so there is time to reach the button. */
export const UNDO_TOAST_DURATION = 8000;
@@ -15,6 +18,8 @@ export interface UndoLabels {
failed: string;
/** "Press Ctrl+Z to undo.", read out after the toast. */
hint?: string;
/** "Undone", after a read, flag or tag change was put back. */
undone?: string;
}
/** An Undo for a toast, with the words a screen reader hears about it. */
@@ -34,9 +39,126 @@ export function undoLabels(t: (key: string, values?: Record<string, string>) =>
done: t("undo_done"),
failed: t("undo_failed"),
hint: t("undo_hint", { keys: undoShortcutLabel() }),
undone: t("undone"),
};
}
/**
* The keywords a read, flag or tag change is about to touch on one message,
* with the values they have now, so Undo can put exactly those back.
*/
export interface KeywordRestorePoint {
id: string;
/** emailKeyFor of the row: the id alone can name another account's message. */
key: string;
changes: Record<string, boolean>;
}
/**
* Restore points for `names` on `emails`, taken before the change. Messages
* the change leaves as they are (`target` given: already in that state) are
* left out.
*/
export function keywordRestorePoints(
emails: Email[],
names: string[],
target?: Record<string, boolean>,
): KeywordRestorePoint[] {
const points: KeywordRestorePoint[] = [];
for (const email of emails) {
const changes = Object.fromEntries(names.map((name) => [name, email.keywords?.[name] === true]));
if (target && Object.entries(target).every(([name, value]) => changes[name] === value)) continue;
points.push({ id: email.id, key: emailKeyFor(email), changes });
}
return points;
}
/** The two spellings of a tag's keyword. */
export function tagKeywords(tagId: string): string[] {
return [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId];
}
/**
* Undo for a read, flag or tag change: writes back the keywords the restore
* points hold, and nothing else (patchEmailKeywords, routed to each message's
* own account by its row key). None when nothing changed.
*/
export function undoKeywordAction(
client: IJMAPClient | null | undefined,
points: KeywordRestorePoint[],
labels: UndoLabels,
): UndoToastAction | undefined {
if (!client || points.length === 0) return undefined;
return {
label: labels.undo,
hint: labels.hint,
onClick: () => {
void (async () => {
const { patchEmailKeywords } = useEmailStore.getState();
const results = await Promise.allSettled(
points.map((p) => patchEmailKeywords(client, p.id, p.changes, p.key)),
);
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (failed) {
console.error("Failed to undo:", failed.reason);
toast.error(labels.failed);
} else {
toast.success(labels.undone ?? labels.done);
}
})();
},
};
}
/**
* Keeps an Undo for a small change that shows no toast of its own (a star,
* a message marked read, a tag): Ctrl+Z reaches it, and a screen reader
* hears what happened.
*/
export function recordQuietUndo(title: string, undo: UndoToastAction | undefined): void {
announce(title);
if (undo) recordUndo(title, undo.onClick);
}
/**
* Marks the selection read or unread, with a toast that offers Undo. The
* messages the change leaves alone are not put back.
*/
export async function markSelectionReadWithUndo(
client: IJMAPClient,
read: boolean,
words: { success: string; error: string; labels: UndoLabels },
): Promise<void> {
const points = keywordRestorePoints(resolveSelectedEmails(useEmailStore.getState()), ["$seen"], { $seen: read });
await runBatchEmailAction(() => useEmailStore.getState().batchMarkAsRead(client, read), {
success: words.success,
error: words.error,
undo: undoKeywordAction(client, points, words.labels),
});
}
/**
* Puts a tag on the selection or takes it off (batchSetTag; rejects when a
* write fails) and keeps an Undo for Ctrl+Z. The selection stays, so several
* tags can be changed in a row; a toast for each would pile up, so the
* change is only read out.
*/
export async function setSelectionTagWithUndo(
client: IJMAPClient,
tagId: string,
add: boolean,
words: { describe: (count: number) => string; labels: UndoLabels },
): Promise<boolean> {
const names = tagKeywords(tagId);
const points = keywordRestorePoints(resolveSelectedEmails(useEmailStore.getState()), names)
.filter((p) => names.some((name) => p.changes[name]) !== add);
const done = await useEmailStore.getState().batchSetTag(client, tagId, add);
if (done && points.length > 0) {
recordQuietUndo(words.describe(points.length), undoKeywordAction(client, points, words.labels));
}
return done;
}
/**
* The toast's Undo for a reversible move (delete to Trash, archive, move,
* report spam, remove from folder): puts the messages back where `batches`
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "تم حفظ إعدادات الغياب، لكن تعذّر حفظ إعادة التوجيه ومستلمي الرد",
"nothing_to_undo": "لا يوجد ما يمكن التراجع عنه",
"undo_hint": "اضغط {keys} للتراجع.",
"undone": "تم التراجع"
"undone": "تم التراجع",
"emails_marked_read": "{count, plural, one {تم تمييز رسالة واحدة كمقروءة} other {تم تمييز # رسائل كمقروءة}}",
"emails_marked_unread": "{count, plural, one {تم تمييز رسالة واحدة كغير مقروءة} other {تم تمييز # رسائل كغير مقروءة}}",
"tag_removed": "تمت إزالة الوسم",
"tag_removed_count": "{count, plural, one {تمت إزالة الوسم من رسالة واحدة} other {تمت إزالة الوسم من # رسائل}}"
},
"third_party": {
"push_needs_relay": "تحتاج الإشعارات الفورية إلى خادم ترحيل تديره مؤسستك، ولم يُعدّ أي خادم. يستمر ظهور البريد الجديد ما دام التطبيق مفتوحًا.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "S'ha desat la configuració d'absència, però no s'han pogut desar el reenviament ni els destinataris de la resposta",
"nothing_to_undo": "No hi ha res per desfer",
"undo_hint": "Premeu {keys} per desfer-ho.",
"undone": "S'ha desfet"
"undone": "S'ha desfet",
"emails_marked_read": "{count, plural, one {# correu marcat com a llegit} other {# correus marcats com a llegits}}",
"emails_marked_unread": "{count, plural, one {# correu marcat com a no llegit} other {# correus marcats com a no llegits}}",
"tag_removed": "S'ha tret l'etiqueta",
"tag_removed_count": "{count, plural, one {S'ha tret l'etiqueta de # correu} other {S'ha tret l'etiqueta de # correus}}"
},
"third_party": {
"push_needs_relay": "Les notificacions push necessiten un repetidor push gestionat per la vostra organització, i no n'hi ha cap de configurat. El correu nou es continua mostrant mentre l'aplicació està oberta.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Nastavení nepřítomnosti bylo uloženo, ale přeposílání a příjemce odpovědi se uložit nepodařilo",
"nothing_to_undo": "Není co vrátit",
"undo_hint": "Stisknutím {keys} akci vrátíte.",
"undone": "Vráceno"
"undone": "Vráceno",
"emails_marked_read": "{count, plural, one {# zpráva označena jako přečtená} few {# zprávy označeny jako přečtené} other {# zpráv označeno jako přečtené}}",
"emails_marked_unread": "{count, plural, one {# zpráva označena jako nepřečtená} few {# zprávy označeny jako nepřečtené} other {# zpráv označeno jako nepřečtené}}",
"tag_removed": "Štítek odebrán",
"tag_removed_count": "{count, plural, one {Štítek odebrán z # zprávy} few {Štítek odebrán ze # zpráv} other {Štítek odebrán z # zpráv}}"
},
"third_party": {
"push_needs_relay": "Push oznámení potřebují push relay provozovaný vaší organizací a žádný není nastaven. Nová pošta se dál zobrazuje, dokud je aplikace otevřená.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Fraværsindstillinger gemt, men videresendelse og svarmodtagere kunne ikke gemmes",
"nothing_to_undo": "Intet at fortryde",
"undo_hint": "Tryk på {keys} for at fortryde.",
"undone": "Fortrudt"
"undone": "Fortrudt",
"emails_marked_read": "{count, plural, one {# e-mail markeret som læst} other {# e-mails markeret som læst}}",
"emails_marked_unread": "{count, plural, one {# e-mail markeret som ulæst} other {# e-mails markeret som ulæst}}",
"tag_removed": "Tag fjernet",
"tag_removed_count": "{count, plural, one {Tag fjernet fra # e-mail} other {Tag fjernet fra # e-mails}}"
},
"third_party": {
"push_needs_relay": "Push-notifikationer kræver et push-relæ, som din organisation driver, og der er ikke sat noget op. Ny mail vises stadig, mens appen er åben.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Abwesenheits-Einstellungen gespeichert, Weiterleitung und Antwortempfänger konnten aber nicht gespeichert werden",
"nothing_to_undo": "Nichts rückgängig zu machen",
"undo_hint": "Mit {keys} machen Sie das rückgängig.",
"undone": "Rückgängig gemacht"
"undone": "Rückgängig gemacht",
"emails_marked_read": "{count, plural, one {# E-Mail als gelesen markiert} other {# E-Mails als gelesen markiert}}",
"emails_marked_unread": "{count, plural, one {# E-Mail als ungelesen markiert} other {# E-Mails als ungelesen markiert}}",
"tag_removed": "Markierung entfernt",
"tag_removed_count": "{count, plural, one {Markierung von # E-Mail entfernt} other {Markierung von # E-Mails entfernt}}"
},
"third_party": {
"push_needs_relay": "Push-Benachrichtigungen brauchen ein Push-Relay Ihrer Organisation, und es ist keines eingerichtet. Neue E-Mails werden weiterhin angezeigt, solange die App geöffnet ist.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Out of office settings saved, but the forwarding and reply recipients could not be saved",
"nothing_to_undo": "Nothing to undo",
"undo_hint": "Press {keys} to undo.",
"undone": "Undone"
"undone": "Undone",
"emails_marked_read": "{count, plural, one {# email marked as read} other {# emails marked as read}}",
"emails_marked_unread": "{count, plural, one {# email marked as unread} other {# emails marked as unread}}",
"tag_removed": "Tag removed",
"tag_removed_count": "{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}"
},
"third_party": {
"push_needs_relay": "Push notifications need a push relay run by your organisation, and none is set up. New mail still shows while the app is open.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Configuración de ausencia guardada, pero no se pudieron guardar el reenvío ni los destinatarios de la respuesta",
"nothing_to_undo": "No hay nada que deshacer",
"undo_hint": "Pulsa {keys} para deshacer.",
"undone": "Deshecho"
"undone": "Deshecho",
"emails_marked_read": "{count, plural, one {# correo marcado como leído} other {# correos marcados como leídos}}",
"emails_marked_unread": "{count, plural, one {# correo marcado como no leído} other {# correos marcados como no leídos}}",
"tag_removed": "Etiqueta quitada",
"tag_removed_count": "{count, plural, one {Etiqueta quitada de # correo} other {Etiqueta quitada de # correos}}"
},
"third_party": {
"push_needs_relay": "Las notificaciones push necesitan un relay push gestionado por tu organización, y no hay ninguno configurado. El correo nuevo se sigue mostrando mientras la aplicación está abierta.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Äraolekuteate seaded salvestati, kuid edastus- ja vastuse adressaate ei õnnestunud salvestada",
"nothing_to_undo": "Pole midagi tagasi võtta",
"undo_hint": "Tagasivõtmiseks vajuta {keys}.",
"undone": "Tagasi võetud"
"undone": "Tagasi võetud",
"emails_marked_read": "{count, plural, one {# kiri märgiti loetuks} other {# kirja märgiti loetuks}}",
"emails_marked_unread": "{count, plural, one {# kiri märgiti lugemata} other {# kirja märgiti lugemata}}",
"tag_removed": "Silt eemaldati",
"tag_removed_count": "{count, plural, one {Silt eemaldati # kirjalt} other {Silt eemaldati # kirjalt}}"
},
"third_party": {
"push_needs_relay": "Tõuketeavitused vajavad sinu organisatsiooni hallatavat push-releed, kuid ühtegi pole seadistatud. Uued kirjad kuvatakse endiselt, kuni rakendus on avatud.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "تنظیمات غیبت ذخیره شد، اما ذخیره بازارسال و گیرندگان پاسخ ناموفق بود",
"nothing_to_undo": "چیزی برای بازگردانی نیست",
"undo_hint": "برای بازگردانی {keys} را بزنید.",
"undone": "بازگردانده شد"
"undone": "بازگردانده شد",
"emails_marked_read": "{count, plural, other {# ایمیل خوانده شده علامت‌گذاری شد}}",
"emails_marked_unread": "{count, plural, other {# ایمیل خوانده نشده علامت‌گذاری شد}}",
"tag_removed": "برچسب برداشته شد",
"tag_removed_count": "{count, plural, other {برچسب از # ایمیل برداشته شد}}"
},
"third_party": {
"push_needs_relay": "اعلان‌های push به یک رلهٔ push نیاز دارند که سازمان شما اداره کند، و هیچ‌کدام راه‌اندازی نشده است. تا وقتی برنامه باز است، ایمیل‌های جدید همچنان نمایش داده می‌شوند.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Paramètres d'absence enregistrés, mais le transfert et les destinataires de la réponse n'ont pas pu être enregistrés",
"nothing_to_undo": "Rien à annuler",
"undo_hint": "Appuyez sur {keys} pour annuler.",
"undone": "Annulé"
"undone": "Annulé",
"emails_marked_read": "{count, plural, one {# e-mail marqué comme lu} other {# e-mails marqués comme lus}}",
"emails_marked_unread": "{count, plural, one {# e-mail marqué comme non lu} other {# e-mails marqués comme non lus}}",
"tag_removed": "Étiquette retirée",
"tag_removed_count": "{count, plural, one {Étiquette retirée de # e-mail} other {Étiquette retirée de # e-mails}}"
},
"third_party": {
"push_needs_relay": "Les notifications push nécessitent un relais push géré par votre organisation, et aucun n'est configuré. Les nouveaux messages s'affichent toujours tant que l'application est ouverte.",
+5 -1
View File
@@ -1119,7 +1119,11 @@
"vacation_filters_save_failed": "הגדרות ההיעדרות נשמרו, אך שמירת ההעברה ונמעני המענה נכשלה",
"nothing_to_undo": "אין מה לבטל",
"undo_hint": "הקישו {keys} כדי לבטל.",
"undone": "בוטל"
"undone": "בוטל",
"emails_marked_read": "{count, plural, one {הודעה אחת סומנה כנקראה} other {# הודעות סומנו כנקראו}}",
"emails_marked_unread": "{count, plural, one {הודעה אחת סומנה כלא נקראה} other {# הודעות סומנו כלא נקראו}}",
"tag_removed": "התגית הוסרה",
"tag_removed_count": "{count, plural, one {התגית הוסרה מהודעה אחת} other {התגית הוסרה מ-# הודעות}}"
},
"third_party": {
"push_needs_relay": "התראות Push דורשות ממסר Push שהארגון שלך מפעיל, ולא הוגדר אף אחד. דואר חדש עדיין מוצג כל עוד האפליקציה פתוחה.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Távolléti beállítások elmentve, de a továbbítást és a válasz címzettjeit nem sikerült menteni",
"nothing_to_undo": "Nincs mit visszavonni",
"undo_hint": "Visszavonáshoz nyomd meg: {keys}.",
"undone": "Visszavonva"
"undone": "Visszavonva",
"emails_marked_read": "{count, plural, other {# e-mail olvasottnak jelölve}}",
"emails_marked_unread": "{count, plural, other {# e-mail olvasatlannak jelölve}}",
"tag_removed": "Címke eltávolítva",
"tag_removed_count": "{count, plural, other {Címke eltávolítva # e-mailről}}"
},
"third_party": {
"push_needs_relay": "A push-értesítésekhez a szervezeted által üzemeltetett push relé kell, és nincs beállítva egy sem. Az új levelek továbbra is megjelennek, amíg az alkalmazás nyitva van.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Impostazioni di assenza salvate, ma non è stato possibile salvare l'inoltro e i destinatari della risposta",
"nothing_to_undo": "Niente da annullare",
"undo_hint": "Premi {keys} per annullare.",
"undone": "Annullato"
"undone": "Annullato",
"emails_marked_read": "{count, plural, one {# email segnata come letta} other {# email segnate come lette}}",
"emails_marked_unread": "{count, plural, one {# email segnata come non letta} other {# email segnate come non lette}}",
"tag_removed": "Etichetta rimossa",
"tag_removed_count": "{count, plural, one {Etichetta rimossa da # email} other {Etichetta rimossa da # email}}"
},
"third_party": {
"push_needs_relay": "Le notifiche push richiedono un relay push gestito dalla tua organizzazione, e non ne è configurato nessuno. La nuova posta compare comunque finché l'app è aperta.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "不在の設定を保存しましたが、転送と返信する相手の保存に失敗しました",
"nothing_to_undo": "元に戻す操作はありません",
"undo_hint": "{keys} で元に戻せます。",
"undone": "元に戻しました"
"undone": "元に戻しました",
"emails_marked_read": "{count, plural, other {#件のメールを既読にしました}}",
"emails_marked_unread": "{count, plural, other {#件のメールを未読にしました}}",
"tag_removed": "タグを外しました",
"tag_removed_count": "{count, plural, other {#件のメールからタグを外しました}}"
},
"third_party": {
"push_needs_relay": "プッシュ通知には組織が運用するプッシュリレーが必要ですが、設定されていません。アプリを開いている間は、新着メールが引き続き表示されます。",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "부재중 설정은 저장되었지만 전달 및 응답 대상 설정을 저장하지 못했어요",
"nothing_to_undo": "실행 취소할 작업이 없어요",
"undo_hint": "{keys}를 누르면 실행 취소돼요.",
"undone": "실행 취소했어요"
"undone": "실행 취소했어요",
"emails_marked_read": "{count, plural, other {메일 #개를 읽음으로 표시했어요}}",
"emails_marked_unread": "{count, plural, other {메일 #개를 읽지 않음으로 표시했어요}}",
"tag_removed": "태그를 뺐어요",
"tag_removed_count": "{count, plural, other {메일 #개에서 태그를 뺐어요}}"
},
"third_party": {
"push_needs_relay": "푸시 알림을 받으려면 조직에서 운영하는 푸시 릴레이가 필요한데, 설정된 릴레이가 없어요. 앱이 열려 있는 동안에는 새 메일이 계속 표시돼요.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Prombūtnes iestatījumi saglabāti, taču pārsūtīšanu un atbildes saņēmējus neizdevās saglabāt",
"nothing_to_undo": "Nav ko atsaukt",
"undo_hint": "Nospiediet {keys}, lai atsauktu.",
"undone": "Atsaukts"
"undone": "Atsaukts",
"emails_marked_read": "{count, plural, zero {# vēstules atzīmētas kā izlasītas} one {# vēstule atzīmēta kā izlasīta} other {# vēstules atzīmētas kā izlasītas}}",
"emails_marked_unread": "{count, plural, zero {# vēstules atzīmētas kā neizlasītas} one {# vēstule atzīmēta kā neizlasīta} other {# vēstules atzīmētas kā neizlasītas}}",
"tag_removed": "Tags noņemts",
"tag_removed_count": "{count, plural, zero {Tags noņemts # vēstulēm} one {Tags noņemts # vēstulei} other {Tags noņemts # vēstulēm}}"
},
"third_party": {
"push_needs_relay": "Push paziņojumiem vajadzīgs jūsu organizācijas uzturēts push relejs, un neviens nav iestatīts. Jauns pasts joprojām tiek rādīts, kamēr lietotne ir atvērta.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Эзгүй байх тохиргоог хадгалсан боловч дамжуулалт болон хариу хүлээн авагчдыг хадгалж чадсангүй",
"nothing_to_undo": "Буцаах зүйл алга",
"undo_hint": "Буцаахын тулд {keys} дарна уу.",
"undone": "Буцаагдлаа"
"undone": "Буцаагдлаа",
"emails_marked_read": "{count, plural, other {# имэйлийг уншсан гэж тэмдэглэлээ}}",
"emails_marked_unread": "{count, plural, other {# имэйлийг уншаагүй гэж тэмдэглэлээ}}",
"tag_removed": "Хаяг хасагдлаа",
"tag_removed_count": "{count, plural, other {# имэйлээс хаяг хасагдлаа}}"
},
"third_party": {
"push_needs_relay": "Push мэдэгдэлд таны байгууллагын ажиллуулдаг push буухиа хэрэгтэй, гэвч нэг ч тохируулагдаагүй байна. Апп нээлттэй байх үед шинэ имэйл харагдсаар байна.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Fraværsinnstillinger er lagret, men videresending og svarmottakere kunne ikke lagres",
"nothing_to_undo": "Ingenting å angre",
"undo_hint": "Trykk {keys} for å angre.",
"undone": "Angret"
"undone": "Angret",
"emails_marked_read": "{count, plural, one {# e-post merket som lest} other {# e-poster merket som lest}}",
"emails_marked_unread": "{count, plural, one {# e-post merket som ulest} other {# e-poster merket som ulest}}",
"tag_removed": "Etikett fjernet",
"tag_removed_count": "{count, plural, one {Etikett fjernet fra # e-post} other {Etikett fjernet fra # e-poster}}"
},
"third_party": {
"push_needs_relay": "Push-varsler trenger et push-relé som organisasjonen din driver, og det er ikke satt opp noe. Ny e-post vises fortsatt mens appen er åpen.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Afwezigheidsinstellingen opgeslagen, maar het doorsturen en de ontvangers van het antwoord konden niet worden opgeslagen",
"nothing_to_undo": "Niets om ongedaan te maken",
"undo_hint": "Druk op {keys} om ongedaan te maken.",
"undone": "Ongedaan gemaakt"
"undone": "Ongedaan gemaakt",
"emails_marked_read": "{count, plural, one {# e-mail gemarkeerd als gelezen} other {# e-mails gemarkeerd als gelezen}}",
"emails_marked_unread": "{count, plural, one {# e-mail gemarkeerd als ongelezen} other {# e-mails gemarkeerd als ongelezen}}",
"tag_removed": "Label verwijderd",
"tag_removed_count": "{count, plural, one {Label verwijderd van # e-mail} other {Label verwijderd van # e-mails}}"
},
"third_party": {
"push_needs_relay": "Pushmeldingen hebben een pushrelay van je organisatie nodig, en er is er geen ingesteld. Nieuwe e-mail wordt nog steeds getoond zolang de app open is.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Ustawienia nieobecności zapisane, ale nie udało się zapisać przekazywania ani odbiorców odpowiedzi",
"nothing_to_undo": "Nie ma czego cofnąć",
"undo_hint": "Naciśnij {keys}, aby cofnąć.",
"undone": "Cofnięto"
"undone": "Cofnięto",
"emails_marked_read": "{count, plural, one {# wiadomość oznaczona jako przeczytana} few {# wiadomości oznaczone jako przeczytane} many {# wiadomości oznaczonych jako przeczytane} other {# wiadomości oznaczonych jako przeczytane}}",
"emails_marked_unread": "{count, plural, one {# wiadomość oznaczona jako nieprzeczytana} few {# wiadomości oznaczone jako nieprzeczytane} many {# wiadomości oznaczonych jako nieprzeczytane} other {# wiadomości oznaczonych jako nieprzeczytane}}",
"tag_removed": "Etykieta usunięta",
"tag_removed_count": "{count, plural, one {Usunięto etykietę z # wiadomości} few {Usunięto etykietę z # wiadomości} many {Usunięto etykietę z # wiadomości} other {Usunięto etykietę z # wiadomości}}"
},
"third_party": {
"push_needs_relay": "Powiadomienia push wymagają przekaźnika push prowadzonego przez Twoją organizację, a żaden nie jest skonfigurowany. Nowe wiadomości nadal pojawiają się, gdy aplikacja jest otwarta.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Configurações de ausência salvas, mas não foi possível salvar o encaminhamento e os destinatários da resposta",
"nothing_to_undo": "Nada para desfazer",
"undo_hint": "Pressione {keys} para desfazer.",
"undone": "Desfeito"
"undone": "Desfeito",
"emails_marked_read": "{count, plural, one {# e-mail marcado como lido} other {# e-mails marcados como lidos}}",
"emails_marked_unread": "{count, plural, one {# e-mail marcado como não lido} other {# e-mails marcados como não lidos}}",
"tag_removed": "Etiqueta removida",
"tag_removed_count": "{count, plural, one {Etiqueta removida de # e-mail} other {Etiqueta removida de # e-mails}}"
},
"third_party": {
"push_needs_relay": "As notificações push precisam de um relay push mantido pela sua organização, e nenhum está configurado. Os novos e-mails continuam aparecendo enquanto o app estiver aberto.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Setările de absență au fost salvate, dar redirecționarea și destinatarii răspunsului nu au putut fi salvați",
"nothing_to_undo": "Nu e nimic de anulat",
"undo_hint": "Apasă {keys} pentru a anula.",
"undone": "Anulat"
"undone": "Anulat",
"emails_marked_read": "{count, plural, one {# e-mail marcat ca citit} few {# e-mailuri marcate ca citite} other {# de e-mailuri marcate ca citite}}",
"emails_marked_unread": "{count, plural, one {# e-mail marcat ca necitit} few {# e-mailuri marcate ca necitite} other {# de e-mailuri marcate ca necitite}}",
"tag_removed": "Etichetă eliminată",
"tag_removed_count": "{count, plural, one {Eticheta a fost eliminată de pe # e-mail} few {Eticheta a fost eliminată de pe # e-mailuri} other {Eticheta a fost eliminată de pe # de e-mailuri}}"
},
"third_party": {
"push_needs_relay": "Notificările push au nevoie de un releu push administrat de organizația dvs., iar niciunul nu este configurat. E-mailurile noi apar în continuare cât timp aplicația este deschisă.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Настройки отсутствия сохранены, но пересылку и получателей ответа сохранить не удалось",
"nothing_to_undo": "Нечего отменять",
"undo_hint": "Нажмите {keys}, чтобы отменить.",
"undone": "Отменено"
"undone": "Отменено",
"emails_marked_read": "{count, plural, one {# письмо отмечено прочитанным} few {# письма отмечены прочитанными} many {# писем отмечено прочитанными} other {# письма отмечено прочитанными}}",
"emails_marked_unread": "{count, plural, one {# письмо отмечено непрочитанным} few {# письма отмечены непрочитанными} many {# писем отмечено непрочитанными} other {# письма отмечено непрочитанными}}",
"tag_removed": "Тег снят",
"tag_removed_count": "{count, plural, one {Тег снят с # письма} few {Тег снят с # писем} many {Тег снят с # писем} other {Тег снят с # письма}}"
},
"third_party": {
"push_needs_relay": "Для push-уведомлений нужен push-релей вашей организации, а он не настроен. Новые письма по-прежнему видны, пока приложение открыто.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Nastavenia neprítomnosti boli uložené, ale preposielanie a príjemcov odpovede sa nepodarilo uložiť",
"nothing_to_undo": "Nie je čo vrátiť",
"undo_hint": "Stlačením {keys} akciu vrátite.",
"undone": "Vrátené"
"undone": "Vrátené",
"emails_marked_read": "{count, plural, one {# e-mail označený ako prečítaný} few {# e-maily označené ako prečítané} other {# e-mailov označených ako prečítané}}",
"emails_marked_unread": "{count, plural, one {# e-mail označený ako neprečítaný} few {# e-maily označené ako neprečítané} other {# e-mailov označených ako neprečítané}}",
"tag_removed": "Štítok odstránený",
"tag_removed_count": "{count, plural, one {Štítok odstránený z # e-mailu} few {Štítok odstránený z # e-mailov} other {Štítok odstránený z # e-mailov}}"
},
"third_party": {
"push_needs_relay": "Push oznámenia potrebujú push relay prevádzkovaný vašou organizáciou a žiadny nie je nastavený. Nová pošta sa naďalej zobrazuje, kým je aplikácia otvorená.",
+5 -1
View File
@@ -521,7 +521,11 @@
"vacation_filters_save_failed": "บันทึกการตั้งค่าตอบกลับอัตโนมัติแล้ว แต่ไม่สามารถบันทึกผู้รับส่งต่อและผู้รับตอบกลับได้",
"nothing_to_undo": "ไม่มีอะไรให้เลิกทำ",
"undo_hint": "กด {keys} เพื่อเลิกทำ",
"undone": "เลิกทำแล้ว"
"undone": "เลิกทำแล้ว",
"emails_marked_read": "{count, plural, other {ทำเครื่องหมายอีเมล # ฉบับว่าอ่านแล้ว}}",
"emails_marked_unread": "{count, plural, other {ทำเครื่องหมายอีเมล # ฉบับว่ายังไม่ได้อ่าน}}",
"tag_removed": "นำป้ายกำกับออกแล้ว",
"tag_removed_count": "{count, plural, other {นำป้ายกำกับออกจากอีเมล # ฉบับแล้ว}}"
},
"third_party": {
"push_needs_relay": "การแจ้งเตือนแบบพุชต้องใช้ push relay ที่องค์กรของคุณดูแล แต่ยังไม่ได้ตั้งค่าไว้ อีเมลใหม่จะยังแสดงขณะที่เปิดแอปอยู่",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Ofis dışında ayarları kaydedildi, ancak iletme ve yanıt alıcıları kaydedilemedi",
"nothing_to_undo": "Geri alınacak bir şey yok",
"undo_hint": "Geri almak için {keys} tuşlarına basın.",
"undone": "Geri alındı"
"undone": "Geri alındı",
"emails_marked_read": "{count, plural, other {# e-posta okundu olarak işaretlendi}}",
"emails_marked_unread": "{count, plural, other {# e-posta okunmadı olarak işaretlendi}}",
"tag_removed": "Etiket kaldırıldı",
"tag_removed_count": "{count, plural, other {# e-postadan etiket kaldırıldı}}"
},
"third_party": {
"push_needs_relay": "Anlık bildirimler için kuruluşunuzun işlettiği bir push rölesi gerekir ve hiçbiri ayarlanmamış. Uygulama açıkken yeni postalar yine de görünür.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "Налаштування відсутності збережено, але пересилання та отримувачів відповіді зберегти не вдалося",
"nothing_to_undo": "Нічого скасовувати",
"undo_hint": "Натисніть {keys}, щоб скасувати.",
"undone": "Скасовано"
"undone": "Скасовано",
"emails_marked_read": "{count, plural, one {# лист позначено прочитаним} few {# листи позначено прочитаними} many {# листів позначено прочитаними} other {# листа позначено прочитаними}}",
"emails_marked_unread": "{count, plural, one {# лист позначено непрочитаним} few {# листи позначено непрочитаними} many {# листів позначено непрочитаними} other {# листа позначено непрочитаними}}",
"tag_removed": "Тег знято",
"tag_removed_count": "{count, plural, one {Тег знято з # листа} few {Тег знято з # листів} many {Тег знято з # листів} other {Тег знято з # листа}}"
},
"third_party": {
"push_needs_relay": "Для push-сповіщень потрібне push-реле вашої організації, а його не налаштовано. Нові листи й далі з'являються, поки застосунок відкритий.",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "外出設定已儲存,但轉寄和回覆對象設定未能儲存",
"nothing_to_undo": "沒有可復原的動作",
"undo_hint": "按 {keys} 復原。",
"undone": "已復原"
"undone": "已復原",
"emails_marked_read": "{count, plural, other {已將 # 封郵件標示為已讀}}",
"emails_marked_unread": "{count, plural, other {已將 # 封郵件標示為未讀}}",
"tag_removed": "已移除標籤",
"tag_removed_count": "{count, plural, other {已從 # 封郵件移除標籤}}"
},
"third_party": {
"push_needs_relay": "推送通知需要由您的組織執行的推送中繼,但尚未設定。應用程式開啟時,新郵件仍會顯示。",
+5 -1
View File
@@ -1154,7 +1154,11 @@
"vacation_filters_save_failed": "外出设置已保存,但转发和回复对象设置未能保存",
"nothing_to_undo": "没有可撤销的操作",
"undo_hint": "按 {keys} 撤销。",
"undone": "已撤销"
"undone": "已撤销",
"emails_marked_read": "{count, plural, other {已将 # 封邮件标记为已读}}",
"emails_marked_unread": "{count, plural, other {已将 # 封邮件标记为未读}}",
"tag_removed": "已移除标签",
"tag_removed_count": "{count, plural, other {已从 # 封邮件移除标签}}"
},
"third_party": {
"push_needs_relay": "推送通知需要由您的组织运行的推送中继,但尚未设置。应用打开时,新邮件仍会显示。",