feat: add address book, fix email layout, update dependencies
- Address book with JMAP sync and local fallback (contacts CRUD, search/filter, composer autocomplete, i18n for 8 languages) - Fix email layout: remove horizontal scroll, left-side clipping, and empty spaces from blocked external images in newsletters - Update all dependencies to latest compatible versions - Expand i18n from 3 to 8 languages (added ES, IT, DE, NL, PT) - Upgrade Next.js to 16.1.6 for security patches
This commit is contained in:
@@ -43,6 +43,16 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
||||
- Live email arrival notifications
|
||||
- Connection status indicator
|
||||
|
||||
### Identity Management
|
||||
- Multiple sender identities with per-identity signatures
|
||||
- Sub-addressing support (user+tag@domain.com) with tag suggestions
|
||||
- Identity badges in email viewer and list
|
||||
|
||||
### Address Book
|
||||
- Contact management with search and filtering
|
||||
- JMAP server sync (RFC 9553/9610) with local fallback
|
||||
- Email autocomplete from contacts in composer
|
||||
|
||||
### Security & Privacy
|
||||
- External content blocked by default
|
||||
- Trusted senders list for automatic image loading
|
||||
@@ -50,6 +60,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
||||
- SPF/DKIM/DMARC status indicators
|
||||
- No password storage (session-based auth)
|
||||
- Shared folder support with proper permissions
|
||||
- Newsletter unsubscribe support (RFC 2369)
|
||||
|
||||
### Internationalization
|
||||
- 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
|
||||
|
||||
+19
-10
@@ -96,10 +96,24 @@ This document tracks the development status and planned features for JMAP Webmai
|
||||
- [x] Identity badges in email viewer and list
|
||||
- [x] Tag suggestions based on context
|
||||
|
||||
### Address Book & Contacts
|
||||
- [x] Contact store with JMAP sync and local fallback
|
||||
- [x] Contact CRUD operations (create, read, update, delete)
|
||||
- [x] Contacts list view with search/filter
|
||||
- [x] Contact details view/edit form
|
||||
- [x] JMAP contacts sync (RFC 9553/9610 ContactCard/AddressBook)
|
||||
- [x] Email autocomplete from contacts
|
||||
- [x] Contacts integration in email composer (To/Cc/Bcc)
|
||||
- [x] i18n support for contacts (all 8 languages)
|
||||
|
||||
### Email Display
|
||||
- [x] Proper email layout without horizontal scroll or clipping
|
||||
- [x] Blocked image container collapsing (no empty spaces in newsletters)
|
||||
|
||||
### Testing
|
||||
- [x] Unit tests for validation utilities (57 tests)
|
||||
- [x] Unit tests for email sanitization
|
||||
- [x] Unit tests for color transformation
|
||||
- [x] Unit tests for email sanitization (27 tests)
|
||||
- [x] Unit tests for color transformation (40 tests)
|
||||
- [x] XSS attack vector testing
|
||||
|
||||
### Deployment
|
||||
@@ -107,15 +121,10 @@ This document tracks the development status and planned features for JMAP Webmai
|
||||
|
||||
## Planned Features
|
||||
|
||||
### Address Book & Contacts
|
||||
- [ ] Contact store with CRUD operations
|
||||
- [ ] Contacts list view with search/filter
|
||||
- [ ] Contact details view/edit form
|
||||
- [ ] Contact groups management
|
||||
### Address Book (Phase 2)
|
||||
- [ ] Contact groups/lists management
|
||||
- [ ] vCard import/export
|
||||
- [ ] JMAP contacts sync (if server supports)
|
||||
- [ ] Email autocomplete from contacts
|
||||
- [ ] Contacts integration in composer
|
||||
- [ ] Bulk contact operations
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Email filters and rules
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactList } from "@/components/contacts/contact-list";
|
||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||
import { ContactForm } from "@/components/contacts/contact-form";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
type View = "list" | "detail" | "create" | "edit";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("contacts");
|
||||
const { client, isAuthenticated } = useAuthStore();
|
||||
const {
|
||||
contacts,
|
||||
selectedContactId,
|
||||
searchQuery,
|
||||
supportsSync,
|
||||
setSelectedContact,
|
||||
setSearchQuery,
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
addLocalContact,
|
||||
updateLocalContact,
|
||||
deleteLocalContact,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchContacts(client);
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
|
||||
const handleSelectContact = (id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("detail");
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedContact(null);
|
||||
setView("create");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContact) return;
|
||||
if (!window.confirm(t("delete_confirm"))) return;
|
||||
|
||||
try {
|
||||
if (supportsSync && client) {
|
||||
await deleteContact(client, selectedContact.id);
|
||||
} else {
|
||||
deleteLocalContact(selectedContact.id);
|
||||
}
|
||||
toast.success(t("toast.deleted"));
|
||||
setView("list");
|
||||
} catch {
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
toast.success(t("toast.created"));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
addressBookIds: {},
|
||||
...data,
|
||||
};
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (!selectedContact) return;
|
||||
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, selectedContact.id, data);
|
||||
toast.success(t("toast.updated"));
|
||||
} else {
|
||||
updateLocalContact(selectedContact.id, data);
|
||||
toast.success(t("toast.updated"));
|
||||
}
|
||||
setView("detail");
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setView(selectedContact ? "detail" : "list");
|
||||
};
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<div className="w-80 border-r border-border flex flex-col">
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/")}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t("back_to_mail")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ContactList
|
||||
contacts={contacts}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
supportsSync={supportsSync}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{view === "create" && (
|
||||
<ContactForm onSave={handleSaveNew} onCancel={handleCancel} />
|
||||
)}
|
||||
{view === "edit" && selectedContact && (
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{(view === "list" || view === "detail") && (
|
||||
<ContactDetail
|
||||
contact={selectedContact}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-12
@@ -88,9 +88,7 @@ body {
|
||||
|
||||
/* Enhanced Email Content Styling */
|
||||
|
||||
/* Wrapper uses inline-block to size to content, enabling horizontal scroll */
|
||||
.email-content-wrapper {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
@@ -100,16 +98,8 @@ body {
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
max-width: none;
|
||||
/* Size to content's natural width, don't shrink below it */
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* Prevent tables with width="100%" from shrinking below their content */
|
||||
.email-content table[width="100%"],
|
||||
.email-content table[style*="width:100%"],
|
||||
.email-content table[style*="width: 100%"] {
|
||||
min-width: max-content;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.email-content p {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactDetailProps {
|
||||
contact: ContactCard | null;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
<BookUser className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="text-sm">{t("detail.no_contact_selected")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
|
||||
const notes = contact.notes ? Object.values(contact.notes) : [];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className="px-6 py-6 border-b border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size="lg" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{name || "—"}</h2>
|
||||
{orgs.length > 0 && orgs[0].name && (
|
||||
<p className="text-sm text-muted-foreground">{orgs[0].name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{emails.length > 0 && (
|
||||
<Section icon={Mail} title={t("detail.emails")}>
|
||||
{emails.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
||||
{e.address}
|
||||
</a>
|
||||
{e.contexts && (
|
||||
<ContextBadge contexts={e.contexts} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{phones.length > 0 && (
|
||||
<Section icon={Phone} title={t("detail.phones")}>
|
||||
{phones.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
||||
{p.number}
|
||||
</a>
|
||||
{p.contexts && (
|
||||
<ContextBadge contexts={p.contexts} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{orgs.length > 0 && (
|
||||
<Section icon={Building} title={t("detail.organizations")}>
|
||||
{orgs.map((o, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{addresses.length > 0 && (
|
||||
<Section icon={MapPin} title={t("detail.addresses")}>
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.contexts && (
|
||||
<ContextBadge contexts={a.contexts} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<Section icon={StickyNote} title={t("detail.notes")}>
|
||||
{notes.map((n, i) => (
|
||||
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
||||
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{labels.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
address: string;
|
||||
context: "work" | "private" | "";
|
||||
}
|
||||
|
||||
interface PhoneEntry {
|
||||
number: string;
|
||||
context: "work" | "private" | "";
|
||||
}
|
||||
|
||||
interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const isEditing = !!contact;
|
||||
|
||||
const givenInit = contact?.name?.components?.find(c => c.kind === "given")?.value || "";
|
||||
const surnameInit = contact?.name?.components?.find(c => c.kind === "surname")?.value || "";
|
||||
|
||||
const [givenName, setGivenName] = useState(givenInit);
|
||||
const [surname, setSurname] = useState(surnameInit);
|
||||
|
||||
const [emails, setEmails] = useState<EmailEntry[]>(() => {
|
||||
if (contact?.emails) {
|
||||
return Object.values(contact.emails).map(e => ({
|
||||
address: e.address,
|
||||
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
|
||||
}));
|
||||
}
|
||||
return [{ address: "", context: "" }];
|
||||
});
|
||||
|
||||
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
|
||||
if (contact?.phones) {
|
||||
return Object.values(contact.phones).map(p => ({
|
||||
number: p.number,
|
||||
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [organization, setOrganization] = useState(
|
||||
contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : ""
|
||||
);
|
||||
|
||||
const [note, setNote] = useState(
|
||||
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!givenName.trim() && !surname.trim()) {
|
||||
setError(t("name_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
const validEmails = emails.filter(e => e.address.trim());
|
||||
for (const entry of validEmails) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry.address.trim())) {
|
||||
setError(t("email_invalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const emailsMap: Record<string, { address: string; contexts?: Record<string, boolean> }> = {};
|
||||
validEmails.forEach((entry, i) => {
|
||||
const obj: { address: string; contexts?: Record<string, boolean> } = { address: entry.address.trim() };
|
||||
if (entry.context) {
|
||||
obj.contexts = { [entry.context]: true };
|
||||
}
|
||||
emailsMap[`e${i}`] = obj;
|
||||
});
|
||||
|
||||
const validPhones = phones.filter(p => p.number.trim());
|
||||
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean> }> = {};
|
||||
validPhones.forEach((entry, i) => {
|
||||
const obj: { number: string; contexts?: Record<string, boolean> } = { number: entry.number.trim() };
|
||||
if (entry.context) {
|
||||
obj.contexts = { [entry.context]: true };
|
||||
}
|
||||
phonesMap[`p${i}`] = obj;
|
||||
});
|
||||
|
||||
const nameComponents = [];
|
||||
if (givenName.trim()) {
|
||||
nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
}
|
||||
if (surname.trim()) {
|
||||
nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
}
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||
organizations: organization.trim()
|
||||
? { o0: { name: organization.trim() } }
|
||||
: undefined,
|
||||
notes: note.trim()
|
||||
? { n0: { note: note.trim() } }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("save_failed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("edit_title") : t("create_title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("given_name")}</label>
|
||||
<Input
|
||||
value={givenName}
|
||||
onChange={(e) => setGivenName(e.target.value)}
|
||||
placeholder={t("given_name")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("surname")}</label>
|
||||
<Input
|
||||
value={surname}
|
||||
onChange={(e) => setSurname(e.target.value)}
|
||||
placeholder={t("surname")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("email")}</label>
|
||||
<div className="space-y-2">
|
||||
{emails.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={entry.address}
|
||||
onChange={(e) => {
|
||||
const next = [...emails];
|
||||
next[i] = { ...next[i], address: e.target.value };
|
||||
setEmails(next);
|
||||
}}
|
||||
placeholder={t("email_placeholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={entry.context}
|
||||
onChange={(e) => {
|
||||
const next = [...emails];
|
||||
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
|
||||
setEmails(next);
|
||||
}}
|
||||
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</select>
|
||||
{emails.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEmails(emails.filter((_, j) => j !== i))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEmails([...emails, { address: "", context: "" }])}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t("add_email")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("phone")}</label>
|
||||
<div className="space-y-2">
|
||||
{phones.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="tel"
|
||||
value={entry.number}
|
||||
onChange={(e) => {
|
||||
const next = [...phones];
|
||||
next[i] = { ...next[i], number: e.target.value };
|
||||
setPhones(next);
|
||||
}}
|
||||
placeholder={t("phone_placeholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={entry.context}
|
||||
onChange={(e) => {
|
||||
const next = [...phones];
|
||||
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
|
||||
setPhones(next);
|
||||
}}
|
||||
className="text-sm bg-transparent border rounded px-2 py-2 text-foreground"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setPhones(phones.filter((_, j) => j !== i))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhones([...phones, { number: "", context: "" }])}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t("add_phone")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
<Input
|
||||
value={organization}
|
||||
onChange={(e) => setOrganization(e.target.value)}
|
||||
placeholder={t("organization_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">{t("note")}</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("note_placeholder")}
|
||||
className="w-full min-h-[80px] rounded border bg-transparent px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (isEditing ? t("updating") : t("creating")) : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactListItemProps {
|
||||
contact: ContactCard;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, onClick }: ContactListItemProps) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = contact.organizations
|
||||
? Object.values(contact.organizations)[0]?.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
"hover:bg-muted",
|
||||
isSelected && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Avatar name={name} email={email} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{name || email || "—"}
|
||||
</div>
|
||||
{email && name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||
)}
|
||||
{org && (
|
||||
<div className="text-xs text-muted-foreground truncate">{org}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, Plus, BookUser, Info } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
|
||||
interface ContactListProps {
|
||||
contacts: ContactCard[];
|
||||
selectedContactId: string | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelectContact: (id: string) => void;
|
||||
onCreateNew: () => void;
|
||||
supportsSync: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
contacts,
|
||||
selectedContactId,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelectContact,
|
||||
onCreateNew,
|
||||
supportsSync,
|
||||
className,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
const lower = searchQuery.toLowerCase();
|
||||
return contacts.filter((c) => {
|
||||
const name = getContactDisplayName(c).toLowerCase();
|
||||
const emails = c.emails
|
||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
||||
: [];
|
||||
return (
|
||||
name.includes(lower) || emails.some((e) => e.includes(lower))
|
||||
);
|
||||
});
|
||||
}, [contacts, searchQuery]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
const nameA = getContactDisplayName(a).toLowerCase();
|
||||
const nameB = getContactDisplayName(b).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
<div className="px-4 py-3 border-b border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<Button size="sm" onClick={onCreateNew}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("create_new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!supportsSync && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted rounded px-3 py-2">
|
||||
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>{t("local_mode")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
|
||||
<BookUser className="w-12 h-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{searchQuery ? t("empty_search") : t("empty_state")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sorted.map((contact) => (
|
||||
<ContactListItem
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
isSelected={contact.id === selectedContactId}
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
import { generateSubAddress } from "@/lib/sub-addressing";
|
||||
|
||||
@@ -108,6 +109,70 @@ export function EmailComposer({
|
||||
const [subAddressTag, setSubAddressTag] = useState<string>('');
|
||||
|
||||
const { client, identities, primaryIdentity } = useAuthStore();
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
|
||||
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
|
||||
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
|
||||
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const toInputRef = useRef<HTMLInputElement>(null);
|
||||
const ccInputRef = useRef<HTMLInputElement>(null);
|
||||
const bccInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
}
|
||||
|
||||
const lastPart = value.split(',').pop()?.trim() || '';
|
||||
if (lastPart.length < 1) {
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
autocompleteTimeoutRef.current = setTimeout(() => {
|
||||
const results = getAutocomplete(lastPart);
|
||||
setAutocompleteResults(results);
|
||||
setActiveAutoField(results.length > 0 ? field : null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}, 200);
|
||||
}, [getAutocomplete]);
|
||||
|
||||
const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => {
|
||||
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
|
||||
|
||||
const parts = getter.split(',');
|
||||
parts.pop();
|
||||
parts.push(` ${email}`);
|
||||
setter(parts.join(',').replace(/^,\s*/, ''));
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
|
||||
const ref = field === 'to' ? toInputRef : field === 'cc' ? ccInputRef : bccInputRef;
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const handleAutoKeyDown = (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => {
|
||||
if (!activeAutoField || autocompleteResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setAutoSelectedIndex((prev) => Math.min(prev + 1, autocompleteResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
|
||||
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field);
|
||||
} else if (e.key === 'Escape') {
|
||||
setAutocompleteResults([]);
|
||||
setActiveAutoField(null);
|
||||
setAutoSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -258,6 +323,14 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
|
||||
}, [to, cc, bcc, subject, body, attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autocompleteTimeoutRef.current) {
|
||||
clearTimeout(autocompleteTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
@@ -421,15 +494,31 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={toInputRef}
|
||||
type="email"
|
||||
placeholder={t('to_placeholder')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
onChange={(e) => {
|
||||
setTo(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'to');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'to') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'to' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -451,28 +540,60 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{showCc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={ccInputRef}
|
||||
type="email"
|
||||
placeholder={t('cc_placeholder')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
onChange={(e) => {
|
||||
setCc(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'cc');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'cc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'cc' ? 'autocomplete-cc' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'cc' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={bccInputRef}
|
||||
type="email"
|
||||
placeholder={t('bcc_placeholder')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
className="flex-1 border-0 focus-visible:ring-0"
|
||||
onChange={(e) => {
|
||||
setBcc(e.target.value);
|
||||
handleAutocomplete(e.target.value, 'bcc');
|
||||
}}
|
||||
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
|
||||
onBlur={() => setTimeout(() => { if (activeAutoField === 'bcc') { setActiveAutoField(null); setAutoSelectedIndex(-1); } }, 200)}
|
||||
className="border-0 focus-visible:ring-0"
|
||||
role="combobox"
|
||||
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={activeAutoField === 'bcc' ? 'autocomplete-bcc' : undefined}
|
||||
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
|
||||
/>
|
||||
{activeAutoField === 'bcc' && autocompleteResults.length > 0 && (
|
||||
<AutocompleteDropdown id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -570,3 +691,42 @@ export function EmailComposer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutocompleteDropdown({
|
||||
id,
|
||||
results,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: {
|
||||
id: string;
|
||||
results: Array<{ name: string; email: string }>;
|
||||
selectedIndex: number;
|
||||
onSelect: (email: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={i}
|
||||
id={`autocomplete-option-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
|
||||
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(r.email);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium truncate">{r.name || r.email}</span>
|
||||
{r.name && (
|
||||
<span className="text-muted-foreground truncate"><{r.email}></span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -466,11 +466,16 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
// Sanitize HTML to prevent XSS
|
||||
const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
|
||||
// Remove the hook after sanitization
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
// Collapse empty containers left behind by blocked images
|
||||
if (shouldBlockExternal && blockedExternalContent) {
|
||||
cleanHtml = collapseBlockedImageContainers(cleanHtml);
|
||||
}
|
||||
|
||||
// Update blocked content state
|
||||
if (blockedExternalContent && !hasBlockedContent) {
|
||||
setHasBlockedContent(true);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -296,11 +296,13 @@ function EmailCard({
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
DOMPurify.removeHook('afterSanitizeAttributes');
|
||||
|
||||
let finalHtml = sanitized;
|
||||
if (blockedExternalContent) {
|
||||
setHasBlockedContent(true);
|
||||
finalHtml = collapseBlockedImageContainers(sanitized);
|
||||
}
|
||||
|
||||
return { html: sanitized, isHtml: true };
|
||||
return { html: finalHtml, isHtml: true };
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ChevronUp,
|
||||
Users,
|
||||
User,
|
||||
BookUser,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
@@ -453,6 +454,18 @@ export function Sidebar({
|
||||
)}
|
||||
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
{/* Contacts */}
|
||||
<button
|
||||
onClick={() => router.push('/contacts')}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<BookUser className="w-4 h-4" />
|
||||
{t("contacts")}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => router.push('/settings')}
|
||||
|
||||
@@ -75,3 +75,35 @@ export function hasRichFormatting(html: string): boolean {
|
||||
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse empty containers left behind when external images are blocked.
|
||||
* Walks up from each blocked img to find the nearest table cell or wrapper div
|
||||
* and hides it if it contains no meaningful visible content.
|
||||
*/
|
||||
export function collapseBlockedImageContainers(html: string): string {
|
||||
const doc = parseHtmlSafely(html);
|
||||
const blockedImages = doc.querySelectorAll('img[data-blocked-src]');
|
||||
|
||||
blockedImages.forEach((img) => {
|
||||
let el: HTMLElement | null = img.parentElement;
|
||||
while (el && el !== doc.body) {
|
||||
if (el.tagName === 'TD' || el.tagName === 'TH' || (el.tagName === 'DIV' && el.parentElement?.tagName === 'TD')) {
|
||||
const hasVisibleText = el.textContent?.replace(/[\s\u00A0]+/g, '').trim();
|
||||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||
const hasLinks = el.querySelector('a[href]');
|
||||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||||
el.style.display = 'none';
|
||||
el.style.height = '0';
|
||||
el.style.padding = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
|
||||
el = el.parentElement;
|
||||
}
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
+211
-3
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -198,13 +198,13 @@ export class JMAPClient {
|
||||
this.capabilities = {};
|
||||
}
|
||||
|
||||
private async request(methodCalls: JMAPMethodCall[]): Promise<JMAPResponse> {
|
||||
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: methodCalls,
|
||||
};
|
||||
|
||||
@@ -1481,6 +1481,214 @@ export class JMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
|
||||
}
|
||||
|
||||
supportsContacts(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:contacts");
|
||||
}
|
||||
|
||||
getContactsAccountId(): string {
|
||||
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
|
||||
return contactsAccount || this.accountId;
|
||||
}
|
||||
|
||||
private contactUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||
}
|
||||
|
||||
async getAddressBooks(): Promise<AddressBook[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/get", { accountId }, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||||
return (response.methodResponses[0][1].list || []) as AddressBook[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get address books:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
|
||||
if (addressBookId) {
|
||||
methodCalls.push(
|
||||
["ContactCard/query", {
|
||||
accountId,
|
||||
filter: { inAddressBook: addressBookId },
|
||||
limit: 1000,
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
);
|
||||
} else {
|
||||
methodCalls.push(
|
||||
["ContactCard/query", { accountId, limit: 1000 }, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls, this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get contacts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContact(contactId: string): Promise<ContactCard | null> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
ids: [contactId],
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
return list[0] || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get contact:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
// If no addressBookIds provided, get default address book
|
||||
let addressBookIds = contact.addressBookIds;
|
||||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||||
const books = await this.getAddressBooks();
|
||||
const defaultBook = books.find(b => b.isDefault) || books[0];
|
||||
if (defaultBook) {
|
||||
addressBookIds = { [defaultBook.id]: true };
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
create: {
|
||||
"new-contact": {
|
||||
...contact,
|
||||
addressBookIds,
|
||||
}
|
||||
}
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notCreated?.["new-contact"]) {
|
||||
const error = result.notCreated["new-contact"];
|
||||
throw new Error(error.description || "Failed to create contact");
|
||||
}
|
||||
|
||||
const createdId = result.created?.["new-contact"]?.id;
|
||||
if (createdId) {
|
||||
const created = await this.getContact(createdId);
|
||||
if (created) return created;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create contact");
|
||||
}
|
||||
|
||||
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[contactId]: updates
|
||||
}
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notUpdated?.[contactId]) {
|
||||
const error = result.notUpdated[contactId];
|
||||
throw new Error(error.description || "Failed to update contact");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update contact");
|
||||
}
|
||||
|
||||
async deleteContact(contactId: string): Promise<void> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
destroy: [contactId]
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notDestroyed?.[contactId]) {
|
||||
const error = result.notDestroyed[contactId];
|
||||
throw new Error(error.description || "Failed to delete contact");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to delete contact");
|
||||
}
|
||||
|
||||
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/query", {
|
||||
accountId,
|
||||
filter: { text: query },
|
||||
limit: 50,
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to search contacts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
|
||||
|
||||
@@ -153,6 +153,86 @@ export interface Identity {
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
// RFC 9553 JSContact / RFC 9610 JMAP for Contacts
|
||||
|
||||
export interface ContactCard {
|
||||
id: string;
|
||||
uid?: string;
|
||||
addressBookIds: Record<string, boolean>;
|
||||
kind?: 'individual' | 'group' | 'org';
|
||||
name?: ContactName;
|
||||
emails?: Record<string, ContactEmail>;
|
||||
phones?: Record<string, ContactPhone>;
|
||||
organizations?: Record<string, ContactOrganization>;
|
||||
addresses?: Record<string, ContactAddress>;
|
||||
nicknames?: Record<string, ContactNickname>;
|
||||
notes?: Record<string, ContactNote>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface ContactName {
|
||||
components: NameComponent[];
|
||||
isOrdered?: boolean;
|
||||
}
|
||||
|
||||
export interface NameComponent {
|
||||
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ContactEmail {
|
||||
address: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactPhone {
|
||||
number: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactOrganization {
|
||||
name?: string;
|
||||
units?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
street?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactNickname {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ContactNote {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface AddressBook {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
}
|
||||
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
@@ -187,6 +267,8 @@ export interface StateChange {
|
||||
EmailDelivery?: string;
|
||||
EmailSubmission?: string;
|
||||
Identity?: string;
|
||||
ContactCard?: string;
|
||||
AddressBook?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "E-Mails durchsuchen...",
|
||||
"storage": "Speicher",
|
||||
"sign_out": "Abmelden",
|
||||
"contacts": "Kontakte",
|
||||
"settings": "Einstellungen",
|
||||
"loading_mailboxes": "Postfächer werden geladen...",
|
||||
"push_connected": "Echtzeit-Updates aktiv",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "über {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Kontakte",
|
||||
"search_placeholder": "Kontakte suchen...",
|
||||
"create_new": "Neuer Kontakt",
|
||||
"empty_state": "Keine Kontakte",
|
||||
"empty_search": "Keine Kontakte gefunden",
|
||||
"delete_confirm": "Möchten Sie diesen Kontakt wirklich löschen?",
|
||||
"local_mode": "Kontakte werden lokal gespeichert (Server unterstützt kein JMAP Contacts)",
|
||||
"back_to_mail": "Zurück zur E-Mail",
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
"phones": "Telefonnummern",
|
||||
"organizations": "Organisationen",
|
||||
"addresses": "Adressen",
|
||||
"notes": "Notizen",
|
||||
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
|
||||
"created": "Erstellt",
|
||||
"updated": "Zuletzt aktualisiert"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
"edit_title": "Kontakt bearbeiten",
|
||||
"given_name": "Vorname",
|
||||
"surname": "Nachname",
|
||||
"email": "E-Mail",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Telefon",
|
||||
"phone_placeholder": "+49 30 1234 5678",
|
||||
"organization": "Organisation",
|
||||
"organization_placeholder": "Firmenname",
|
||||
"note": "Notizen",
|
||||
"note_placeholder": "Notiz hinzufügen...",
|
||||
"context_work": "Arbeit",
|
||||
"context_private": "Privat",
|
||||
"add_email": "E-Mail hinzufügen",
|
||||
"add_phone": "Telefon hinzufügen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"creating": "Wird erstellt...",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
|
||||
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"save_failed": "Kontakt konnte nicht gespeichert werden"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Kontakt erstellt",
|
||||
"updated": "Kontakt aktualisiert",
|
||||
"deleted": "Kontakt gelöscht",
|
||||
"error_create": "Kontakt konnte nicht erstellt werden",
|
||||
"error_update": "Kontakt konnte nicht aktualisiert werden",
|
||||
"error_delete": "Kontakt konnte nicht gelöscht werden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Search mail...",
|
||||
"storage": "Storage",
|
||||
"sign_out": "Sign out",
|
||||
"contacts": "Contacts",
|
||||
"settings": "Settings",
|
||||
"loading_mailboxes": "Loading mailboxes...",
|
||||
"push_connected": "Real-time updates active",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "via {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"search_placeholder": "Search contacts...",
|
||||
"create_new": "New Contact",
|
||||
"empty_state": "No contacts yet",
|
||||
"empty_search": "No contacts match your search",
|
||||
"delete_confirm": "Are you sure you want to delete this contact?",
|
||||
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
|
||||
"back_to_mail": "Back to mail",
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
"phones": "Phone Numbers",
|
||||
"organizations": "Organizations",
|
||||
"addresses": "Addresses",
|
||||
"notes": "Notes",
|
||||
"no_contact_selected": "Select a contact to view details",
|
||||
"created": "Created",
|
||||
"updated": "Last updated"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "New Contact",
|
||||
"edit_title": "Edit Contact",
|
||||
"given_name": "First name",
|
||||
"surname": "Last name",
|
||||
"email": "Email",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Phone",
|
||||
"phone_placeholder": "+1 234 567 890",
|
||||
"organization": "Organization",
|
||||
"organization_placeholder": "Company name",
|
||||
"note": "Notes",
|
||||
"note_placeholder": "Add a note...",
|
||||
"context_work": "Work",
|
||||
"context_private": "Private",
|
||||
"add_email": "Add email",
|
||||
"add_phone": "Add phone",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"creating": "Creating...",
|
||||
"updating": "Updating...",
|
||||
"name_required": "At least a first name or last name is required",
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
"save_failed": "Failed to save contact"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contact created",
|
||||
"updated": "Contact updated",
|
||||
"deleted": "Contact deleted",
|
||||
"error_create": "Failed to create contact",
|
||||
"error_update": "Failed to update contact",
|
||||
"error_delete": "Failed to delete contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Buscar correo...",
|
||||
"storage": "Almacenamiento",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"contacts": "Contactos",
|
||||
"settings": "Configuración",
|
||||
"loading_mailboxes": "Cargando buzones...",
|
||||
"push_connected": "Actualizaciones en tiempo real activas",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "vía {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contactos",
|
||||
"search_placeholder": "Buscar contactos...",
|
||||
"create_new": "Nuevo contacto",
|
||||
"empty_state": "No hay contactos",
|
||||
"empty_search": "Ningún contacto coincide con tu búsqueda",
|
||||
"delete_confirm": "¿Estás seguro de que quieres eliminar este contacto?",
|
||||
"local_mode": "Los contactos se almacenan localmente (el servidor no soporta JMAP Contacts)",
|
||||
"back_to_mail": "Volver al correo",
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
"phones": "Números de teléfono",
|
||||
"organizations": "Organizaciones",
|
||||
"addresses": "Direcciones",
|
||||
"notes": "Notas",
|
||||
"no_contact_selected": "Selecciona un contacto para ver los detalles",
|
||||
"created": "Creado",
|
||||
"updated": "Última actualización"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
"edit_title": "Editar contacto",
|
||||
"given_name": "Nombre",
|
||||
"surname": "Apellido",
|
||||
"email": "Correo",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Teléfono",
|
||||
"phone_placeholder": "+34 612 345 678",
|
||||
"organization": "Organización",
|
||||
"organization_placeholder": "Nombre de la empresa",
|
||||
"note": "Notas",
|
||||
"note_placeholder": "Agregar una nota...",
|
||||
"context_work": "Trabajo",
|
||||
"context_private": "Personal",
|
||||
"add_email": "Agregar correo",
|
||||
"add_phone": "Agregar teléfono",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"creating": "Creando...",
|
||||
"updating": "Actualizando...",
|
||||
"name_required": "Se requiere al menos un nombre o apellido",
|
||||
"email_invalid": "Introduce una dirección de correo válida",
|
||||
"save_failed": "Error al guardar el contacto"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contacto creado",
|
||||
"updated": "Contacto actualizado",
|
||||
"deleted": "Contacto eliminado",
|
||||
"error_create": "Error al crear el contacto",
|
||||
"error_update": "Error al actualizar el contacto",
|
||||
"error_delete": "Error al eliminar el contacto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Rechercher un email...",
|
||||
"storage": "Stockage",
|
||||
"sign_out": "Se déconnecter",
|
||||
"contacts": "Contacts",
|
||||
"settings": "Paramètres",
|
||||
"loading_mailboxes": "Chargement des boîtes mail...",
|
||||
"push_connected": "Mises à jour en temps réel actives",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "via {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"search_placeholder": "Rechercher des contacts...",
|
||||
"create_new": "Nouveau contact",
|
||||
"empty_state": "Aucun contact",
|
||||
"empty_search": "Aucun contact ne correspond à votre recherche",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ce contact ?",
|
||||
"local_mode": "Les contacts sont stockés localement (le serveur ne prend pas en charge JMAP Contacts)",
|
||||
"back_to_mail": "Retour aux e-mails",
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
"phones": "Numéros de téléphone",
|
||||
"organizations": "Organisations",
|
||||
"addresses": "Adresses",
|
||||
"notes": "Notes",
|
||||
"no_contact_selected": "Sélectionnez un contact pour voir les détails",
|
||||
"created": "Créé",
|
||||
"updated": "Dernière mise à jour"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
"edit_title": "Modifier le contact",
|
||||
"given_name": "Prénom",
|
||||
"surname": "Nom",
|
||||
"email": "E-mail",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Téléphone",
|
||||
"phone_placeholder": "+33 1 23 45 67 89",
|
||||
"organization": "Organisation",
|
||||
"organization_placeholder": "Nom de l'entreprise",
|
||||
"note": "Notes",
|
||||
"note_placeholder": "Ajouter une note...",
|
||||
"context_work": "Professionnel",
|
||||
"context_private": "Personnel",
|
||||
"add_email": "Ajouter un e-mail",
|
||||
"add_phone": "Ajouter un téléphone",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"creating": "Création...",
|
||||
"updating": "Mise à jour...",
|
||||
"name_required": "Un prénom ou un nom est requis",
|
||||
"email_invalid": "Veuillez saisir une adresse e-mail valide",
|
||||
"save_failed": "Échec de l'enregistrement du contact"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contact créé",
|
||||
"updated": "Contact mis à jour",
|
||||
"deleted": "Contact supprimé",
|
||||
"error_create": "Échec de la création du contact",
|
||||
"error_update": "Échec de la mise à jour du contact",
|
||||
"error_delete": "Échec de la suppression du contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Cerca nella posta...",
|
||||
"storage": "Spazio di archiviazione",
|
||||
"sign_out": "Esci",
|
||||
"contacts": "Contatti",
|
||||
"settings": "Impostazioni",
|
||||
"loading_mailboxes": "Caricamento caselle di posta...",
|
||||
"push_connected": "Aggiornamenti in tempo reale attivi",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "tramite {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contatti",
|
||||
"search_placeholder": "Cerca contatti...",
|
||||
"create_new": "Nuovo contatto",
|
||||
"empty_state": "Nessun contatto",
|
||||
"empty_search": "Nessun contatto corrisponde alla ricerca",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare questo contatto?",
|
||||
"local_mode": "I contatti sono salvati localmente (il server non supporta JMAP Contacts)",
|
||||
"back_to_mail": "Torna alla posta",
|
||||
"detail": {
|
||||
"emails": "Indirizzi email",
|
||||
"phones": "Numeri di telefono",
|
||||
"organizations": "Organizzazioni",
|
||||
"addresses": "Indirizzi",
|
||||
"notes": "Note",
|
||||
"no_contact_selected": "Seleziona un contatto per vedere i dettagli",
|
||||
"created": "Creato",
|
||||
"updated": "Ultimo aggiornamento"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nuovo contatto",
|
||||
"edit_title": "Modifica contatto",
|
||||
"given_name": "Nome",
|
||||
"surname": "Cognome",
|
||||
"email": "Email",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Telefono",
|
||||
"phone_placeholder": "+39 02 1234 5678",
|
||||
"organization": "Organizzazione",
|
||||
"organization_placeholder": "Nome azienda",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Aggiungi una nota...",
|
||||
"context_work": "Lavoro",
|
||||
"context_private": "Personale",
|
||||
"add_email": "Aggiungi email",
|
||||
"add_phone": "Aggiungi telefono",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"creating": "Creazione...",
|
||||
"updating": "Aggiornamento...",
|
||||
"name_required": "È richiesto almeno un nome o cognome",
|
||||
"email_invalid": "Inserisci un indirizzo email valido",
|
||||
"save_failed": "Impossibile salvare il contatto"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contatto creato",
|
||||
"updated": "Contatto aggiornato",
|
||||
"deleted": "Contatto eliminato",
|
||||
"error_create": "Impossibile creare il contatto",
|
||||
"error_update": "Impossibile aggiornare il contatto",
|
||||
"error_delete": "Impossibile eliminare il contatto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "メールを検索...",
|
||||
"storage": "ストレージ",
|
||||
"sign_out": "サインアウト",
|
||||
"contacts": "連絡先",
|
||||
"settings": "設定",
|
||||
"loading_mailboxes": "メールボックスを読み込み中...",
|
||||
"push_connected": "リアルタイム更新が有効",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "{name}経由",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "連絡先",
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"empty_state": "連絡先がありません",
|
||||
"empty_search": "検索に一致する連絡先がありません",
|
||||
"delete_confirm": "この連絡先を削除してもよろしいですか?",
|
||||
"local_mode": "連絡先はローカルに保存されています(サーバーがJMAPコンタクトをサポートしていません)",
|
||||
"back_to_mail": "メールに戻る",
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
"phones": "電話番号",
|
||||
"organizations": "組織",
|
||||
"addresses": "住所",
|
||||
"notes": "メモ",
|
||||
"no_contact_selected": "連絡先を選択して詳細を表示",
|
||||
"created": "作成日",
|
||||
"updated": "最終更新"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
"edit_title": "連絡先を編集",
|
||||
"given_name": "名",
|
||||
"surname": "姓",
|
||||
"email": "メール",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "電話",
|
||||
"phone_placeholder": "+81 3 1234 5678",
|
||||
"organization": "組織",
|
||||
"organization_placeholder": "会社名",
|
||||
"note": "メモ",
|
||||
"note_placeholder": "メモを追加...",
|
||||
"context_work": "仕事",
|
||||
"context_private": "プライベート",
|
||||
"add_email": "メール追加",
|
||||
"add_phone": "電話追加",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"creating": "作成中...",
|
||||
"updating": "更新中...",
|
||||
"name_required": "名前は必須です",
|
||||
"email_invalid": "有効なメールアドレスを入力してください",
|
||||
"save_failed": "連絡先の保存に失敗しました"
|
||||
},
|
||||
"toast": {
|
||||
"created": "連絡先を作成しました",
|
||||
"updated": "連絡先を更新しました",
|
||||
"deleted": "連絡先を削除しました",
|
||||
"error_create": "連絡先の作成に失敗しました",
|
||||
"error_update": "連絡先の更新に失敗しました",
|
||||
"error_delete": "連絡先の削除に失敗しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Zoeken in e-mail...",
|
||||
"storage": "Opslag",
|
||||
"sign_out": "Afmelden",
|
||||
"contacts": "Contacten",
|
||||
"settings": "Instellingen",
|
||||
"loading_mailboxes": "Mappen laden...",
|
||||
"push_connected": "Real-time updates actief",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "via {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacten",
|
||||
"search_placeholder": "Contacten zoeken...",
|
||||
"create_new": "Nieuw contact",
|
||||
"empty_state": "Geen contacten",
|
||||
"empty_search": "Geen contacten gevonden",
|
||||
"delete_confirm": "Weet u zeker dat u dit contact wilt verwijderen?",
|
||||
"local_mode": "Contacten worden lokaal opgeslagen (server ondersteunt geen JMAP Contacts)",
|
||||
"back_to_mail": "Terug naar e-mail",
|
||||
"detail": {
|
||||
"emails": "E-mailadressen",
|
||||
"phones": "Telefoonnummers",
|
||||
"organizations": "Organisaties",
|
||||
"addresses": "Adressen",
|
||||
"notes": "Notities",
|
||||
"no_contact_selected": "Selecteer een contact om details te bekijken",
|
||||
"created": "Aangemaakt",
|
||||
"updated": "Laatst bijgewerkt"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Nieuw contact",
|
||||
"edit_title": "Contact bewerken",
|
||||
"given_name": "Voornaam",
|
||||
"surname": "Achternaam",
|
||||
"email": "E-mail",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Telefoon",
|
||||
"phone_placeholder": "+31 20 123 4567",
|
||||
"organization": "Organisatie",
|
||||
"organization_placeholder": "Bedrijfsnaam",
|
||||
"note": "Notities",
|
||||
"note_placeholder": "Notitie toevoegen...",
|
||||
"context_work": "Werk",
|
||||
"context_private": "Privé",
|
||||
"add_email": "E-mail toevoegen",
|
||||
"add_phone": "Telefoon toevoegen",
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"creating": "Aanmaken...",
|
||||
"updating": "Bijwerken...",
|
||||
"name_required": "Ten minste een voor- of achternaam is vereist",
|
||||
"email_invalid": "Voer een geldig e-mailadres in",
|
||||
"save_failed": "Kon contact niet opslaan"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contact aangemaakt",
|
||||
"updated": "Contact bijgewerkt",
|
||||
"deleted": "Contact verwijderd",
|
||||
"error_create": "Kon contact niet aanmaken",
|
||||
"error_update": "Kon contact niet bijwerken",
|
||||
"error_delete": "Kon contact niet verwijderen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"search_placeholder": "Buscar e-mails...",
|
||||
"storage": "Armazenamento",
|
||||
"sign_out": "Sair",
|
||||
"contacts": "Contatos",
|
||||
"settings": "Configurações",
|
||||
"loading_mailboxes": "Carregando caixas de entrada...",
|
||||
"push_connected": "Atualizações em tempo real ativas",
|
||||
@@ -742,5 +743,58 @@
|
||||
"identity_short": "via {name}",
|
||||
"subaddress_tag": "+{tag}"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contatos",
|
||||
"search_placeholder": "Pesquisar contatos...",
|
||||
"create_new": "Novo contato",
|
||||
"empty_state": "Nenhum contato",
|
||||
"empty_search": "Nenhum contato encontrado",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir este contato?",
|
||||
"local_mode": "Os contatos são armazenados localmente (o servidor não suporta JMAP Contacts)",
|
||||
"back_to_mail": "Voltar ao e-mail",
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
"phones": "Números de telefone",
|
||||
"organizations": "Organizações",
|
||||
"addresses": "Endereços",
|
||||
"notes": "Notas",
|
||||
"no_contact_selected": "Selecione um contato para ver os detalhes",
|
||||
"created": "Criado",
|
||||
"updated": "Última atualização"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
"edit_title": "Editar contato",
|
||||
"given_name": "Nome",
|
||||
"surname": "Sobrenome",
|
||||
"email": "E-mail",
|
||||
"email_placeholder": "email@example.com",
|
||||
"phone": "Telefone",
|
||||
"phone_placeholder": "+55 11 1234 5678",
|
||||
"organization": "Organização",
|
||||
"organization_placeholder": "Nome da empresa",
|
||||
"note": "Notas",
|
||||
"note_placeholder": "Adicionar uma nota...",
|
||||
"context_work": "Trabalho",
|
||||
"context_private": "Pessoal",
|
||||
"add_email": "Adicionar e-mail",
|
||||
"add_phone": "Adicionar telefone",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"creating": "Criando...",
|
||||
"updating": "Atualizando...",
|
||||
"name_required": "É necessário pelo menos um nome ou sobrenome",
|
||||
"email_invalid": "Por favor, insira um endereço de e-mail válido",
|
||||
"save_failed": "Falha ao salvar contato"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Contato criado",
|
||||
"updated": "Contato atualizado",
|
||||
"deleted": "Contato excluído",
|
||||
"error_create": "Falha ao criar contato",
|
||||
"error_update": "Falha ao atualizar contato",
|
||||
"error_delete": "Falha ao excluir contato"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+932
-910
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -36,7 +36,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"jmap-jam": "^0.13.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "^16.1.5",
|
||||
"next-intl": "^4.5.8",
|
||||
"react": "^19.2.1",
|
||||
@@ -50,19 +50,19 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^22",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -50,6 +51,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Sync identities to identity store
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
|
||||
// Fetch contacts if server supports JMAP Contacts
|
||||
if (client.supportsContacts()) {
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.setSupportsSync(true);
|
||||
contactStore.fetchAddressBooks(client).catch(() => {});
|
||||
contactStore.fetchContacts(client).catch(() => {});
|
||||
} else {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
@@ -124,6 +135,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Clear identity store state
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
|
||||
// Clear contact store state
|
||||
useContactStore.getState().clearContacts();
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name?.components) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||
const full = [given, surname].filter(Boolean).join(' ');
|
||||
if (full) return full;
|
||||
}
|
||||
if (contact.nicknames) {
|
||||
const nick = Object.values(contact.nicknames)[0];
|
||||
if (nick?.name) return nick.name;
|
||||
}
|
||||
if (contact.emails) {
|
||||
const email = Object.values(contact.emails)[0];
|
||||
if (email?.address) return email.address;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getContactPrimaryEmail(contact: ContactCard): string {
|
||||
if (!contact.emails) return '';
|
||||
return Object.values(contact.emails)[0]?.address || '';
|
||||
}
|
||||
|
||||
interface ContactStore {
|
||||
contacts: ContactCard[];
|
||||
addressBooks: AddressBook[];
|
||||
selectedContactId: string | null;
|
||||
searchQuery: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
supportsSync: boolean;
|
||||
|
||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
||||
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
||||
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
||||
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
|
||||
|
||||
addLocalContact: (contact: ContactCard) => void;
|
||||
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
|
||||
deleteLocalContact: (id: string) => void;
|
||||
|
||||
setSelectedContact: (id: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSupportsSync: (supports: boolean) => void;
|
||||
clearContacts: () => void;
|
||||
|
||||
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsSync: false,
|
||||
|
||||
fetchContacts: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const contacts = await client.getContacts();
|
||||
set({ contacts, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error);
|
||||
set({ error: 'Failed to fetch contacts', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchAddressBooks: async (client) => {
|
||||
try {
|
||||
const addressBooks = await client.getAddressBooks();
|
||||
set({ addressBooks });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch address books:', error);
|
||||
}
|
||||
},
|
||||
|
||||
createContact: async (client, contact) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const created = await client.createContact(contact);
|
||||
set((state) => ({
|
||||
contacts: [...state.contacts, created],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create contact';
|
||||
set({ error: msg, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateContact: async (client, id, updates) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.updateContact(id, updates);
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to update contact';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteContact: async (client, id) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.deleteContact(id);
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addLocalContact: (contact) => set((state) => ({
|
||||
contacts: [...state.contacts, contact],
|
||||
})),
|
||||
|
||||
updateLocalContact: (id, updates) => set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
),
|
||||
})),
|
||||
|
||||
deleteLocalContact: (id) => set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
})),
|
||||
|
||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSupportsSync: (supports) => set({ supportsSync: supports }),
|
||||
|
||||
clearContacts: () => set({
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getAutocomplete: (query) => {
|
||||
const { contacts } = get();
|
||||
if (!query || query.length < 1) return [];
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const results: Array<{ name: string; email: string }> = [];
|
||||
|
||||
for (const contact of contacts) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
|
||||
for (const emailEntry of emails) {
|
||||
if (!emailEntry.address) continue;
|
||||
if (
|
||||
name.toLowerCase().includes(lower) ||
|
||||
emailEntry.address.toLowerCase().includes(lower)
|
||||
) {
|
||||
results.push({ name, email: emailEntry.address });
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length >= 10) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'contact-storage',
|
||||
partialize: (state) => ({
|
||||
contacts: state.supportsSync ? [] : state.contacts,
|
||||
supportsSync: state.supportsSync,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { getContactDisplayName, getContactPrimaryEmail };
|
||||
export type { ContactName };
|
||||
Reference in New Issue
Block a user