diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ac3b6e062..b818c84d0 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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')); diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index 14cdc6f58..d66eb9b71 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -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, 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({ run(async () => { if (client) await batchMarkAsRead(client, true); })} + onClick={() => run(async () => { if (client) await markSelectionRead(client, true); })} disabled={isProcessing} /> run(async () => { if (client) await batchMarkAsRead(client, false); })} + onClick={() => run(async () => { if (client) await markSelectionRead(client, false); })} disabled={isProcessing} />
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index ef9a3a907..b63d2de23 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 => { + 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 => { + 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. diff --git a/lib/__tests__/email-action-keyword-undo.test.ts b/lib/__tests__/email-action-keyword-undo.test.ts new file mode 100644 index 000000000..a6e44811f --- /dev/null +++ b/lib/__tests__/email-action-keyword-undo.test.ts @@ -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 = {}) => ({ id, keywords, mailboxIds: { inbox: true } }) as unknown as Email; + +let patchEmailKeywords: ReturnType; + +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")); + }); +}); diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index 7e66e8fb2..663a3b417 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -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) => 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; +} + +/** + * 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, +): 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 { + 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 { + 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` diff --git a/locales/ar/common.json b/locales/ar/common.json index 0925fe5e7..158038972 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "تحتاج الإشعارات الفورية إلى خادم ترحيل تديره مؤسستك، ولم يُعدّ أي خادم. يستمر ظهور البريد الجديد ما دام التطبيق مفتوحًا.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 94c77d460..17b2f59e0 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 2d89eaeb6..f2e88c542 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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á.", diff --git a/locales/da/common.json b/locales/da/common.json index 820ecf073..de32bff1f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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.", diff --git a/locales/de/common.json b/locales/de/common.json index 4b294d7b3..8e7973cd9 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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.", diff --git a/locales/en/common.json b/locales/en/common.json index a9a28dd8a..9bbf0113f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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.", diff --git a/locales/es/common.json b/locales/es/common.json index 615cfa130..a097c0f1d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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.", diff --git a/locales/et/common.json b/locales/et/common.json index 5090e4ce4..a8cdfd9fa 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 40634a476..bc2877c55 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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 نیاز دارند که سازمان شما اداره کند، و هیچ‌کدام راه‌اندازی نشده است. تا وقتی برنامه باز است، ایمیل‌های جدید همچنان نمایش داده می‌شوند.", diff --git a/locales/fr/common.json b/locales/fr/common.json index d8c97169a..f32d4eabc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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.", diff --git a/locales/he/common.json b/locales/he/common.json index 0a8742845..bdc809c13 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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 שהארגון שלך מפעיל, ולא הוגדר אף אחד. דואר חדש עדיין מוצג כל עוד האפליקציה פתוחה.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 16eb3c555..96933fe0a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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.", diff --git a/locales/it/common.json b/locales/it/common.json index 87b7490e3..fff5d633c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 78be83a96..f647bee5f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "プッシュ通知には組織が運用するプッシュリレーが必要ですが、設定されていません。アプリを開いている間は、新着メールが引き続き表示されます。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 3e83189a2..8f68eac76 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "푸시 알림을 받으려면 조직에서 운영하는 푸시 릴레이가 필요한데, 설정된 릴레이가 없어요. 앱이 열려 있는 동안에는 새 메일이 계속 표시돼요.", diff --git a/locales/lv/common.json b/locales/lv/common.json index e2b05d133..1fa1f2e93 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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.", diff --git a/locales/mn/common.json b/locales/mn/common.json index b762a810b..c33be2380 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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 буухиа хэрэгтэй, гэвч нэг ч тохируулагдаагүй байна. Апп нээлттэй байх үед шинэ имэйл харагдсаар байна.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 77f64f145..98b9533a6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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.", diff --git a/locales/nl/common.json b/locales/nl/common.json index cb7ae0ca1..732e8a0c4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 9797f574a..7c6d2eaba 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 597fc2e29..14b7268e0 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a57d2cb02..1f3c47213 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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ă.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 253d85023..4446a18d9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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-релей вашей организации, а он не настроен. Новые письма по-прежнему видны, пока приложение открыто.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 4b459b2bf..026f1372e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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á.", diff --git a/locales/th/common.json b/locales/th/common.json index e626dc607..eaa9c26d1 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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 ที่องค์กรของคุณดูแล แต่ยังไม่ได้ตั้งค่าไว้ อีเมลใหม่จะยังแสดงขณะที่เปิดแอปอยู่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 91da502ab..3b12a6588 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 56d33c2bd..27207d483 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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-реле вашої організації, а його не налаштовано. Нові листи й далі з'являються, поки застосунок відкритий.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 15f9ea7f7..4bc2f2bc2 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "推送通知需要由您的組織執行的推送中繼,但尚未設定。應用程式開啟時,新郵件仍會顯示。", diff --git a/locales/zh/common.json b/locales/zh/common.json index afc483bc6..af7e6d920 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "推送通知需要由您的组织运行的推送中继,但尚未设置。应用打开时,新邮件仍会显示。",