mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: list your contacts in the composer's recipient picker, with the directory on its own tab
This commit is contained in:
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DirectoryPickerDialog } from '../directory-picker-dialog';
|
||||
import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
// next-intl is mocked globally in vitest.setup (t returns the key).
|
||||
|
||||
@@ -25,7 +26,11 @@ function rows() {
|
||||
}
|
||||
|
||||
describe('DirectoryPickerDialog', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// No contacts of the user's own: the picker opens on the directory.
|
||||
useContactStore.setState({ contacts: [] });
|
||||
});
|
||||
|
||||
it('lists the directory before any search, one page at a time', () => {
|
||||
setDirectory(people(60), 'ready');
|
||||
@@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => {
|
||||
expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// compose-17: the picker listed only the server's directory, not the
|
||||
// contacts the To field's suggestions offer.
|
||||
describe('DirectoryPickerDialog with contacts', () => {
|
||||
function contact(id: string, name: string, ...emails: string[]): ContactCard {
|
||||
return {
|
||||
id, addressBookIds: { book: true },
|
||||
name: { full: name },
|
||||
emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])),
|
||||
} as unknown as ContactCard;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready');
|
||||
useContactStore.setState({
|
||||
contacts: [
|
||||
contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'),
|
||||
contact('c2', 'Anna Becker', 'anna@example.com'),
|
||||
{ id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('opens on the contacts, one row per address, without groups', () => {
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(rows().map((r) => r.textContent)).toEqual([
|
||||
expect.stringContaining('anna@example.com'),
|
||||
expect.stringContaining('zoe.f@work.example'),
|
||||
expect.stringContaining('zoe@example.com'),
|
||||
]);
|
||||
expect(screen.queryByText('Server Account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches the contacts', () => {
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } });
|
||||
expect(rows()).toHaveLength(1);
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } });
|
||||
expect(screen.getByText('contacts_no_match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds what was checked on both tabs', () => {
|
||||
const onAdd = vi.fn();
|
||||
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={onAdd} />);
|
||||
fireEvent.click(rows()[0]);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' }));
|
||||
fireEvent.click(rows()[0]);
|
||||
fireEvent.click(screen.getByText('add_to'));
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith('to', [
|
||||
expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }),
|
||||
expect.objectContaining({ email: 'server@example.org' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the contacts alone where the server has no directory', () => {
|
||||
const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient;
|
||||
render(<DirectoryPickerDialog client={noDirectory} onClose={vi.fn()} onAdd={vi.fn()} />);
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ interface DirectoryBrowserProps {
|
||||
/** Set while the list is being printed. */
|
||||
printing?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Browse these instead of the server's directory (the recipient picker's
|
||||
* Contacts tab): nothing is fetched and there is nothing to refresh.
|
||||
*/
|
||||
entries?: DirectoryEntry[];
|
||||
/** With `entries`: the list's name and what an empty list or search says. */
|
||||
labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,13 +76,17 @@ export function DirectoryBrowser({
|
||||
onPrint,
|
||||
printing = false,
|
||||
className,
|
||||
entries: ownEntries,
|
||||
labels,
|
||||
}: DirectoryBrowserProps) {
|
||||
const t = useTranslations("contacts.directory");
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tCommon = useTranslations("common");
|
||||
const locale = useLocale();
|
||||
const entries = useContactStore((s) => s.directoryPrincipals);
|
||||
const status = useContactStore((s) => s.directoryStatus);
|
||||
const directoryEntries = useContactStore((s) => s.directoryPrincipals);
|
||||
const directoryStatus = useContactStore((s) => s.directoryStatus);
|
||||
const entries = ownEntries ?? directoryEntries;
|
||||
const status = ownEntries ? "ready" : directoryStatus;
|
||||
const fetchDirectory = useContactStore((s) => s.fetchDirectory);
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -83,8 +94,8 @@ export function DirectoryBrowser({
|
||||
|
||||
// The directory loads at sign-in; fetch here only if that never ran.
|
||||
useEffect(() => {
|
||||
if (client && status === "idle") void fetchDirectory(client);
|
||||
}, [client, status, fetchDirectory]);
|
||||
if (!ownEntries && client && status === "idle") void fetchDirectory(client);
|
||||
}, [ownEntries, client, status, fetchDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocusSearch) searchRef.current?.focus();
|
||||
@@ -117,7 +128,9 @@ export function DirectoryBrowser({
|
||||
};
|
||||
|
||||
let placeholder: React.ReactNode = null;
|
||||
if (entries.length === 0) {
|
||||
if (entries.length === 0 && ownEntries) {
|
||||
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />;
|
||||
} else if (entries.length === 0) {
|
||||
if (loading) {
|
||||
placeholder = (
|
||||
<StatePanel icon={<Loader2 className="w-6 h-6 animate-spin" />} title={t("loading")} />
|
||||
@@ -132,7 +145,7 @@ export function DirectoryBrowser({
|
||||
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={t("empty_title")} hint={t("empty_hint")} />;
|
||||
}
|
||||
} else if (filtered.length === 0) {
|
||||
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={t("no_match")} />;
|
||||
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={labels?.noMatch ?? t("no_match")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,13 +176,15 @@ export function DirectoryBrowser({
|
||||
onClear={() => { setQuery(""); setPage(0); }}
|
||||
clearLabel={tContacts("clear_search")}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={t("refresh")}
|
||||
onClick={refresh}
|
||||
disabled={!client || status === "loading" || status === "unavailable"}
|
||||
>
|
||||
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
|
||||
</ToolbarButton>
|
||||
{!ownEntries && (
|
||||
<ToolbarButton
|
||||
label={t("refresh")}
|
||||
onClick={refresh}
|
||||
disabled={!client || status === "loading" || status === "unavailable"}
|
||||
>
|
||||
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{onPrint && (
|
||||
<ToolbarButton label={tCommon("print")} onClick={onPrint}>
|
||||
<Printer />
|
||||
@@ -186,7 +201,7 @@ export function DirectoryBrowser({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={t("title")}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={labels?.list ?? t("title")}>
|
||||
{placeholder ?? pageEntries.map((entry) => {
|
||||
const key = directoryKey(entry);
|
||||
const checked = selected.has(key);
|
||||
|
||||
@@ -1,36 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store";
|
||||
import { DirectoryBrowser, directoryKey } from "./directory-browser";
|
||||
|
||||
export type RecipientField = "to" | "cc" | "bcc";
|
||||
|
||||
type PickerTab = "contacts" | "directory";
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
client: IJMAPClient | null;
|
||||
onClose: () => void;
|
||||
/** Called with the checked entries in directory order; the dialog closes afterwards. */
|
||||
/** Called with the checked entries in list order; the dialog closes afterwards. */
|
||||
onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void;
|
||||
}
|
||||
|
||||
/** The composer's "Select from directory" dialog. */
|
||||
/** Every address of the user's own contacts, one row each; groups are left out. */
|
||||
export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: DirectoryEntry[] = [];
|
||||
for (const contact of contacts) {
|
||||
if (contact.kind === "group") continue;
|
||||
const name = getContactDisplayName(contact);
|
||||
for (const email of Object.values(contact.emails ?? {})) {
|
||||
const address = email?.address?.trim();
|
||||
if (!address) continue;
|
||||
const entry = { id: `${contact.id}:${address}`, name: name || address, email: address };
|
||||
if (seen.has(directoryKey(entry))) continue;
|
||||
seen.add(directoryKey(entry));
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's recipient picker: the user's own contacts, and the server's
|
||||
* directory (other accounts) on a tab of its own where the server has one.
|
||||
* It used to list only the directory, without the contacts autocomplete offers.
|
||||
*/
|
||||
export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) {
|
||||
const t = useTranslations("contacts.directory");
|
||||
const tSend = useTranslations("compose_send");
|
||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]);
|
||||
const hasDirectory = !!client?.supportsPrincipals?.();
|
||||
// Contacts first; straight to the directory for someone who has none.
|
||||
const [tab, setTab] = useState<PickerTab>(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts"));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const add = (field: RecipientField) => {
|
||||
const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e)));
|
||||
// Checked on either tab, each address once.
|
||||
const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals];
|
||||
const seen = new Set<string>();
|
||||
const entries = pool.filter((e) => {
|
||||
const key = directoryKey(e);
|
||||
if (!selected.has(key) || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
if (entries.length === 0) return;
|
||||
onAdd(field, entries);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const tabButton = (value: PickerTab, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`recipient-picker-tab-${value}`}
|
||||
aria-selected={tab === value}
|
||||
aria-controls="recipient-picker-panel"
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
@@ -43,7 +101,7 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id="directory-picker-title" className="text-sm font-semibold text-foreground">
|
||||
{t("picker_title")}
|
||||
{tSend("picker_title")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,13 +113,40 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DirectoryBrowser
|
||||
client={client}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
autoFocusSearch
|
||||
className="flex-1"
|
||||
/>
|
||||
{hasDirectory && (
|
||||
<div role="tablist" className="flex gap-1 px-3 border-b border-border">
|
||||
{tabButton("contacts", tSend("tab_contacts"))}
|
||||
{tabButton("directory", tSend("tab_directory"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id="recipient-picker-panel"
|
||||
role={hasDirectory ? "tabpanel" : undefined}
|
||||
aria-labelledby={hasDirectory ? `recipient-picker-tab-${tab}` : undefined}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<DirectoryBrowser
|
||||
// A fresh search and first page on each tab.
|
||||
key={tab}
|
||||
client={client}
|
||||
selected={selected}
|
||||
onSelectedChange={setSelected}
|
||||
autoFocusSearch
|
||||
className="flex-1"
|
||||
{...(tab === "contacts"
|
||||
? {
|
||||
entries: ownEntries,
|
||||
labels: {
|
||||
list: tSend("tab_contacts"),
|
||||
emptyTitle: tSend("contacts_empty_title"),
|
||||
emptyHint: tSend("contacts_empty_hint"),
|
||||
noMatch: tSend("contacts_no_match"),
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground me-auto tabular-nums">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
@@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => {
|
||||
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
|
||||
});
|
||||
|
||||
// compose-17: the button was there only with a server directory, so
|
||||
// contacts alone were never offered.
|
||||
it('offers the recipient picker for the contacts when the server has no directory', async () => {
|
||||
const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState();
|
||||
contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }];
|
||||
try {
|
||||
await renderComposer();
|
||||
const button = screen.getByTestId('composer-directory-picker');
|
||||
expect(button).toHaveAccessibleName('picker_button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument();
|
||||
} finally {
|
||||
contactState.contacts = [];
|
||||
}
|
||||
});
|
||||
|
||||
// The directory picker is drawn inside the composer itself, not portaled.
|
||||
it('lets Escape in the directory picker leave the composer alone', async () => {
|
||||
// Every other capability answers nothing.
|
||||
|
||||
@@ -1775,6 +1775,9 @@ export function EmailComposer({
|
||||
// Whether a Sent mailbox is known so the on-demand server search is worth
|
||||
// offering (falls back to hiding the "search the server" row otherwise).
|
||||
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
|
||||
// The recipient picker: the user's contacts, and the server's directory.
|
||||
const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0);
|
||||
const tSend = useTranslations('compose_send');
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
@@ -5091,15 +5094,15 @@ export function EmailComposer({
|
||||
onMoveChip={handleMoveChip}
|
||||
/>
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
{activeClient?.supportsPrincipals?.() && (
|
||||
{(hasContacts || activeClient?.supportsPrincipals?.()) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDirectoryPicker(true)}
|
||||
className="h-7 w-7 p-0"
|
||||
title={t('select_from_directory')}
|
||||
aria-label={t('select_from_directory')}
|
||||
title={tSend('picker_button')}
|
||||
aria-label={tSend('picker_button')}
|
||||
data-testid="composer-directory-picker"
|
||||
>
|
||||
<BookUser className="w-4 h-4" />
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "مجدولة في {when}",
|
||||
"sending": "جارٍ الإرسال…",
|
||||
"sent": "تم الإرسال",
|
||||
"view": "عرض"
|
||||
"view": "عرض",
|
||||
"picker_button": "إضافة من جهات الاتصال",
|
||||
"picker_title": "إضافة مستلمين",
|
||||
"tab_contacts": "جهات الاتصال",
|
||||
"tab_directory": "الدليل",
|
||||
"contacts_empty_title": "لا توجد جهات اتصال بعد",
|
||||
"contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.",
|
||||
"contacts_no_match": "لا توجد جهة اتصال تطابق بحثك"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "قوالبي",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Programat per a {when}",
|
||||
"sending": "S'està enviant…",
|
||||
"sent": "Enviat",
|
||||
"view": "Mostra"
|
||||
"view": "Mostra",
|
||||
"picker_button": "Afegeix des dels contactes",
|
||||
"picker_title": "Afegeix destinataris",
|
||||
"tab_contacts": "Contactes",
|
||||
"tab_directory": "Directori",
|
||||
"contacts_empty_title": "Encara no hi ha contactes",
|
||||
"contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.",
|
||||
"contacts_no_match": "Cap contacte coincideix amb la cerca"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Les meves plantilles",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Naplánováno na {when}",
|
||||
"sending": "Odesílání…",
|
||||
"sent": "Odesláno",
|
||||
"view": "Zobrazit"
|
||||
"view": "Zobrazit",
|
||||
"picker_button": "Přidat z kontaktů",
|
||||
"picker_title": "Přidat příjemce",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Adresář organizace",
|
||||
"contacts_empty_title": "Zatím žádné kontakty",
|
||||
"contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.",
|
||||
"contacts_no_match": "Hledání neodpovídá žádný kontakt"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje šablony",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Planlagt til {when}",
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis"
|
||||
"view": "Vis",
|
||||
"picker_button": "Tilføj fra kontakter",
|
||||
"picker_title": "Tilføj modtagere",
|
||||
"tab_contacts": "Kontakter",
|
||||
"tab_directory": "Adressefortegnelse",
|
||||
"contacts_empty_title": "Ingen kontakter endnu",
|
||||
"contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.",
|
||||
"contacts_no_match": "Ingen kontakter matcher din søgning"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mine skabeloner",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Geplant für {when}",
|
||||
"sending": "Wird gesendet…",
|
||||
"sent": "Gesendet",
|
||||
"view": "Anzeigen"
|
||||
"view": "Anzeigen",
|
||||
"picker_button": "Aus Kontakten hinzufügen",
|
||||
"picker_title": "Empfänger hinzufügen",
|
||||
"tab_contacts": "Kontakte",
|
||||
"tab_directory": "Verzeichnis",
|
||||
"contacts_empty_title": "Noch keine Kontakte",
|
||||
"contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.",
|
||||
"contacts_no_match": "Kein Kontakt passt zu Ihrer Suche"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Meine Vorlagen",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Scheduled for {when}",
|
||||
"sending": "Sending…",
|
||||
"sent": "Sent",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"picker_button": "Add from contacts",
|
||||
"picker_title": "Add recipients",
|
||||
"tab_contacts": "Contacts",
|
||||
"tab_directory": "Directory",
|
||||
"contacts_empty_title": "No contacts yet",
|
||||
"contacts_empty_hint": "People you save in Contacts show up here.",
|
||||
"contacts_no_match": "No contact matches your search"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "My templates",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Programado para {when}",
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver"
|
||||
"view": "Ver",
|
||||
"picker_button": "Añadir desde contactos",
|
||||
"picker_title": "Añadir destinatarios",
|
||||
"tab_contacts": "Contactos",
|
||||
"tab_directory": "Directorio",
|
||||
"contacts_empty_title": "Aún no hay contactos",
|
||||
"contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.",
|
||||
"contacts_no_match": "Ningún contacto coincide con la búsqueda"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mis plantillas",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Ajastatud: {when}",
|
||||
"sending": "Saatmine…",
|
||||
"sent": "Saadetud",
|
||||
"view": "Vaata"
|
||||
"view": "Vaata",
|
||||
"picker_button": "Lisa kontaktidest",
|
||||
"picker_title": "Lisa saajaid",
|
||||
"tab_contacts": "Kontaktid",
|
||||
"tab_directory": "Kataloog",
|
||||
"contacts_empty_title": "Kontakte veel pole",
|
||||
"contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.",
|
||||
"contacts_no_match": "Otsingule ei vasta ükski kontakt"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Minu mallid",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "زمانبندیشده برای {when}",
|
||||
"sending": "در حال ارسال…",
|
||||
"sent": "ارسال شد",
|
||||
"view": "مشاهده"
|
||||
"view": "مشاهده",
|
||||
"picker_button": "افزودن از مخاطبین",
|
||||
"picker_title": "افزودن گیرندگان",
|
||||
"tab_contacts": "مخاطبین",
|
||||
"tab_directory": "فهرست سازمانی",
|
||||
"contacts_empty_title": "هنوز مخاطبی وجود ندارد",
|
||||
"contacts_empty_hint": "افرادی که در مخاطبین ذخیره میکنید اینجا نمایش داده میشوند.",
|
||||
"contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "قالبهای من",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Programmé pour {when}",
|
||||
"sending": "Envoi…",
|
||||
"sent": "Envoyé",
|
||||
"view": "Afficher"
|
||||
"view": "Afficher",
|
||||
"picker_button": "Ajouter depuis les contacts",
|
||||
"picker_title": "Ajouter des destinataires",
|
||||
"tab_contacts": "Contacts",
|
||||
"tab_directory": "Annuaire",
|
||||
"contacts_empty_title": "Aucun contact pour l'instant",
|
||||
"contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.",
|
||||
"contacts_no_match": "Aucun contact ne correspond à votre recherche"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mes modèles",
|
||||
|
||||
@@ -3807,7 +3807,14 @@
|
||||
"scheduled_for": "מתוזמן ל-{when}",
|
||||
"sending": "שולח…",
|
||||
"sent": "נשלח",
|
||||
"view": "הצג"
|
||||
"view": "הצג",
|
||||
"picker_button": "הוסף מאנשי הקשר",
|
||||
"picker_title": "הוספת נמענים",
|
||||
"tab_contacts": "אנשי קשר",
|
||||
"tab_directory": "ספרייה",
|
||||
"contacts_empty_title": "אין עדיין אנשי קשר",
|
||||
"contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.",
|
||||
"contacts_no_match": "אין איש קשר שתואם לחיפוש"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "התבניות שלי",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Ütemezve: {when}",
|
||||
"sending": "Küldés…",
|
||||
"sent": "Elküldve",
|
||||
"view": "Megtekintés"
|
||||
"view": "Megtekintés",
|
||||
"picker_button": "Hozzáadás a névjegyekből",
|
||||
"picker_title": "Címzettek hozzáadása",
|
||||
"tab_contacts": "Névjegyek",
|
||||
"tab_directory": "Címtár",
|
||||
"contacts_empty_title": "Még nincsenek névjegyek",
|
||||
"contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.",
|
||||
"contacts_no_match": "Egy névjegy sem felel meg a keresésnek"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Saját sablonok",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Programmato per {when}",
|
||||
"sending": "Invio in corso…",
|
||||
"sent": "Inviato",
|
||||
"view": "Visualizza"
|
||||
"view": "Visualizza",
|
||||
"picker_button": "Aggiungi dai contatti",
|
||||
"picker_title": "Aggiungi destinatari",
|
||||
"tab_contacts": "Contatti",
|
||||
"tab_directory": "Rubrica aziendale",
|
||||
"contacts_empty_title": "Ancora nessun contatto",
|
||||
"contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.",
|
||||
"contacts_no_match": "Nessun contatto corrisponde alla ricerca"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "I miei modelli",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "{when} に送信予定",
|
||||
"sending": "送信中…",
|
||||
"sent": "送信しました",
|
||||
"view": "表示"
|
||||
"view": "表示",
|
||||
"picker_button": "連絡先から追加",
|
||||
"picker_title": "宛先を追加",
|
||||
"tab_contacts": "連絡先",
|
||||
"tab_directory": "ディレクトリ",
|
||||
"contacts_empty_title": "連絡先はまだありません",
|
||||
"contacts_empty_hint": "連絡先に保存した人がここに表示されます。",
|
||||
"contacts_no_match": "検索に一致する連絡先はありません"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "マイテンプレート",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "{when}에 보내기 예약됨",
|
||||
"sending": "보내는 중…",
|
||||
"sent": "보냈어요",
|
||||
"view": "보기"
|
||||
"view": "보기",
|
||||
"picker_button": "연락처에서 추가",
|
||||
"picker_title": "받는 사람 추가",
|
||||
"tab_contacts": "연락처",
|
||||
"tab_directory": "디렉터리",
|
||||
"contacts_empty_title": "아직 연락처가 없어요",
|
||||
"contacts_empty_hint": "연락처에 저장한 사람이 여기에 표시돼요.",
|
||||
"contacts_no_match": "검색과 일치하는 연락처가 없어요"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "내 템플릿",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Ieplānots: {when}",
|
||||
"sending": "Sūta…",
|
||||
"sent": "Nosūtīts",
|
||||
"view": "Skatīt"
|
||||
"view": "Skatīt",
|
||||
"picker_button": "Pievienot no kontaktiem",
|
||||
"picker_title": "Pievienot saņēmējus",
|
||||
"tab_contacts": "Kontakti",
|
||||
"tab_directory": "Katalogs",
|
||||
"contacts_empty_title": "Vēl nav kontaktu",
|
||||
"contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.",
|
||||
"contacts_no_match": "Meklēšanai neatbilst neviens kontakts"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Manas veidnes",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "{when}-д илгээхээр товлосон",
|
||||
"sending": "Илгээж байна…",
|
||||
"sent": "Илгээгдлээ",
|
||||
"view": "Харах"
|
||||
"view": "Харах",
|
||||
"picker_button": "Харилцагчдаас нэмэх",
|
||||
"picker_title": "Хүлээн авагч нэмэх",
|
||||
"tab_contacts": "Харилцагчид",
|
||||
"tab_directory": "Лавлах",
|
||||
"contacts_empty_title": "Одоогоор харилцагч алга",
|
||||
"contacts_empty_hint": "Харилцагчдад хадгалсан хүмүүс энд харагдана.",
|
||||
"contacts_no_match": "Хайлтад тохирох харилцагч алга"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Миний загварууд",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Planlagt til {when}",
|
||||
"sending": "Sender…",
|
||||
"sent": "Sendt",
|
||||
"view": "Vis"
|
||||
"view": "Vis",
|
||||
"picker_button": "Legg til fra kontakter",
|
||||
"picker_title": "Legg til mottakere",
|
||||
"tab_contacts": "Kontakter",
|
||||
"tab_directory": "Katalog",
|
||||
"contacts_empty_title": "Ingen kontakter ennå",
|
||||
"contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.",
|
||||
"contacts_no_match": "Ingen kontakter samsvarer med søket"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mine maler",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Gepland voor {when}",
|
||||
"sending": "Bezig met verzenden…",
|
||||
"sent": "Verzonden",
|
||||
"view": "Bekijken"
|
||||
"view": "Bekijken",
|
||||
"picker_button": "Toevoegen uit contacten",
|
||||
"picker_title": "Ontvangers toevoegen",
|
||||
"tab_contacts": "Contacten",
|
||||
"tab_directory": "Adresboek van de organisatie",
|
||||
"contacts_empty_title": "Nog geen contacten",
|
||||
"contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.",
|
||||
"contacts_no_match": "Geen contact komt overeen met uw zoekopdracht"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mijn sjablonen",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Zaplanowano na {when}",
|
||||
"sending": "Wysyłanie…",
|
||||
"sent": "Wysłano",
|
||||
"view": "Pokaż"
|
||||
"view": "Pokaż",
|
||||
"picker_button": "Dodaj z kontaktów",
|
||||
"picker_title": "Dodaj odbiorców",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Katalog",
|
||||
"contacts_empty_title": "Brak kontaktów",
|
||||
"contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.",
|
||||
"contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje szablony",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Agendado para {when}",
|
||||
"sending": "Enviando…",
|
||||
"sent": "Enviado",
|
||||
"view": "Ver"
|
||||
"view": "Ver",
|
||||
"picker_button": "Adicionar dos contatos",
|
||||
"picker_title": "Adicionar destinatários",
|
||||
"tab_contacts": "Contatos",
|
||||
"tab_directory": "Diretório",
|
||||
"contacts_empty_title": "Nenhum contato ainda",
|
||||
"contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.",
|
||||
"contacts_no_match": "Nenhum contato corresponde à sua pesquisa"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Meus modelos",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Programat pentru {when}",
|
||||
"sending": "Se trimite…",
|
||||
"sent": "Trimis",
|
||||
"view": "Vezi"
|
||||
"view": "Vezi",
|
||||
"picker_button": "Adaugă din contacte",
|
||||
"picker_title": "Adaugă destinatari",
|
||||
"tab_contacts": "Contacte",
|
||||
"tab_directory": "Director",
|
||||
"contacts_empty_title": "Încă nu există contacte",
|
||||
"contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.",
|
||||
"contacts_no_match": "Niciun contact nu corespunde căutării"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Șabloanele mele",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Запланировано на {when}",
|
||||
"sending": "Отправка…",
|
||||
"sent": "Отправлено",
|
||||
"view": "Открыть"
|
||||
"view": "Открыть",
|
||||
"picker_button": "Добавить из контактов",
|
||||
"picker_title": "Добавить получателей",
|
||||
"tab_contacts": "Контакты",
|
||||
"tab_directory": "Справочник",
|
||||
"contacts_empty_title": "Контактов пока нет",
|
||||
"contacts_empty_hint": "Здесь появятся люди, которых вы сохраните в контактах.",
|
||||
"contacts_no_match": "Нет контактов, подходящих под запрос"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Мои шаблоны",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Naplánované na {when}",
|
||||
"sending": "Odosielanie…",
|
||||
"sent": "Odoslané",
|
||||
"view": "Zobraziť"
|
||||
"view": "Zobraziť",
|
||||
"picker_button": "Pridať z kontaktov",
|
||||
"picker_title": "Pridať príjemcov",
|
||||
"tab_contacts": "Kontakty",
|
||||
"tab_directory": "Adresár organizácie",
|
||||
"contacts_empty_title": "Zatiaľ žiadne kontakty",
|
||||
"contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.",
|
||||
"contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Moje šablóny",
|
||||
|
||||
@@ -1161,7 +1161,14 @@
|
||||
"scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}",
|
||||
"sending": "กำลังส่ง…",
|
||||
"sent": "ส่งแล้ว",
|
||||
"view": "ดู"
|
||||
"view": "ดู",
|
||||
"picker_button": "เพิ่มจากผู้ติดต่อ",
|
||||
"picker_title": "เพิ่มผู้รับ",
|
||||
"tab_contacts": "ผู้ติดต่อ",
|
||||
"tab_directory": "ไดเรกทอรีกลาง",
|
||||
"contacts_empty_title": "ยังไม่มีผู้ติดต่อ",
|
||||
"contacts_empty_hint": "คนที่คุณบันทึกไว้ในผู้ติดต่อจะแสดงที่นี่",
|
||||
"contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "แม่แบบของฉัน",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "{when} için zamanlandı",
|
||||
"sending": "Gönderiliyor…",
|
||||
"sent": "Gönderildi",
|
||||
"view": "Görüntüle"
|
||||
"view": "Görüntüle",
|
||||
"picker_button": "Kişilerden ekle",
|
||||
"picker_title": "Alıcı ekle",
|
||||
"tab_contacts": "Kişiler",
|
||||
"tab_directory": "Dizin",
|
||||
"contacts_empty_title": "Henüz kişi yok",
|
||||
"contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.",
|
||||
"contacts_no_match": "Aramanızla eşleşen kişi yok"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Şablonlarım",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "Заплановано на {when}",
|
||||
"sending": "Надсилання…",
|
||||
"sent": "Надіслано",
|
||||
"view": "Переглянути"
|
||||
"view": "Переглянути",
|
||||
"picker_button": "Додати з контактів",
|
||||
"picker_title": "Додати одержувачів",
|
||||
"tab_contacts": "Контакти",
|
||||
"tab_directory": "Довідник",
|
||||
"contacts_empty_title": "Контактів ще немає",
|
||||
"contacts_empty_hint": "Тут з’являться люди, яких ви збережете в контактах.",
|
||||
"contacts_no_match": "Немає контактів, що відповідають пошуку"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Мої шаблони",
|
||||
|
||||
@@ -3922,7 +3922,14 @@
|
||||
"scheduled_for": "Đã lên lịch gửi lúc {when}",
|
||||
"sending": "Đang gửi…",
|
||||
"sent": "Đã gửi",
|
||||
"view": "Xem"
|
||||
"view": "Xem",
|
||||
"picker_button": "Thêm từ danh bạ",
|
||||
"picker_title": "Thêm người nhận",
|
||||
"tab_contacts": "Danh bạ",
|
||||
"tab_directory": "Danh bạ tổ chức",
|
||||
"contacts_empty_title": "Chưa có liên hệ nào",
|
||||
"contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.",
|
||||
"contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Mẫu của tôi",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "已排定於 {when} 傳送",
|
||||
"sending": "正在傳送…",
|
||||
"sent": "已傳送",
|
||||
"view": "檢視"
|
||||
"view": "檢視",
|
||||
"picker_button": "從聯絡人新增",
|
||||
"picker_title": "新增收件者",
|
||||
"tab_contacts": "聯絡人",
|
||||
"tab_directory": "通訊錄目錄",
|
||||
"contacts_empty_title": "還沒有聯絡人",
|
||||
"contacts_empty_hint": "你在聯絡人中儲存的人會顯示在這裡。",
|
||||
"contacts_no_match": "沒有符合搜尋的聯絡人"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "我的範本",
|
||||
|
||||
@@ -3910,7 +3910,14 @@
|
||||
"scheduled_for": "已安排在 {when} 发送",
|
||||
"sending": "正在发送…",
|
||||
"sent": "已发送",
|
||||
"view": "查看"
|
||||
"view": "查看",
|
||||
"picker_button": "从联系人添加",
|
||||
"picker_title": "添加收件人",
|
||||
"tab_contacts": "联系人",
|
||||
"tab_directory": "通讯录目录",
|
||||
"contacts_empty_title": "还没有联系人",
|
||||
"contacts_empty_hint": "你在联系人中保存的人会显示在这里。",
|
||||
"contacts_no_match": "没有符合搜索的联系人"
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "我的模板",
|
||||
|
||||
Reference in New Issue
Block a user