feat: enhance contact management and vCard support

- Added support for parsing and generating additional vCard properties including GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, and SOURCE.
- Extended ContactCard interface to include new fields such as gender, media, anniversaries, online services, and personal info.
- Implemented logic to handle multi-part TLDs for domain extraction in avatars.
- Improved avatar component to prioritize contact photos and handle inline images in emails.
- Updated localization files to include new fields and labels for contact details.
- Refactored contact store to expose a method for retrieving contact photos.
- Enhanced unit tests to cover new vCard properties and ensure correct parsing and generation.
This commit is contained in:
Linus Rath
2026-03-12 15:54:09 +01:00
parent 4737f2928b
commit 7fedcb8e58
12 changed files with 2042 additions and 361 deletions
+44 -30
View File
@@ -1,70 +1,75 @@
# JMAP Webmail Configuration
# Copy this file to .env.local and fill in your values
# JMAP Webmail — Production Configuration
# Copy this file to .env.local and fill in your values.
# For development with the built-in mock server, see .env.dev.example instead.
# =============================================================================
# Runtime Configuration (recommended for Docker)
# These are read at request time, allowing post-build configuration
# JMAP Server (required)
# =============================================================================
# App name displayed in the UI
APP_NAME=JMAP Webmail
# JMAP server URL (required)
# This is the URL of your JMAP-compatible mail server
# URL of your JMAP-compatible mail server (required)
JMAP_SERVER_URL=https://your-jmap-server.com
# =============================================================================
# Build-time Configuration (legacy, still supported as fallback)
# These are baked into the bundle at build time
# Stalwart Mail Server Integration
# =============================================================================
# NEXT_PUBLIC_APP_NAME=JMAP Webmail
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
# Enable Stalwart-specific features (password change, sieve filters, etc.)
# Set to "false" to disable if using a non-Stalwart JMAP server.
# STALWART_FEATURES=true
# =============================================================================
# Logging Configuration
# OAuth / OpenID Connect (optional)
# =============================================================================
# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation)
LOG_FORMAT=text
# Set to "true" to use OAuth instead of basic JMAP authentication
# OAUTH_ENABLED=true
# Log level: "error", "warn", "info", or "debug"
LOG_LEVEL=info
# OAuth client ID registered with your identity provider
# OAUTH_CLIENT_ID=your-client-id
# =============================================================================
# Docker Configuration
# =============================================================================
# When running with Docker, set these in .env.local:
# APP_NAME=My Webmail
# JMAP_SERVER_URL=https://mail.example.com
#
# Then run:
# docker compose up -d
# OAuth client secret (server-side only, never exposed to the browser)
# OAUTH_CLIENT_SECRET=your-client-secret
# OpenID Connect issuer URL for discovery
# OAUTH_ISSUER_URL=https://your-idp.example.com
# =============================================================================
# Session & Security
# =============================================================================
# Secret key for encrypting "Remember me" sessions and settings sync data
# Required for both "Remember me" and settings sync features
# Use a strong random string (e.g. openssl rand -base64 32)
# Secret key for encrypting "Remember me" sessions and settings sync data.
# Required for both "Remember me" and settings sync features.
# Generate with: openssl rand -base64 32
# SESSION_SECRET=your-secret-key-here
# =============================================================================
# Settings Sync
# =============================================================================
# Enable server-side settings persistence (requires SESSION_SECRET)
# Enable server-side settings persistence (requires SESSION_SECRET).
# When enabled, user settings are encrypted and stored on the server,
# allowing them to sync across browsers and devices.
# SETTINGS_SYNC_ENABLED=true
# Directory for storing encrypted settings files (default: ./data/settings)
# Directory for storing encrypted settings files (default: ./data/settings).
# For Docker, mount a persistent volume at this path.
# SETTINGS_DATA_DIR=./data/settings
# =============================================================================
# Login Page Customization
# Logging
# =============================================================================
# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation)
# LOG_FORMAT=text
# Log level: "error", "warn", "info", or "debug"
# LOG_LEVEL=info
# =============================================================================
# Login Page Customization (all optional)
# =============================================================================
# Company or organization name displayed above the version on the login page
@@ -78,3 +83,12 @@ LOG_LEVEL=info
# URL for the company website link on the login page
# LOGIN_WEBSITE_URL=https://example.com
# =============================================================================
# Legacy Build-time Variables (still supported as fallback)
# =============================================================================
# These are baked into the bundle at build time. The runtime variables above
# take priority when both are set.
#
# NEXT_PUBLIC_APP_NAME=JMAP Webmail
# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com
+42 -1
View File
@@ -38,6 +38,46 @@ function isValidDomain(domain: string): boolean {
return true;
}
// Known multi-part TLDs where the registrable domain includes one extra label.
const MULTI_PART_TLDS = new Set([
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
"co.kr", "or.kr", "go.kr", "ac.kr",
"co.in", "net.in", "org.in", "ac.in", "gov.in",
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
"co.za", "org.za", "net.za", "gov.za", "ac.za",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"com.br", "net.br", "org.br", "edu.br", "gov.br",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
"com.my", "net.my", "org.my", "edu.my", "gov.my",
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
"co.il", "org.il", "net.il", "ac.il", "gov.il",
"co.th", "or.th", "ac.th", "go.th", "in.th",
"co.id", "or.id", "ac.id", "go.id", "web.id",
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
]);
function getRootDomain(domain: string): string {
const parts = domain.split(".");
if (parts.length <= 2) return domain;
const lastTwo = parts.slice(-2).join(".");
if (MULTI_PART_TLDS.has(lastTwo)) {
return parts.length >= 3 ? parts.slice(-3).join(".") : domain;
}
return parts.slice(-2).join(".");
}
function evictOldest() {
if (cache.size < CACHE_MAX_SIZE) return;
// Evict the oldest entry
@@ -62,7 +102,8 @@ export async function GET(request: NextRequest) {
});
}
const normalizedDomain = domain.toLowerCase();
// Resolve to root domain so subdomains share the same favicon lookup
const normalizedDomain = getRootDomain(domain.toLowerCase());
// Check negative cache (domains known to have no favicon)
const neg = negativeCache.get(normalizedDomain);
+341 -107
View File
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -17,6 +17,30 @@ interface ContactDetailProps {
className?: string;
}
function formatPhoneFeatures(features?: Record<string, boolean>): string {
if (!features) return "";
return Object.keys(features).filter(k => features[k]).join(", ");
}
function formatDate(dateStr: string): string {
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
if (dateStr.startsWith("--")) {
// Partial date without year
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
@@ -36,6 +60,20 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
const nicknames = contact.nicknames ? Object.values(contact.nicknames) : [];
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
@@ -45,6 +83,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
{titleLine && (
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
)}
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)}
@@ -62,129 +106,299 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</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 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
{e.contexts && (
<ContextBadge contexts={e.contexts} />
)}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
<div className="px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{/* Contact info */}
{emails.length > 0 && (
<Section icon={Mail} title={t("detail.emails")} category="contact">
{emails.map((e, i) => (
<div key={i} className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
{e.address}
</a>
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(e.address);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.copy_email")}
aria-label={t("detail.copy_email")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</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 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && (
<ContextBadge contexts={p.contexts} />
)}
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(p.number);
toast.success(t("detail.copied"));
} catch {
toast.error(t("detail.copy_failed"));
}
}}
className={cn(
"p-1.5 rounded hover:bg-muted transition-colors touch-manipulation",
{e.contexts && <ContextBadge contexts={e.contexts} />}
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</div>
</div>
))}
</Section>
)}
{phones.length > 0 && (
<Section icon={Phone} title={t("detail.phones")} category="contact">
{phones.map((p, i) => {
const featureStr = formatPhoneFeatures(p.features);
return (
<div key={i} className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
{p.contexts && <ContextBadge contexts={p.contexts} />}
{featureStr && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
)}
<CopyButton
value={p.number}
label={t("detail.copy_phone")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
);
})}
</Section>
)}
{(roles.length > 0 || jobTitles.length > 1) && (
<Section icon={Briefcase} title={t("detail.titles")} category="work">
{titles.map((tl, i) => (
<div key={i} className="text-sm flex items-center gap-2">
<span>{tl.name}</span>
{tl.kind && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
)}
title={t("detail.copy_phone")}
aria-label={t("detail.copy_phone")}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
))}
</Section>
)}
</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>
{orgs.length > 0 && (
<Section icon={Building} title={t("detail.organizations")} category="work">
{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 span full width */}
{addresses.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={MapPin} title={t("detail.addresses")} category="location">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{addresses.map((a, i) => (
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
<div>
{a.fullAddress
? a.fullAddress
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
{a.contexts && <ContextBadge contexts={a.contexts} />}
</div>
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
))}
</div>
</Section>
</div>
)}
{onlineServices.length > 0 && (
<Section icon={Globe} title={t("detail.online_services")} category="digital">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2 group">
{svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || svc.uri}</span>
)}
{svc.service && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
)}
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
<CopyButton
value={svc.user || svc.uri}
label={t("detail.copy_url")}
successMsg={t("detail.copied")}
failMsg={t("detail.copy_failed")}
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
/>
</div>
))}
</Section>
)}
{anniversaries.length > 0 && (
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{formatDate(ann.date)}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{t(`detail.anniversary_${ann.kind}`)}
</span>
</div>
))}
</Section>
)}
{personalInfo.length > 0 && (
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
{personalInfo.map((pi, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{pi.value}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
{pi.level && (
<span className="text-xs text-muted-foreground">({pi.level})</span>
)}
</div>
))}
</Section>
)}
{contact.gender && (contact.gender.sex || contact.gender.identity) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
<div className="text-sm">
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
{contact.gender.identity && (
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
)}
</div>
))}
</Section>
)}
</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} />
)}
{preferredLanguages.length > 0 && (
<Section icon={Languages} title={t("detail.languages")} category="personal">
{preferredLanguages.map((lang, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{lang.language}</span>
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
</div>
))}
</Section>
)}
{keywords.length > 0 && (
<Section icon={Tag} title={t("detail.categories")} category="digital">
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
))}
</Section>
)}
</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>
)}
{relatedTo.length > 0 && (
<Section icon={Users} title={t("detail.related_contacts")} category="personal">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<div key={i} className="flex items-center gap-2 text-sm">
<span>{uri}</span>
{relType && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
)}
</div>
);
})}
</Section>
)}
{cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.map((key, i) => (
<div key={i} className="text-sm break-all">
{key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{key.uri.substring(0, 80)}{key.uri.length > 80 ? "…" : ""}</span>
)}
</div>
))}
</Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
{contact.calendarUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
</div>
)}
{contact.schedulingUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
</div>
)}
{contact.freeBusyUri && (
<div className="text-sm">
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
</div>
)}
</Section>
)}
{/* Notes span full width */}
{notes.length > 0 && (
<div className="md:col-span-2 xl:col-span-3">
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
{notes.map((n, i) => (
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
))}
</Section>
</div>
)}
{/* Timestamps span full width */}
{(contact.created || contact.updated) && (
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const categoryStyles: Record<SectionCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
<div className="flex items-center gap-2 mb-2.5">
<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 className="space-y-1.5 pl-6">{children}</div>
</div>
);
}
@@ -194,8 +408,28 @@ function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
if (labels.length === 0) return null;
return (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
{labels.join(", ")}
</span>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(successMsg);
} catch {
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation transition-opacity", className)}
title={label}
aria-label={label}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
);
}
+631 -175
View File
@@ -2,11 +2,11 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { X, Plus } from "lucide-react";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
@@ -16,6 +16,33 @@ interface EmailEntry {
interface PhoneEntry {
number: string;
context: "work" | "private" | "";
feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | "";
}
interface OnlineServiceEntry {
uri: string;
service: string;
label: string;
}
interface AnniversaryEntry {
date: string;
kind: "birth" | "death" | "wedding" | "other";
}
interface PersonalInfoEntry {
value: string;
kind: "expertise" | "hobby" | "interest" | "other";
level: "high" | "medium" | "low" | "";
}
interface AddressEntry {
street: string;
locality: string;
region: string;
postcode: string;
country: string;
context: "work" | "private" | "";
}
interface ContactFormProps {
@@ -24,15 +51,92 @@ interface ContactFormProps {
onCancel: () => void;
}
type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
const formCategoryStyles: Record<FormCategory, string> = {
contact: "border-l-blue-400 dark:border-l-blue-500",
work: "border-l-amber-400 dark:border-l-amber-500",
location: "border-l-emerald-400 dark:border-l-emerald-500",
personal: "border-l-violet-400 dark:border-l-violet-500",
digital: "border-l-cyan-400 dark:border-l-cyan-500",
calendar: "border-l-rose-400 dark:border-l-rose-500",
notes: "border-l-stone-400 dark:border-l-stone-500",
};
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
category?: FormCategory;
}) {
const [open, setOpen] = useState(defaultOpen || !collapsible);
return (
<div className={cn("rounded-lg border border-border bg-card border-l-[3px] px-4 py-3", formCategoryStyles[category])}>
<button
type="button"
className={cn(
"flex items-center gap-2 w-full py-0.5 text-sm font-medium text-foreground transition-colors",
collapsible && "hover:text-muted-foreground cursor-pointer",
!collapsible && "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1}
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-left">{title}</span>
{collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
{open && (
<div className="space-y-3 pt-3 pb-1">
{children}
</div>
)}
</div>
);
}
function Select({ value, onChange, children, className }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
children: React.ReactNode;
className?: string;
}) {
return (
<select
value={value}
onChange={onChange}
className={cn(
"text-sm bg-transparent border border-input rounded-md px-2.5 py-2 text-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
"hover:border-muted-foreground/50 transition-colors",
className
)}
>
{children}
</select>
);
}
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 findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
const [givenName, setGivenName] = useState(givenInit);
const [surname, setSurname] = useState(surnameInit);
const [prefix, setPrefix] = useState(findComponent("prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
const [surname, setSurname] = useState(findComponent("surname"));
const [suffix, setSuffix] = useState(findComponent("suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
);
const [emails, setEmails] = useState<EmailEntry[]>(() => {
if (contact?.emails) {
@@ -49,6 +153,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
return Object.values(contact.phones).map(p => ({
number: p.number,
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "",
}));
}
return [];
@@ -57,11 +162,85 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
const [organization, setOrganization] = useState(
contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : ""
);
const [department, setDepartment] = useState(
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
);
const [jobTitle, setJobTitle] = useState(() => {
if (contact?.titles) {
const t = Object.values(contact.titles).find(t => t.kind !== "role");
return t?.name || "";
}
return "";
});
const [role, setRole] = useState(() => {
if (contact?.titles) {
const r = Object.values(contact.titles).find(t => t.kind === "role");
return r?.name || "";
}
return "";
});
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
if (contact?.addresses) {
return Object.values(contact.addresses).map(a => ({
street: a.street || "",
locality: a.locality || "",
region: a.region || "",
postcode: a.postcode || "",
country: a.country || "",
context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "",
}));
}
return [];
});
const [onlineServices, setOnlineServices] = useState<OnlineServiceEntry[]>(() => {
if (contact?.onlineServices) {
return Object.values(contact.onlineServices).map(s => ({
uri: s.uri,
service: s.service || "",
label: s.label || "",
}));
}
return [];
});
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
if (contact?.anniversaries) {
return Object.values(contact.anniversaries).map(a => ({
date: a.date,
kind: a.kind,
}));
}
return [];
});
const [personalInfoEntries, setPersonalInfoEntries] = useState<PersonalInfoEntry[]>(() => {
if (contact?.personalInfo) {
return Object.values(contact.personalInfo).map(p => ({
value: p.value,
kind: p.kind,
level: p.level || "",
}));
}
return [];
});
const [keywordsStr, setKeywordsStr] = useState(
contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : ""
);
const [note, setNote] = useState(
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
);
const [genderSex, setGenderSex] = useState(contact?.gender?.sex || "");
const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || "");
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
@@ -110,33 +289,94 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
});
const validPhones = phones.filter(p => p.number.trim());
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean> }> = {};
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> }> = {};
validPhones.forEach((entry, i) => {
const obj: { number: string; contexts?: Record<string, boolean> } = { number: entry.number.trim() };
const obj: { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> } = { number: entry.number.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
if (entry.feature) {
obj.features = { [entry.feature]: 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() });
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" };
const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined;
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
const obj: Record<string, unknown> = {};
if (a.street.trim()) obj.street = a.street.trim();
if (a.locality.trim()) obj.locality = a.locality.trim();
if (a.region.trim()) obj.region = a.region.trim();
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
if (a.country.trim()) obj.country = a.country.trim();
if (a.context) obj.contexts = { [a.context]: true };
// @ts-expect-error - dynamic build
addressesMap[`a${i}`] = obj;
});
const onlineServicesMap: Record<string, ContactOnlineService> = {};
onlineServices.filter(s => s.uri.trim()).forEach((s, i) => {
const obj: ContactOnlineService = { uri: s.uri.trim() };
if (s.service.trim()) obj.service = s.service.trim();
if (s.label.trim()) obj.label = s.label.trim();
onlineServicesMap[`os${i}`] = obj;
});
const anniversariesMap: Record<string, ContactAnniversary> = {};
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind };
});
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => {
const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind };
if (p.level) obj.level = p.level as "high" | "medium" | "low";
personalInfoMap[`pi${i}`] = obj;
});
const keywordsMap: Record<string, boolean> = {};
if (keywordsStr.trim()) {
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
keywordsMap[k] = true;
});
}
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim() } }
? { o0: { name: organization.trim(), units: orgUnits } }
: undefined,
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined,
personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined,
keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined,
notes: note.trim()
? { n0: { note: note.trim() } }
: undefined,
gender: (genderSex.trim() || genderIdentity.trim())
? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined }
: undefined,
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
};
setIsSaving(true);
@@ -150,186 +390,402 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
};
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<button type="button" onClick={onCancel} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</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="flex-1 overflow-y-auto">
<div className="px-6 py-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-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</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")} <span className="text-red-500">*</span>
</label>
<Input
value={surname}
onChange={(e) => setSurname(e.target.value)}
placeholder={t("surname")}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<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}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => {
const n = { ...prev };
delete n[i];
return n;
});
}
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/>
<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"
{/* Name & Identity — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={User} title={t("section_identity")} category="contact">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</FormSection>
</div>
{/* Email */}
<FormSection icon={Mail} title={t("email")} collapsible defaultOpen category="contact">
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], address: e.target.value };
setEmails(next);
if (emailErrors[i]) {
setEmailErrors(prev => { const n = { ...prev }; delete n[i]; return n; });
}
}}
onBlur={() => handleEmailBlur(i, entry.address)}
placeholder={t("email_placeholder")}
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
/>
<Select
value={entry.context}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
setEmails(next);
}}
>
<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 shrink-0">
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_email")}
</Button>
</div>
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")} collapsible defaultOpen category="contact">
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
inputMode="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.feature}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], feature: e.target.value as PhoneEntry["feature"] };
setPhones(next);
}}
className="w-[5.5rem]"
>
<option value="">{t("phone_type")}</option>
<option value="voice">{t("phone_voice")}</option>
<option value="cell">{t("phone_cell")}</option>
<option value="fax">{t("phone_fax")}</option>
<option value="pager">{t("phone_pager")}</option>
<option value="video">{t("phone_video")}</option>
<option value="text">{t("phone_text")}</option>
</Select>
<Select
value={entry.context}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
setPhones(next);
}}
>
<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 shrink-0">
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{emailErrors[i]}</p>
)}
</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>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_phone")}
</Button>
</div>
</FormSection>
<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"
inputMode="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"
>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen category="work">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs 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-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("job_title")}</label>
<Input value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder={t("job_title_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("role")}</label>
<Input value={role} onChange={(e) => setRole(e.target.value)} placeholder={t("role_placeholder")} />
</div>
</div>
</FormSection>
{/* Addresses — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<Button type="button" variant="ghost" size="icon" onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="h-6 w-6 absolute top-2 right-2">
<X className="w-3 h-3" />
</Button>
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
<div className="grid grid-cols-2 gap-2">
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} />
<Input value={addr.region} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} />
</div>
<div className="grid grid-cols-3 gap-2">
<Input value={addr.postcode} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} />
<Input value={addr.country} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} />
<Select
value={addr.context}
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
>
<option value=""></option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
</div>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_address")}
</Button>
</div>
</FormSection>
</div>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen category="digital">
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={svc.uri}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
placeholder={t("url_placeholder")}
className="flex-1"
/>
<Input
value={svc.service}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
placeholder={t("service_placeholder")}
className="w-24"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_online_service")}
</Button>
</div>
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen category="personal">
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="date"
value={ann.date}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], date: e.target.value }; setAnniversaries(n); }}
className="flex-1"
/>
<Select
value={ann.kind}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], kind: e.target.value as AnniversaryEntry["kind"] }; setAnniversaries(n); }}
>
<option value="birth">{t("anniversary_birth")}</option>
<option value="wedding">{t("anniversary_wedding")}</option>
<option value="death">{t("anniversary_death")}</option>
<option value="other">{t("anniversary_other")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_anniversary")}
</Button>
</div>
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen category="personal">
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={pi.value}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
placeholder={t("personal_info_placeholder")}
className="flex-1"
/>
<Select
value={pi.kind}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
>
<option value="expertise">{t("personal_expertise")}</option>
<option value="hobby">{t("personal_hobby")}</option>
<option value="interest">{t("personal_interest")}</option>
<option value="other">{t("personal_other")}</option>
</Select>
<Select
value={pi.level}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
>
<option value="">{t("level")}</option>
<option value="high">{t("level_high")}</option>
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
{t("add_personal_info")}
</Button>
</div>
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<div>
<Input
value={keywordsStr}
onChange={(e) => setKeywordsStr(e.target.value)}
placeholder={t("categories_placeholder")}
/>
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
</div>
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen category="personal">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<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>
<option value="M">{t("gender_male")}</option>
<option value="F">{t("gender_female")}</option>
<option value="O">{t("gender_other")}</option>
<option value="N">{t("gender_none")}</option>
<option value="U">{t("gender_unknown")}</option>
</Select>
</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>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
<Input value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
</div>
</div>
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen category="calendar">
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
<Input value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
<Input value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
<Input value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
</div>
</div>
</FormSection>
{/* Notes — full width */}
<div className="md:col-span-2 xl:col-span-3">
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("note_placeholder")}
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</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>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("cancel")}
</Button>
+188 -23
View File
@@ -431,7 +431,7 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const { identities, client } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false);
@@ -662,6 +662,30 @@ export function EmailViewer({
// If we should use HTML version and it exists
if (useHtmlVersion && htmlContent) {
// Replace cid: references with actual blob download URLs for inline images
const cidReplacedUrls = new Set<string>();
if (client && email.attachments) {
const cidMap = new Map<string, string>();
for (const att of email.attachments) {
if (att.cid && att.blobId) {
const cidValue = att.cid.replace(/^<|>$/g, '');
try {
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
cidMap.set(cidValue, url);
cidReplacedUrls.add(url);
} catch {
// downloadUrl not available yet, skip
}
}
}
if (cidMap.size > 0) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(match, cidRef) => cidMap.get(cidRef) || match
);
}
}
// Create a custom DOMPurify hook to handle external content
let blockedExternalContent = false;
@@ -690,7 +714,7 @@ export function EmailViewer({
if (shouldBlockExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4=');
node.setAttribute('alt', '');
@@ -801,7 +825,7 @@ export function EmailViewer({
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme]);
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, resolvedTheme, client]);
// Print only the email content in a new window
const handlePrint = () => {
@@ -1424,20 +1448,57 @@ export function EmailViewer({
{/* === SENDER INFO (Desktop) === */}
<div className="hidden lg:block bg-background border-b border-border px-6 py-4">
<div className="flex items-start gap-4">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12"
/>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-12 h-12 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')}
</span>
<EmailIdentityBadge email={email} identities={identities} />
{/* Sender line with email and badges */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
title={t('view_contact')}
>
{sender?.name || sender?.email || t('unknown_sender')}
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
{sender?.email && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">
{sender.email}
</div>
)}
</div>
{/* Date and size on the right */}
<div className="text-right flex-shrink-0">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(email.receivedAt).toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
{email.size > 0 && (
<div className="text-xs text-muted-foreground/70 mt-0.5">
{formatFileSize(email.size)}
</div>
)}
</div>
</div>
{/* Recipient section - separate line */}
@@ -1466,6 +1527,16 @@ export function EmailViewer({
)}
</div>
)}
{email.bcc && email.bcc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">{t('bcc')}:</span>
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar)}
{email.bcc.length > 2 && (
<span className="text-muted-foreground text-sm">+{email.bcc.length - 2}</span>
)}
</div>
)}
</div>
{/* Details toggle - stays in place when expanded */}
@@ -1489,6 +1560,91 @@ export function EmailViewer({
{/* Expandable Details */}
{showFullHeaders && (
<div className="mt-3 space-y-3">
{/* Full Recipients Section */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xs font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider flex items-center gap-2">
<Mail className="w-3.5 h-3.5" />
{t('message_details')}
</h3>
</div>
<div className="bg-background p-4 space-y-2 text-sm">
{/* From */}
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('from')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
onViewContact={handleViewContactSidebar}
className="text-sm"
/>
</div>
</div>
{/* To - show all */}
{email.to && email.to.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('to')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* CC - show all */}
{email.cc && email.cc.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('cc')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.cc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* BCC - show all */}
{email.bcc && email.bcc.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('bcc')}:</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{renderClickableRecipients(email.bcc, currentUserEmail, t, handleViewContactSidebar, 100)}
</div>
</div>
)}
{/* Date */}
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('date')}:</span>
<span className="text-foreground">
{new Date(email.receivedAt).toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
})}
</span>
</div>
{/* Reply-To if different */}
{email.replyTo && email.replyTo.length > 0 &&
(!email.from || email.replyTo[0].email !== email.from[0]?.email) && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground font-medium w-12 shrink-0">{t('reply_to_label').replace(':', '')}</span>
<div className="flex flex-wrap items-center gap-1 min-w-0">
{email.replyTo.map((r, i) => (
<RecipientPopover
key={r.email + i}
name={r.name}
email={r.email}
onViewContact={handleViewContactSidebar}
className="text-sm"
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Security & Authentication Section */}
{(email.authenticationResults || email.spamScore !== undefined) && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@@ -1811,18 +1967,27 @@ export function EmailViewer({
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4 py-3">
<div className="flex items-start gap-3">
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10"
/>
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="cursor-pointer group flex-shrink-0"
title={sender?.email || undefined}
>
<Avatar
name={sender?.name}
email={sender?.email}
size="lg"
className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all"
/>
</button>
<div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground">
<button
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
className="text-sm font-semibold text-foreground hover:text-primary hover:underline transition-colors cursor-pointer text-left"
>
{sender?.name || sender?.email || t('unknown_sender')}
</span>
</button>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
+28 -2
View File
@@ -29,6 +29,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -224,6 +225,7 @@ function EmailCard({
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const { client } = useAuthStore();
// Mark as read when email is expanded
useEffect(() => {
@@ -267,6 +269,30 @@ function EmailCard({
}
if (useHtmlVersion && htmlContent) {
// Replace cid: references with actual blob download URLs for inline images
const cidReplacedUrls = new Set<string>();
if (client && email.attachments) {
const cidMap = new Map<string, string>();
for (const att of email.attachments) {
if (att.cid && att.blobId) {
const cidValue = att.cid.replace(/^<|>$/g, '');
try {
const url = client.getBlobDownloadUrl(att.blobId, att.name || 'inline', att.type);
cidMap.set(cidValue, url);
cidReplacedUrls.add(url);
} catch {
// downloadUrl not available yet, skip
}
}
}
if (cidMap.size > 0) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(match, cidRef) => cidMap.get(cidRef) || match
);
}
}
let blockedExternalContent = false;
// Use shared sanitization config as base (more secure)
@@ -278,7 +304,7 @@ function EmailCard({
if (!allowExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
if (src && !cidReplacedUrls.has(src) && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.removeAttribute('src');
node.setAttribute('alt', '[Image blocked]');
@@ -352,7 +378,7 @@ function EmailCard({
}
return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme]);
}, [email, allowExternal, resolvedTheme, client]);
return (
<div className={cn(
+85 -11
View File
@@ -1,11 +1,64 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
const IS_DEV = process.env.NODE_ENV !== "production";
// Known multi-part TLDs where the "main" domain includes one extra label.
// e.g. "newsletter.example.co.uk" → "example.co.uk", not "co.uk".
const MULTI_PART_TLDS = new Set([
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
"co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
"co.kr", "or.kr", "go.kr", "ac.kr",
"co.in", "net.in", "org.in", "ac.in", "gov.in",
"co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
"co.za", "org.za", "net.za", "gov.za", "ac.za",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"com.br", "net.br", "org.br", "edu.br", "gov.br",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
"com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
"com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
"com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
"com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
"com.my", "net.my", "org.my", "edu.my", "gov.my",
"com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
"com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
"com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
"co.il", "org.il", "net.il", "ac.il", "gov.il",
"co.th", "or.th", "ac.th", "go.th", "in.th",
"co.id", "or.id", "ac.id", "go.id", "web.id",
"com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
"com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
"com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
"com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
"co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
]);
/**
* Extract the root/registrable domain from a full domain.
* e.g. "newsletter.example.com" → "example.com"
* "mail.shop.example.co.uk" → "example.co.uk"
* "example.com" → "example.com"
*/
function getRootDomain(domain: string): string {
const parts = domain.split(".");
if (parts.length <= 2) return domain;
// Check if the last two parts form a known multi-part TLD
const lastTwo = parts.slice(-2).join(".");
if (MULTI_PART_TLDS.has(lastTwo)) {
// Need at least 3 parts for a valid domain under a multi-part TLD
return parts.length >= 3 ? parts.slice(-3).join(".") : domain;
}
// Standard TLD: take last two parts
return parts.slice(-2).join(".");
}
// Module-level cache of domains whose favicons failed to load.
// Shared across all Avatar instances to avoid re-requesting known-bad domains.
const failedFaviconDomains = new Set<string>();
@@ -82,15 +135,36 @@ function getProfilePictureUrl(email: string, domain: string, name?: string): str
interface AvatarProps {
name?: string;
email?: string;
contactPhotoUri?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts);
// Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri;
if (!email) return undefined;
const lowerEmail = email.toLowerCase();
for (const contact of contacts) {
if (!contact.emails) continue;
for (const e of Object.values(contact.emails)) {
if (e.address.toLowerCase() === lowerEmail) {
return getContactPhotoUri(contact);
}
}
}
return undefined;
}, [contactPhotoUri, email, contacts]);
const domain = email?.split("@")[1]?.toLowerCase();
const domainFailed = domain ? failedFaviconDomains.has(domain) : false;
// Use root domain for favicon lookups (e.g. newsletter.example.com → example.com)
const faviconDomain = domain ? getRootDomain(domain) : undefined;
const domainFailed = faviconDomain ? failedFaviconDomains.has(faviconDomain) : false;
const getInitials = () => {
if (name) {
@@ -124,21 +198,21 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const profilePic = email && domain ? getProfilePictureUrl(email, domain, name) : null;
const showFavicon =
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError && !domainFailed;
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: custom avatar > profile picture > company favicon > initials
// Priority: contact photo > custom avatar > profile picture > company favicon > initials
const customAvatar = email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const imgSrc = !imgError && !domainFailed
? customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(domain!)}` : null)
: (customAvatar || profilePic || null);
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null);
const handleImgError = useCallback(() => {
setImgError(true);
// If this was a favicon URL (not a custom avatar or profile pic), remember the domain
if (domain && !customAvatar && !profilePic) {
failedFaviconDomains.add(domain);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain);
}
}, [domain, customAvatar, profilePic]);
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return (
<div
+81 -1
View File
@@ -203,6 +203,47 @@ describe("parseVCard", () => {
expect(result).toHaveLength(1);
expect(result[0].kind).toBe("group");
});
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:4.0",
"FN:Jane Doe",
"GENDER:F;Female",
"LOGO;MEDIATYPE=image/png:https://example.com/logo.png",
"SOUND;MEDIATYPE=audio/ogg:https://example.com/sound.ogg",
"LABEL;TYPE=HOME:123 Main St\\nSpringfield, IL",
"ADR;TYPE=HOME:;;123 Main St;Springfield;IL;62704;US",
"CALURI:https://example.com/calendar/jane",
"CALADRURI:https://example.com/calendar/jane/schedule",
"FBURL:https://example.com/freebusy/jane",
"SOURCE:https://example.com/jane.vcf",
"EMAIL:jane@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.gender).toEqual({ sex: "F", identity: "Female" });
expect(card.media?.m0).toEqual({
kind: "logo",
uri: "https://example.com/logo.png",
mediaType: "image/png",
});
expect(card.media?.m1).toEqual({
kind: "sound",
uri: "https://example.com/sound.ogg",
mediaType: "audio/ogg",
});
expect(card.calendarUri).toBe("https://example.com/calendar/jane");
expect(card.schedulingUri).toBe("https://example.com/calendar/jane/schedule");
expect(card.freeBusyUri).toBe("https://example.com/freebusy/jane");
expect(card.source).toBe("https://example.com/jane.vcf");
// LABEL sets fullAddress on the ADR entry
expect(card.addresses?.a0?.fullAddress).toBe("123 Main St\nSpringfield, IL");
});
});
describe("generateVCard", () => {
@@ -251,7 +292,7 @@ describe("generateVCard", () => {
expect(vcf).toContain("VERSION:3.0");
expect(vcf).toContain("UID:uid-1");
expect(vcf).toContain("KIND:individual");
expect(vcf).toContain("FN:Jane Smith");
expect(vcf).toContain("FN:Dr. Jane Marie Smith PhD");
expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD");
expect(vcf).toContain("NICKNAME:JJ");
expect(vcf).toContain("EMAIL;TYPE=WORK:jane@work.com");
@@ -282,6 +323,45 @@ describe("generateVCard", () => {
expect(lines[lines.length - 1]).toBe("END:VCARD");
});
it("exports GENDER, LOGO, SOUND, GEO, TZ, CALURI, CALADRURI, FBURL, SOURCE", () => {
const contact: ContactCard = {
id: "c-new",
addressBookIds: {},
name: {
components: [{ kind: "given", value: "Jane" }],
isOrdered: true,
},
gender: { sex: "F", identity: "Female" },
media: {
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
},
addresses: {
a0: {
street: "123 Main St",
locality: "City",
coordinates: "geo:37.386013,-122.082932",
timeZone: "America/Los_Angeles",
},
},
calendarUri: "https://example.com/calendar/jane",
schedulingUri: "https://example.com/calendar/jane/schedule",
freeBusyUri: "https://example.com/freebusy/jane",
source: "https://example.com/jane.vcf",
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("GENDER:F;Female");
expect(vcf).toContain("LOGO;VALUE=URI;MEDIATYPE=image/png:https://example.com/logo.png");
expect(vcf).toContain("SOUND;VALUE=URI;MEDIATYPE=audio/ogg:https://example.com/sound.ogg");
expect(vcf).toContain("GEO:geo:37.386013,-122.082932");
expect(vcf).toContain("TZ:America/Los_Angeles");
expect(vcf).toContain("CALURI:https://example.com/calendar/jane");
expect(vcf).toContain("CALADRURI:https://example.com/calendar/jane/schedule");
expect(vcf).toContain("FBURL:https://example.com/freebusy/jane");
expect(vcf).toContain("SOURCE:https://example.com/jane.vcf");
});
it("encodes special characters in values", () => {
const contact: ContactCard = {
id: "c3",
+97 -3
View File
@@ -159,15 +159,33 @@ export interface ContactCard {
id: string;
uid?: string;
addressBookIds: Record<string, boolean>;
kind?: 'individual' | 'group' | 'org';
kind?: 'individual' | 'group' | 'org' | 'location' | 'device' | 'application';
language?: string;
name?: ContactName;
nicknames?: Record<string, ContactNickname>;
emails?: Record<string, ContactEmail>;
phones?: Record<string, ContactPhone>;
onlineServices?: Record<string, ContactOnlineService>;
preferredLanguages?: Record<string, ContactLanguagePref>;
organizations?: Record<string, ContactOrganization>;
titles?: Record<string, ContactTitle>;
addresses?: Record<string, ContactAddress>;
nicknames?: Record<string, ContactNickname>;
anniversaries?: Record<string, ContactAnniversary>;
personalInfo?: Record<string, ContactPersonalInfo>;
notes?: Record<string, ContactNote>;
media?: Record<string, ContactMedia>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
links?: Record<string, ContactLink>;
relatedTo?: Record<string, ContactRelation>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
gender?: { sex?: string; identity?: string };
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -178,7 +196,7 @@ export interface ContactName {
}
export interface NameComponent {
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional';
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
value: string;
}
@@ -186,17 +204,42 @@ export interface ContactEmail {
address: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactPhone {
number: string;
contexts?: Record<string, boolean>;
features?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactOnlineService {
service?: string;
uri: string;
user?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactLanguagePref {
language: string;
contexts?: Record<string, boolean>;
pref?: number;
}
export interface ContactOrganization {
name?: string;
units?: Array<{ name: string }>;
sortAs?: string;
}
export interface ContactTitle {
name: string;
kind?: 'title' | 'role';
organizationId?: string;
}
export interface ContactAddress {
@@ -205,16 +248,67 @@ export interface ContactAddress {
region?: string;
postcode?: string;
country?: string;
countryCode?: string;
fullAddress?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactNickname {
name: string;
contexts?: Record<string, boolean>;
}
export interface ContactNote {
note: string;
created?: string;
author?: { name?: string; uri?: string };
}
export interface ContactMedia {
kind: 'photo' | 'sound' | 'logo';
uri: string;
mediaType?: string;
}
export interface ContactAnniversary {
kind: 'birth' | 'death' | 'wedding' | 'other';
date: string;
place?: ContactAddress;
}
export interface ContactPersonalInfo {
kind: 'expertise' | 'hobby' | 'interest' | 'other';
value: string;
level?: 'high' | 'medium' | 'low';
}
export interface ContactCryptoKey {
uri: string;
mediaType?: string;
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry';
mediaType?: string;
}
export interface ContactLink {
uri: string;
kind?: 'contact' | 'generic';
mediaType?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
}
export interface ContactRelation {
relation?: Record<string, boolean>;
}
export interface AddressBook {
+400 -5
View File
@@ -1,4 +1,4 @@
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -30,7 +30,7 @@ function parseParams(paramStr: string): Record<string, string> {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF"].includes(upper)) {
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
@@ -38,6 +38,20 @@ function parseParams(paramStr: string): Record<string, string> {
return params;
}
const PHONE_FEATURE_TYPES = new Set(["CELL", "FAX", "VOICE", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"]);
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
const features: Record<string, boolean> = {};
for (const t of types) {
if (PHONE_FEATURE_TYPES.has(t)) {
features[t.toLowerCase()] = true;
}
}
return Object.keys(features).length > 0 ? features : undefined;
}
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
@@ -150,6 +164,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.phones[`p${idx}`] = {
number: val,
contexts: typeToContext(params.TYPE),
features: typeToPhoneFeatures(params.TYPE),
};
break;
}
@@ -211,6 +226,248 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
card.members[memberUri] = true;
break;
}
case "PHOTO": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
// Inline base64 photo - construct a data URI
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/jpeg";
card.media[`m${idx}`] = {
kind: "photo",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
// URI value (data URI or URL)
card.media[`m${idx}`] = {
kind: "photo",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "TITLE": {
if (!card.titles) card.titles = {};
const idx = Object.keys(card.titles).length;
card.titles[`t${idx}`] = { name: val, kind: "title" };
break;
}
case "ROLE": {
if (!card.titles) card.titles = {};
const idx = Object.keys(card.titles).length;
card.titles[`t${idx}`] = { name: val, kind: "role" };
break;
}
case "URL": {
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
card.onlineServices[`u${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
label: params.TYPE?.toLowerCase() === "home" || params.TYPE?.toLowerCase() === "work" ? undefined : params.TYPE,
};
break;
}
case "IMPP":
case "X-SOCIALPROFILE": {
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
const svc: ContactOnlineService = {
uri: val,
contexts: typeToContext(params.TYPE),
};
if (params["X-SERVICE-TYPE"]) {
svc.service = params["X-SERVICE-TYPE"];
} else if (propName === "X-SOCIALPROFILE" && params.TYPE) {
const typeVal = params.TYPE.toLowerCase();
if (typeVal !== "work" && typeVal !== "home") {
svc.service = params.TYPE;
}
}
if (params["X-USER"]) svc.user = params["X-USER"];
card.onlineServices[`u${idx}`] = svc;
break;
}
case "BDAY": {
if (!card.anniversaries) card.anniversaries = {};
card.anniversaries.a0 = { kind: "birth", date: val };
break;
}
case "ANNIVERSARY":
case "X-ANNIVERSARY": {
if (!card.anniversaries) card.anniversaries = {};
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: "wedding", date: val };
break;
}
case "DEATHDATE":
case "X-DEATHDATE": {
if (!card.anniversaries) card.anniversaries = {};
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: "death", date: val };
break;
}
case "CATEGORIES": {
if (!card.keywords) card.keywords = {};
const cats = val.split(",").map(c => c.trim()).filter(Boolean);
for (const cat of cats) {
card.keywords[cat] = true;
}
break;
}
case "KEY": {
if (!card.cryptoKeys) card.cryptoKeys = {};
const idx = Object.keys(card.cryptoKeys).length;
card.cryptoKeys[`k${idx}`] = {
uri: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "RELATED": {
if (!card.relatedTo) card.relatedTo = {};
const relType = params.TYPE?.toLowerCase();
const relation: Record<string, boolean> = {};
if (relType) relation[relType] = true;
card.relatedTo[val] = { relation: Object.keys(relation).length > 0 ? relation : undefined };
break;
}
case "LANG": {
if (!card.preferredLanguages) card.preferredLanguages = {};
const idx = Object.keys(card.preferredLanguages).length;
card.preferredLanguages[`l${idx}`] = {
language: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "PRODID":
card.prodId = val;
break;
case "REV":
card.updated = val;
break;
case "GEO": {
// Store GEO as coordinates on the first address, or create one
if (!card.addresses) card.addresses = {};
if (Object.keys(card.addresses).length === 0) {
card.addresses.a0 = { coordinates: val };
} else {
const firstKey = Object.keys(card.addresses)[0];
card.addresses[firstKey].coordinates = val;
}
break;
}
case "TZ": {
if (!card.addresses) card.addresses = {};
if (Object.keys(card.addresses).length === 0) {
card.addresses.a0 = { timeZone: val };
} else {
const firstKey = Object.keys(card.addresses)[0];
card.addresses[firstKey].timeZone = val;
}
break;
}
case "GENDER": {
const gParts = val.split(";");
card.gender = {};
if (gParts[0]) card.gender.sex = gParts[0];
if (gParts[1]) card.gender.identity = gParts[1];
break;
}
case "LOGO": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `image/${mediaType.toLowerCase()}` : "image/png";
card.media[`m${idx}`] = {
kind: "logo",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
card.media[`m${idx}`] = {
kind: "logo",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "SOUND": {
if (!card.media) card.media = {};
const idx = Object.keys(card.media).length;
const encoding = params.ENCODING?.toUpperCase();
const mediaType = params.TYPE || params.MEDIATYPE || "";
if (encoding === "B" || encoding === "BASE64") {
const mime = mediaType.includes("/") ? mediaType : mediaType ? `audio/${mediaType.toLowerCase()}` : "audio/ogg";
card.media[`m${idx}`] = {
kind: "sound",
uri: `data:${mime};base64,${rawValue}`,
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
card.media[`m${idx}`] = {
kind: "sound",
uri: val,
mediaType: mediaType.includes("/") ? mediaType : undefined,
};
}
break;
}
case "LABEL": {
// Mailing label (v2.1/3.0) - store as fullAddress on last/new address
if (!card.addresses) card.addresses = {};
const addrKeys = Object.keys(card.addresses);
if (addrKeys.length > 0) {
const lastKey = addrKeys[addrKeys.length - 1];
card.addresses[lastKey].fullAddress = val;
} else {
card.addresses.a0 = { fullAddress: val, contexts: typeToContext(params.TYPE) };
}
break;
}
case "CALURI":
card.calendarUri = val;
break;
case "CALADRURI":
card.schedulingUri = val;
break;
case "FBURL":
card.freeBusyUri = val;
break;
case "SOURCE":
card.source = val;
break;
}
}
}
@@ -233,10 +490,18 @@ function generateSingleVCard(contact: ContactCard): string {
lines.push(`UID:${contact.uid}`);
}
if (contact.prodId) {
lines.push(`PRODID:${contact.prodId}`);
}
if (contact.kind) {
lines.push(`KIND:${contact.kind}`);
}
if (contact.updated) {
lines.push(`REV:${contact.updated}`);
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
@@ -244,7 +509,7 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [given, surname].filter(Boolean).join(" ");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ");
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
@@ -266,8 +531,15 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.phones) {
for (const phone of Object.values(contact.phones)) {
const type = contextToType(phone.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
const typeParts: string[] = [];
const ctxType = contextToType(phone.contexts);
if (ctxType) typeParts.push(ctxType);
if (phone.features) {
for (const feat of Object.keys(phone.features)) {
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
}
}
const typeParam = typeParts.length > 0 ? `;TYPE=${typeParts.join(",")}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
}
}
@@ -280,6 +552,16 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.titles) {
for (const title of Object.values(contact.titles)) {
if (title.kind === "role") {
lines.push(`ROLE:${encodeValue(title.name)}`);
} else {
lines.push(`TITLE:${encodeValue(title.name)}`);
}
}
}
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
@@ -297,6 +579,68 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.anniversaries) {
for (const ann of Object.values(contact.anniversaries)) {
if (ann.kind === "birth") {
lines.push(`BDAY:${ann.date}`);
} else if (ann.kind === "wedding") {
lines.push(`ANNIVERSARY:${ann.date}`);
} else if (ann.kind === "death") {
lines.push(`DEATHDATE:${ann.date}`);
}
}
}
if (contact.onlineServices) {
for (const svc of Object.values(contact.onlineServices)) {
if (svc.service || svc.user) {
// Output as IMPP for instant messaging / social profiles
const params: string[] = [];
if (svc.service) params.push(`X-SERVICE-TYPE=${svc.service}`);
const ctxType = contextToType(svc.contexts);
if (ctxType) params.push(`TYPE=${ctxType}`);
const paramStr = params.length > 0 ? `;${params.join(";")}` : "";
lines.push(`IMPP${paramStr}:${svc.uri}`);
} else {
// Output as URL for plain web links
const type = contextToType(svc.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`URL${typeParam}:${svc.uri}`);
}
}
}
if (contact.keywords) {
const cats = Object.keys(contact.keywords).filter(k => contact.keywords![k]);
if (cats.length > 0) {
lines.push(`CATEGORIES:${cats.map(encodeValue).join(",")}`);
}
}
if (contact.preferredLanguages) {
for (const lang of Object.values(contact.preferredLanguages)) {
const type = contextToType(lang.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`LANG${typeParam}:${lang.language}`);
}
}
if (contact.relatedTo) {
for (const [uri, rel] of Object.entries(contact.relatedTo)) {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
const typeParam = relType ? `;TYPE=${relType}` : "";
lines.push(`RELATED${typeParam}:${uri}`);
}
}
if (contact.cryptoKeys) {
for (const key of Object.values(contact.cryptoKeys)) {
const type = contextToType(key.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`KEY${typeParam}:${key.uri}`);
}
}
if (contact.notes) {
for (const n of Object.values(contact.notes)) {
lines.push(`NOTE:${encodeValue(n.note)}`);
@@ -311,6 +655,57 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.media) {
for (const media of Object.values(contact.media)) {
if (media.uri) {
const prop = media.kind === "logo" ? "LOGO" : media.kind === "sound" ? "SOUND" : "PHOTO";
if (media.uri.startsWith("data:")) {
const match = media.uri.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
lines.push(`${prop};ENCODING=b;TYPE=${match[1]}:${match[2]}`);
}
} else {
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : "";
lines.push(`${prop};VALUE=URI${mt}:${media.uri}`);
}
}
}
}
// GEO and TZ from addresses
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
if (addr.coordinates) {
lines.push(`GEO:${addr.coordinates}`);
}
if (addr.timeZone) {
lines.push(`TZ:${addr.timeZone}`);
}
}
}
if (contact.gender) {
const sex = contact.gender.sex || "";
const identity = contact.gender.identity || "";
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
}
if (contact.calendarUri) {
lines.push(`CALURI:${contact.calendarUri}`);
}
if (contact.schedulingUri) {
lines.push(`CALADRURI:${contact.schedulingUri}`);
}
if (contact.freeBusyUri) {
lines.push(`FBURL:${contact.freeBusyUri}`);
}
if (contact.source) {
lines.push(`SOURCE:${contact.source}`);
}
lines.push("END:VCARD");
return lines.join("\r\n");
}
+96 -1
View File
@@ -161,6 +161,7 @@
"load_external_content": "Load images",
"trust_sender": "Always trust this sender",
"back_to_list": "Back to list",
"view_contact": "View contact",
"message_details": "Message Details",
"more_reply_options": "More reply options",
"set_color": "Set tag",
@@ -1233,28 +1234,122 @@
"organizations": "Organizations",
"addresses": "Addresses",
"notes": "Notes",
"titles": "Titles & Roles",
"online_services": "Online Services",
"anniversaries": "Anniversaries",
"personal_info": "Personal Info",
"languages": "Languages",
"categories": "Categories",
"related_contacts": "Related Contacts",
"crypto_keys": "Crypto Keys",
"no_contact_selected": "Select a contact to view details",
"compose_email": "Compose email",
"copy_email": "Copy email",
"copy_phone": "Copy phone number",
"copy_url": "Copy URL",
"copied": "Copied to clipboard",
"copy_failed": "Failed to copy to clipboard",
"created": "Created",
"updated": "Last updated"
"updated": "Last updated",
"timezone": "Timezone",
"anniversary_birth": "Birthday",
"anniversary_death": "Passed away",
"anniversary_wedding": "Anniversary",
"anniversary_other": "Other",
"personal_expertise": "Expertise",
"personal_hobby": "Hobby",
"personal_interest": "Interest",
"personal_other": "Other",
"gender": "Gender",
"gender_M": "Male",
"gender_F": "Female",
"gender_O": "Other",
"gender_N": "Not applicable",
"gender_U": "Unknown",
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL"
},
"form": {
"create_title": "New Contact",
"edit_title": "Edit Contact",
"section_identity": "Name & Identity",
"section_work": "Work & Organization",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Mr., Mrs.",
"given_name": "First name",
"middle_name": "Middle name",
"surname": "Last name",
"suffix": "Suffix",
"suffix_placeholder": "Jr., Sr., III",
"nickname": "Nickname",
"nickname_placeholder": "Nickname",
"email": "Email",
"email_placeholder": "email@example.com",
"phone": "Phone",
"phone_placeholder": "+1 234 567 890",
"phone_type": "Type",
"phone_voice": "Voice",
"phone_cell": "Mobile",
"phone_fax": "Fax",
"phone_pager": "Pager",
"phone_video": "Video",
"phone_text": "Text",
"organization": "Organization",
"organization_placeholder": "Company name",
"department": "Department",
"department_placeholder": "Department",
"job_title": "Job title",
"job_title_placeholder": "e.g., Software Engineer",
"role": "Role",
"role_placeholder": "e.g., Team Lead",
"addresses": "Addresses",
"add_address": "Add address",
"street": "Street",
"city": "City",
"region": "State / Region",
"postcode": "Postal code",
"country": "Country",
"online_services": "Online Services",
"add_online_service": "Add online service",
"url_placeholder": "https://...",
"service_placeholder": "Service",
"anniversaries": "Anniversaries",
"add_anniversary": "Add date",
"anniversary_birth": "Birthday",
"anniversary_wedding": "Anniversary",
"anniversary_death": "Memorial",
"anniversary_other": "Other",
"personal_info": "Personal Info",
"add_personal_info": "Add entry",
"personal_info_placeholder": "e.g., Photography",
"personal_expertise": "Expertise",
"personal_hobby": "Hobby",
"personal_interest": "Interest",
"personal_other": "Other",
"level": "Level",
"level_high": "High",
"level_medium": "Medium",
"level_low": "Low",
"categories": "Categories",
"categories_placeholder": "e.g., Family, Friends, Colleagues",
"categories_hint": "Separate with commas",
"note": "Notes",
"note_placeholder": "Add a note...",
"gender": "Gender",
"gender_sex": "Sex",
"gender_male": "Male",
"gender_female": "Female",
"gender_other": "Other",
"gender_none": "Not applicable",
"gender_unknown": "Unknown",
"gender_identity": "Gender identity",
"gender_identity_placeholder": "Gender identity...",
"calendar": "Calendar",
"calendar_uri": "Calendar URL",
"scheduling_uri": "Scheduling URL",
"freebusy_uri": "Free/Busy URL",
"context_work": "Work",
"context_private": "Private",
"add_email": "Add email",
+9 -2
View File
@@ -21,11 +21,19 @@ export function getContactDisplayName(contact: ContactCard): string {
return '';
}
function getContactPrimaryEmail(contact: ContactCard): string {
export function getContactPrimaryEmail(contact: ContactCard): string {
if (!contact.emails) return '';
return Object.values(contact.emails)[0]?.address || '';
}
export function getContactPhotoUri(contact: ContactCard): string | undefined {
if (!contact.media) return undefined;
for (const media of Object.values(contact.media)) {
if (media.kind === 'photo' && media.uri) return media.uri;
}
return undefined;
}
interface ContactStore {
contacts: ContactCard[];
addressBooks: AddressBook[];
@@ -409,5 +417,4 @@ export const useContactStore = create<ContactStore>()(
)
);
export { getContactPrimaryEmail };
export type { ContactName };