mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "تثبيت",
|
||||
"unpin": "إلغاء التثبيت",
|
||||
"move_to": "نقل إلى...",
|
||||
"move_to_dialog": "نقل إلى…",
|
||||
"go_to_folder": "الانتقال إلى {folder}",
|
||||
"copy_to_account": "نسخ إلى حساب...",
|
||||
"remove_from_folder": "إزالة من هذا المجلد",
|
||||
"archive": "أرشفة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "سنجاق کردن",
|
||||
"unpin": "برداشتن سنجاق",
|
||||
"move_to": "انتقال به...",
|
||||
"move_to_dialog": "انتقال به…",
|
||||
"go_to_folder": "رفتن به {folder}",
|
||||
"copy_to_account": "کپی به حساب...",
|
||||
"remove_from_folder": "حذف از این پوشه",
|
||||
"archive": "بایگانی",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2640,6 +2640,8 @@
|
||||
"pin": "הצמד",
|
||||
"unpin": "בטל הצמדה",
|
||||
"move_to": "העבר ל...",
|
||||
"move_to_dialog": "העברה אל…",
|
||||
"go_to_folder": "מעבר אל {folder}",
|
||||
"copy_to_account": "העתק לחשבון...",
|
||||
"remove_from_folder": "הסרה מתיקייה זו",
|
||||
"archive": "ארכיון",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "ピン留め",
|
||||
"unpin": "ピン留めを外す",
|
||||
"move_to": "移動...",
|
||||
"move_to_dialog": "移動…",
|
||||
"go_to_folder": "{folder} に移動",
|
||||
"copy_to_account": "アカウントにコピー...",
|
||||
"remove_from_folder": "このフォルダから削除",
|
||||
"archive": "アーカイブ",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "고정",
|
||||
"unpin": "고정 해제",
|
||||
"move_to": "이동...",
|
||||
"move_to_dialog": "이동…",
|
||||
"go_to_folder": "{folder}(으)로 이동",
|
||||
"copy_to_account": "계정으로 복사...",
|
||||
"remove_from_folder": "이 폴더에서 제거",
|
||||
"archive": "보관",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "Бэхлэх",
|
||||
"unpin": "Буулгах",
|
||||
"move_to": "...",
|
||||
"move_to_dialog": "Шилжүүлэх…",
|
||||
"go_to_folder": "{folder} руу очих",
|
||||
"copy_to_account": "Бүртгэл рүү хуулах...",
|
||||
"remove_from_folder": "Энэ хавтаснаас хасах",
|
||||
"archive": "Архив",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить",
|
||||
"move_to": "Переместить в...",
|
||||
"move_to_dialog": "Переместить в…",
|
||||
"go_to_folder": "Перейти в {folder}",
|
||||
"copy_to_account": "Копировать в аккаунт...",
|
||||
"remove_from_folder": "Убрать из этой папки",
|
||||
"archive": "В архив",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -563,6 +563,8 @@
|
||||
"pin": "ปักหมุด",
|
||||
"unpin": "ถอนหมุด",
|
||||
"move_to": "ย้ายไปที่...",
|
||||
"move_to_dialog": "ย้ายไปที่…",
|
||||
"go_to_folder": "ไปที่ {folder}",
|
||||
"copy_to_account": "คัดลอกไปยังบัญชี...",
|
||||
"remove_from_folder": "นำออกจากโฟลเดอร์นี้",
|
||||
"archive": "เก็บถาวร",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "Закріпити",
|
||||
"unpin": "Відкріпити",
|
||||
"move_to": "Перемістити до...",
|
||||
"move_to_dialog": "Перемістити до…",
|
||||
"go_to_folder": "Перейти до {folder}",
|
||||
"copy_to_account": "Копіювати в обліковий запис...",
|
||||
"remove_from_folder": "Прибрати з цієї папки",
|
||||
"archive": "Архів",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "固定",
|
||||
"unpin": "取消固定",
|
||||
"move_to": "移至…",
|
||||
"move_to_dialog": "移動到…",
|
||||
"go_to_folder": "前往{folder}",
|
||||
"copy_to_account": "複製到帳戶…",
|
||||
"remove_from_folder": "從此資料夾移除",
|
||||
"archive": "封存",
|
||||
|
||||
@@ -2674,6 +2674,8 @@
|
||||
"pin": "固定",
|
||||
"unpin": "取消固定",
|
||||
"move_to": "移动到…",
|
||||
"move_to_dialog": "移动到…",
|
||||
"go_to_folder": "转到{folder}",
|
||||
"copy_to_account": "复制到账户…",
|
||||
"remove_from_folder": "从此文件夹中移除",
|
||||
"archive": "归档",
|
||||
|
||||
Reference in New Issue
Block a user