feat: list your contacts in the composer's recipient picker, with the directory on its own tab

This commit is contained in:
Linus Rath
2026-11-04 07:03:15 +01:00
parent 781013cc44
commit 0ced5482d5
35 changed files with 462 additions and 61 deletions
@@ -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);
});
});
+29 -14
View File
@@ -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);
+98 -13
View File
@@ -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.
+6 -3
View File
@@ -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" />
+8 -1
View File
@@ -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": "قوالبي",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "قالب‌های من",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "התבניות שלי",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "マイテンプレート",
+8 -1
View File
@@ -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": "내 템플릿",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "Миний загварууд",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "Мои шаблоны",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "แม่แบบของฉัน",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "Мої шаблони",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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": "我的範本",
+8 -1
View File
@@ -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": "我的模板",