feat: add Mail's commands to the command palette: reply, archive, delete, move, read state, spam and go to a folder

This commit is contained in:
Linus Rath
2026-10-28 05:27:17 +01:00
parent 7166c5f112
commit af6deee047
32 changed files with 288 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveTargetList } from "./move-target-list";
interface MoveToDialogProps {
/** The message to move; the dialog is open while it is set. */
email: Email | null;
mailboxes: Mailbox[];
selectedMailbox: string;
onClose: () => void;
onMove: (mailboxId: string) => void;
}
/**
* "Move to…" from the command palette: the same folder list as the reader's
* Move menu (the message's own account first), in a dialog.
*/
export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) {
const t = useTranslations("context_menu");
const { tree, targetIds } = useMemo(
() => buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox),
}),
[email, mailboxes, selectedMailbox],
);
return (
<Dialog isOpen={!!email} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto">
<MoveTargetList
tree={tree}
targetIds={targetIds}
onPick={(mailboxId) => {
onClose();
onMove(mailboxId);
}}
/>
</div>
</Dialog>
);
}
@@ -0,0 +1,122 @@
import React, { Suspense, lazy } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { clearAllHooks } from '@/lib/plugin-hooks';
import type { CommandGroup } from '@/lib/command-registry';
// Mail adds its own commands to the palette (Ctrl/⌘+K): the open message's
// actions and a "Go to" per folder.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>viewer</div> }));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const folder = (id: string, role: string | undefined, name = role ?? id): Mailbox => ({
id, name, role, sortOrder: 0, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, isSubscribed: true,
myRights: { mayAddItems: true, mayRemoveItems: true, mayReadItems: true },
}) as Mailbox;
const folders = [folder('inbox', 'inbox'), folder('trash', 'trash'), folder('work', undefined, 'Work')];
const open = { id: 'm1', threadId: 't1', subject: 'Hello', receivedAt: '2026-10-26T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
let client: Record<string, unknown>;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
useToastStore.setState({ toasts: [] });
clearAllHooks();
client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getMailboxes: vi.fn().mockResolvedValue(folders),
moveEmail: vi.fn().mockResolvedValue(undefined),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never, getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split' } as never);
useEmailStore.setState({
emails: [open], selectedEmail: open, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders,
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedEmailKeys: new Set(),
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const { useRegisteredCommandGroups } = await import('@/lib/command-registry');
function Palette({ onGroups }: { onGroups: (groups: CommandGroup[]) => void }) {
onGroups(useRegisteredCommandGroups());
return null;
}
describe('Mail in the command palette', () => {
it('lists the open message\'s actions and a Go to per other folder', async () => {
let groups: CommandGroup[] = [];
render(<><MailApp /><Palette onGroups={(g) => { groups = g; }} /></>);
await screen.findByText('viewer', {}, { timeout: 5000 });
await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined());
const ids = groups.find((g) => g.label === 'sidebar.mail')!.commands.map((c) => c.id);
expect(ids).toEqual(expect.arrayContaining(['reply', 'archive', 'delete', 'move', 'mark-unread', 'spam', 'go-trash', 'go-work']));
expect(ids).not.toContain('go-inbox');
expect(ids).not.toContain('mark-read');
});
it('moves the open message from "Move to…"', async () => {
let groups: CommandGroup[] = [];
render(<><MailApp /><Palette onGroups={(g) => { groups = g; }} /></>);
await screen.findByText('viewer', {}, { timeout: 5000 });
await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined());
const move = groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === 'move')!;
act(() => move.run());
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeNull());
fireEvent.click(await screen.findByText('Work'));
await waitFor(() => expect(client.moveEmail).toHaveBeenCalledWith('m1', 'work', undefined));
});
});
+62 -1
View File
@@ -120,7 +120,9 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw } from "@/components/icons";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useDocumentTitle } from "@/hooks/use-document-title";
@@ -251,6 +253,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// The message the palette's "Move to…" was picked for.
const [moveDialogEmail, setMoveDialogEmail] = useState<Email | null>(null);
// The scheduled message Reschedule was picked for in the list's menu.
const [rescheduleTarget, setRescheduleTarget] = useState<Email | null>(null);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
@@ -4197,6 +4201,54 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onAccountMailboxSelect: handleAccountMailboxSelect,
});
// Mail's commands in the palette (Ctrl/⌘+K), on the open message, plus a
// "Go to" for every folder. Compose is the palette's own.
const paletteEmail = isScheduledView ? null : selectedEmail;
const hasPaletteEmail = () => !!paletteEmail;
useRegisterCommands(t('sidebar.mail'), [
{ id: 'reply', label: t('context_menu.reply'), icon: Reply, shortcut: 'r', when: hasPaletteEmail, run: () => void handleReply() },
{ id: 'reply-all', label: t('context_menu.reply_all'), icon: ReplyAll, shortcut: 'a', when: hasPaletteEmail, run: () => void handleReplyAll() },
{ id: 'forward', label: t('context_menu.forward'), icon: Forward, shortcut: 'f', when: hasPaletteEmail, run: () => void handleForward() },
{
id: 'archive', label: t('context_menu.archive'), icon: Archive, shortcut: 'e',
when: () => !!paletteEmail && archiveActionEnabled && conversationRole !== 'archive',
run: () => void handleArchive(paletteEmail),
},
{ id: 'delete', label: t('context_menu.delete'), icon: Trash2, shortcut: '#', when: hasPaletteEmail, run: () => void handleDelete(paletteEmail) },
{
id: 'move', label: t('context_menu.move_to_dialog'), icon: FolderInput, keywords: [t('context_menu.move_to')],
when: hasPaletteEmail, run: () => setMoveDialogEmail(paletteEmail),
},
{
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); },
},
{
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); },
},
{
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
when: () => !!paletteEmail && conversationSpamApplicable,
run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)),
},
...mailboxes
.filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox)
.map((mb) => {
const name = localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`));
return {
id: `go-${mb.id}`,
label: t('context_menu.go_to_folder', { folder: name }),
icon: Folder,
keywords: [name],
run: () => void handleMailboxSelect(mb.id),
};
}),
], !isEmbedded);
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
return (
<div className="flex h-screen items-center justify-center bg-background">
@@ -5657,6 +5709,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/>
)}
<ConfirmDialog {...confirmDialogProps} />
<MoveToDialog
email={moveDialogEmail}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onClose={() => setMoveDialogEmail(null)}
onMove={(mailboxId) => {
if (moveDialogEmail) void handleMoveToMailbox(moveDialogEmail.id, mailboxId, emailKeyFor(moveDialogEmail));
}}
/>
{rescheduleTarget && (
<ScheduleSendDialog
isOpen
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "تثبيت",
"unpin": "إلغاء التثبيت",
"move_to": "نقل إلى...",
"move_to_dialog": "نقل إلى…",
"go_to_folder": "الانتقال إلى {folder}",
"copy_to_account": "نسخ إلى حساب...",
"remove_from_folder": "إزالة من هذا المجلد",
"archive": "أرشفة",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fixa",
"unpin": "Desfixa",
"move_to": "Mou a...",
"move_to_dialog": "Mou a…",
"go_to_folder": "Ves a {folder}",
"copy_to_account": "Copia a un compte...",
"remove_from_folder": "Treu d'aquesta carpeta",
"archive": "Arxiva",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Připnout",
"unpin": "Odepnout",
"move_to": "Přesunout do...",
"move_to_dialog": "Přesunout do…",
"go_to_folder": "Přejít do {folder}",
"copy_to_account": "Kopírovat do účtu...",
"remove_from_folder": "Odebrat z této složky",
"archive": "Archivovat",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fastgør",
"unpin": "Frigør",
"move_to": "Flyt til...",
"move_to_dialog": "Flyt til…",
"go_to_folder": "Gå til {folder}",
"copy_to_account": "Kopiér til konto...",
"remove_from_folder": "Fjern fra denne mappe",
"archive": "Arkivér",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Anheften",
"unpin": "Lösen",
"move_to": "Verschieben nach...",
"move_to_dialog": "Verschieben nach…",
"go_to_folder": "Zu {folder} wechseln",
"copy_to_account": "In Konto kopieren...",
"remove_from_folder": "Aus diesem Ordner entfernen",
"archive": "Archivieren",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Pin",
"unpin": "Unpin",
"move_to": "Move to...",
"move_to_dialog": "Move to…",
"go_to_folder": "Go to {folder}",
"copy_to_account": "Copy to account...",
"remove_from_folder": "Remove from this folder",
"archive": "Archive",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Anclar",
"unpin": "Desanclar",
"move_to": "Mover a...",
"move_to_dialog": "Mover a…",
"go_to_folder": "Ir a {folder}",
"copy_to_account": "Copiar a cuenta...",
"remove_from_folder": "Quitar de esta carpeta",
"archive": "Archivar",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Kinnita",
"unpin": "Eemalda kinnitus",
"move_to": "Liiguta kausta...",
"move_to_dialog": "Teisalda…",
"go_to_folder": "Mine kausta {folder}",
"copy_to_account": "Kopeeri kontole...",
"remove_from_folder": "Eemalda sellest kaustast",
"archive": "Arhiveeri",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "سنجاق کردن",
"unpin": "برداشتن سنجاق",
"move_to": "انتقال به...",
"move_to_dialog": "انتقال به…",
"go_to_folder": "رفتن به {folder}",
"copy_to_account": "کپی به حساب...",
"remove_from_folder": "حذف از این پوشه",
"archive": "بایگانی",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Épingler",
"unpin": "Désépingler",
"move_to": "Déplacer vers...",
"move_to_dialog": "Déplacer vers…",
"go_to_folder": "Aller à {folder}",
"copy_to_account": "Copier vers un compte...",
"remove_from_folder": "Retirer de ce dossier",
"archive": "Archiver",
+2
View File
@@ -2640,6 +2640,8 @@
"pin": "הצמד",
"unpin": "בטל הצמדה",
"move_to": "העבר ל...",
"move_to_dialog": "העברה אל…",
"go_to_folder": "מעבר אל {folder}",
"copy_to_account": "העתק לחשבון...",
"remove_from_folder": "הסרה מתיקייה זו",
"archive": "ארכיון",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Rögzítés",
"unpin": "Rögzítés feloldása",
"move_to": "Áthelyezés ide...",
"move_to_dialog": "Áthelyezés ide…",
"go_to_folder": "Ugrás ide: {folder}",
"copy_to_account": "Másolás fiókba...",
"remove_from_folder": "Eltávolítás ebből a mappából",
"archive": "Archiválás",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fissa",
"unpin": "Non fissare più",
"move_to": "Sposta in...",
"move_to_dialog": "Sposta in…",
"go_to_folder": "Vai a {folder}",
"copy_to_account": "Copia in account...",
"remove_from_folder": "Rimuovi da questa cartella",
"archive": "Archivia",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "ピン留め",
"unpin": "ピン留めを外す",
"move_to": "移動...",
"move_to_dialog": "移動…",
"go_to_folder": "{folder} に移動",
"copy_to_account": "アカウントにコピー...",
"remove_from_folder": "このフォルダから削除",
"archive": "アーカイブ",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "고정",
"unpin": "고정 해제",
"move_to": "이동...",
"move_to_dialog": "이동…",
"go_to_folder": "{folder}(으)로 이동",
"copy_to_account": "계정으로 복사...",
"remove_from_folder": "이 폴더에서 제거",
"archive": "보관",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Piespraust",
"unpin": "Atspraust",
"move_to": "Pārvietot uz...",
"move_to_dialog": "Pārvietot uz…",
"go_to_folder": "Doties uz {folder}",
"copy_to_account": "Kopēt uz kontu...",
"remove_from_folder": "Noņemt no šīs mapes",
"archive": "Arhivēt",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Бэхлэх",
"unpin": "Буулгах",
"move_to": "...",
"move_to_dialog": "Шилжүүлэх…",
"go_to_folder": "{folder} руу очих",
"copy_to_account": "Бүртгэл рүү хуулах...",
"remove_from_folder": "Энэ хавтаснаас хасах",
"archive": "Архив",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fest",
"unpin": "Løsne",
"move_to": "Flytt til...",
"move_to_dialog": "Flytt til…",
"go_to_folder": "Gå til {folder}",
"copy_to_account": "Kopier til konto...",
"remove_from_folder": "Fjern fra denne mappen",
"archive": "Arkiver",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Vastmaken",
"unpin": "Losmaken",
"move_to": "Verplaatsen naar...",
"move_to_dialog": "Verplaatsen naar…",
"go_to_folder": "Naar {folder}",
"copy_to_account": "Kopiëren naar account...",
"remove_from_folder": "Uit deze map verwijderen",
"archive": "Archiveren",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Przypnij",
"unpin": "Odepnij",
"move_to": "Przenieś do...",
"move_to_dialog": "Przenieś do…",
"go_to_folder": "Przejdź do: {folder}",
"copy_to_account": "Kopiuj do konta...",
"remove_from_folder": "Usuń z tego folderu",
"archive": "Archiwizuj",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fixar",
"unpin": "Desafixar",
"move_to": "Mover para...",
"move_to_dialog": "Mover para…",
"go_to_folder": "Ir para {folder}",
"copy_to_account": "Copiar para conta...",
"remove_from_folder": "Remover desta pasta",
"archive": "Arquivar",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Fixează",
"unpin": "Anulează fixarea",
"move_to": "Mergi la...",
"move_to_dialog": "Mută în…",
"go_to_folder": "Mergi la {folder}",
"copy_to_account": "Copiază în cont...",
"remove_from_folder": "Elimină din acest dosar",
"archive": "Arhivează",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Закрепить",
"unpin": "Открепить",
"move_to": "Переместить в...",
"move_to_dialog": "Переместить в…",
"go_to_folder": "Перейти в {folder}",
"copy_to_account": "Копировать в аккаунт...",
"remove_from_folder": "Убрать из этой папки",
"archive": "В архив",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Pripnúť",
"unpin": "Odopnúť",
"move_to": "Presunúť do...",
"move_to_dialog": "Presunúť do…",
"go_to_folder": "Prejsť do {folder}",
"copy_to_account": "Kopírovať do účtu...",
"remove_from_folder": "Odstrániť z tohto priečinka",
"archive": "Archivovať",
+2
View File
@@ -563,6 +563,8 @@
"pin": "ปักหมุด",
"unpin": "ถอนหมุด",
"move_to": "ย้ายไปที่...",
"move_to_dialog": "ย้ายไปที่…",
"go_to_folder": "ไปที่ {folder}",
"copy_to_account": "คัดลอกไปยังบัญชี...",
"remove_from_folder": "นำออกจากโฟลเดอร์นี้",
"archive": "เก็บถาวร",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Sabitle",
"unpin": "Sabitlemeyi kaldır",
"move_to": "Şuraya taşı...",
"move_to_dialog": "Şuraya taşı…",
"go_to_folder": "{folder} klasörüne git",
"copy_to_account": "Hesaba kopyala...",
"remove_from_folder": "Bu klasörden kaldır",
"archive": "Arşivle",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "Закріпити",
"unpin": "Відкріпити",
"move_to": "Перемістити до...",
"move_to_dialog": "Перемістити до…",
"go_to_folder": "Перейти до {folder}",
"copy_to_account": "Копіювати в обліковий запис...",
"remove_from_folder": "Прибрати з цієї папки",
"archive": "Архів",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "固定",
"unpin": "取消固定",
"move_to": "移至…",
"move_to_dialog": "移動到…",
"go_to_folder": "前往{folder}",
"copy_to_account": "複製到帳戶…",
"remove_from_folder": "從此資料夾移除",
"archive": "封存",
+2
View File
@@ -2674,6 +2674,8 @@
"pin": "固定",
"unpin": "取消固定",
"move_to": "移动到…",
"move_to_dialog": "移动到…",
"go_to_folder": "转到{folder}",
"copy_to_account": "复制到账户…",
"remove_from_folder": "从此文件夹中移除",
"archive": "归档",