mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: undo marking read or unread, stars and tags from the toast or with Ctrl+Z
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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": "تحتاج الإشعارات الفورية إلى خادم ترحيل تديره مؤسستك، ولم يُعدّ أي خادم. يستمر ظهور البريد الجديد ما دام التطبيق مفتوحًا.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 نیاز دارند که سازمان شما اداره کند، و هیچکدام راهاندازی نشده است. تا وقتی برنامه باز است، ایمیلهای جدید همچنان نمایش داده میشوند.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 שהארגון שלך מפעיל, ולא הוגדר אף אחד. דואר חדש עדיין מוצג כל עוד האפליקציה פתוחה.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "プッシュ通知には組織が運用するプッシュリレーが必要ですが、設定されていません。アプリを開いている間は、新着メールが引き続き表示されます。",
|
||||
|
||||
@@ -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": "푸시 알림을 받으려면 조직에서 운영하는 푸시 릴레이가 필요한데, 설정된 릴레이가 없어요. 앱이 열려 있는 동안에는 새 메일이 계속 표시돼요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 буухиа хэрэгтэй, гэвч нэг ч тохируулагдаагүй байна. Апп нээлттэй байх үед шинэ имэйл харагдсаар байна.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ă.",
|
||||
|
||||
@@ -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-релей вашей организации, а он не настроен. Новые письма по-прежнему видны, пока приложение открыто.",
|
||||
|
||||
@@ -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á.",
|
||||
|
||||
@@ -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 ที่องค์กรของคุณดูแล แต่ยังไม่ได้ตั้งค่าไว้ อีเมลใหม่จะยังแสดงขณะที่เปิดแอปอยู่",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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-реле вашої організації, а його не налаштовано. Нові листи й далі з'являються, поки застосунок відкритий.",
|
||||
|
||||
@@ -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": "推送通知需要由您的組織執行的推送中繼,但尚未設定。應用程式開啟時,新郵件仍會顯示。",
|
||||
|
||||
@@ -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": "推送通知需要由您的组织运行的推送中继,但尚未设置。应用打开时,新邮件仍会显示。",
|
||||
|
||||
Reference in New Issue
Block a user