diff --git a/components/contacts/contact-activity.tsx b/components/contacts/contact-activity.tsx new file mode 100644 index 00000000..2a4b9a75 --- /dev/null +++ b/components/contacts/contact-activity.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations, useFormatter } from "next-intl"; +import { Mail, CalendarDays, Loader2 } from "lucide-react"; +import { useRouter } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { cn } from "@/lib/utils"; +import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types"; + +const EMAIL_LIMIT = 5; +const EVENT_LIMIT = 5; +const EVENT_LOOKAHEAD_DAYS = 365; + +interface ContactActivityProps { + contact: ContactCard; +} + +function getContactEmails(contact: ContactCard): string[] { + if (!contact.emails) return []; + const seen = new Set(); + const result: string[] = []; + for (const e of Object.values(contact.emails)) { + const addr = e.address?.trim().toLowerCase(); + if (addr && !seen.has(addr)) { + seen.add(addr); + result.push(addr); + } + } + return result; +} + +function buildEmailFilter(addresses: string[]): Record { + const conditions: Record[] = []; + for (const addr of addresses) { + conditions.push({ from: addr }); + conditions.push({ to: addr }); + } + if (conditions.length === 1) return conditions[0]; + return { operator: "OR", conditions }; +} + +function eventInvolvesContact(event: CalendarEvent, addresses: Set): boolean { + if (!event.participants) return false; + for (const p of Object.values(event.participants)) { + const email = p.email?.trim().toLowerCase(); + if (email && addresses.has(email)) return true; + if (p.sendTo) { + for (const target of Object.values(p.sendTo)) { + const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null; + if (m && addresses.has(m[1].trim().toLowerCase())) return true; + } + } + } + if (event.organizerCalendarAddress) { + const m = event.organizerCalendarAddress.match(/mailto:(.+)/i); + const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase(); + if (addresses.has(org)) return true; + } + return false; +} + +export function ContactActivity({ contact }: ContactActivityProps) { + const t = useTranslations("contacts.activity"); + const format = useFormatter(); + const router = useRouter(); + const client = useAuthStore((s) => s.client); + const selectEmail = useEmailStore((s) => s.selectEmail); + const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId); + const setSelectedDate = useCalendarStore((s) => s.setSelectedDate); + + const [emails, setEmails] = useState(null); + const [events, setEvents] = useState(null); + const [emailsLoading, setEmailsLoading] = useState(false); + const [eventsLoading, setEventsLoading] = useState(false); + const [emailsError, setEmailsError] = useState(false); + const [eventsError, setEventsError] = useState(false); + + const addresses = getContactEmails(contact); + const addressKey = addresses.join(","); + + useEffect(() => { + if (!client || addresses.length === 0) { + setEmails([]); + return; + } + let cancelled = false; + setEmailsLoading(true); + setEmailsError(false); + client + .advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0) + .then((res) => { + if (cancelled) return; + setEmails(res.emails); + }) + .catch(() => { + if (cancelled) return; + setEmailsError(true); + setEmails([]); + }) + .finally(() => { + if (!cancelled) setEmailsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!client || addresses.length === 0) { + setEvents([]); + return; + } + let cancelled = false; + setEventsLoading(true); + setEventsError(false); + const now = new Date(); + const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000); + const addrSet = new Set(addresses); + client + .queryAllCalendarEvents( + { after: now.toISOString(), before: before.toISOString() }, + [{ property: "start", isAscending: true }], + 500, + ) + .then((all) => { + if (cancelled) return; + const matching = all + .filter((e) => eventInvolvesContact(e, addrSet)) + .sort((a, b) => a.start.localeCompare(b.start)) + .slice(0, EVENT_LIMIT); + setEvents(matching); + }) + .catch(() => { + if (cancelled) return; + setEventsError(true); + setEvents([]); + }) + .finally(() => { + if (!cancelled) setEventsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (addresses.length === 0) return null; + + const handleOpenEmail = (email: Email) => { + selectEmail(email); + router.push("/mail"); + }; + + const handleOpenEvent = (event: CalendarEvent) => { + const start = new Date(event.start); + if (!isNaN(start.getTime())) setSelectedDate(start); + setSelectedEventId(event.id); + router.push("/calendar"); + }; + + const formatEmailDate = (dateStr: string) => { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + const now = new Date(); + const sameYear = d.getFullYear() === now.getFullYear(); + return format.dateTime(d, sameYear + ? { month: "short", day: "numeric" } + : { year: "numeric", month: "short", day: "numeric" }); + }; + + const formatEventDate = (event: CalendarEvent) => { + const d = new Date(event.start); + if (isNaN(d.getTime())) return event.start; + if (event.showWithoutTime) { + return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" }); + } + return format.dateTime(d, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + }; + + return ( + <> + + {emailsLoading ? ( + + ) : emailsError ? ( +

{t("load_failed")}

+ ) : !emails || emails.length === 0 ? ( +

{t("no_emails")}

+ ) : ( + emails.map((email) => ( + + )) + )} +
+ + + {eventsLoading ? ( + + ) : eventsError ? ( +

{t("load_failed")}

+ ) : !events || events.length === 0 ? ( +

{t("no_events")}

+ ) : ( + events.map((event) => ( + + )) + )} +
+ + ); +} + +function ActivitySection({ + icon: Icon, + title, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+
+
{children}
+
+ ); +} + +function LoadingRow() { + return ( +
+ +
+ ); +} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index ffdd8e28..f58f9902 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { ContactActivity } from "./contact-activity"; import { useSmimeStore } from "@/stores/smime-store"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; import type { CertificateInfo } from "@/lib/smime/types"; @@ -204,6 +205,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
+ + {/* Contact info */} {emails.length > 0 && (
diff --git a/locales/de/common.json b/locales/de/common.json index 21a595fe..7640c099 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Zertifikat importiert", "cert_import_failed": "Import des Zertifikats fehlgeschlagen" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Neuer Kontakt", "edit_title": "Kontakt bearbeiten", diff --git a/locales/en/common.json b/locales/en/common.json index 157e0506..88951ce0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "Scheduling URL", "freebusy_uri": "Free/Busy URL" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "New Contact", "edit_title": "Edit Contact", diff --git a/locales/es/common.json b/locales/es/common.json index 37f993b5..85712d86 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Certificado importado", "cert_import_failed": "Error al importar el certificado" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Nuevo contacto", "edit_title": "Editar contacto", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7ba16d47..7afefb65 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Certificat importé", "cert_import_failed": "Échec de l'import du certificat" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Nouveau contact", "edit_title": "Modifier le contact", diff --git a/locales/it/common.json b/locales/it/common.json index 1a6f7276..d2eee9b1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Certificato importato", "cert_import_failed": "Importazione del certificato non riuscita" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Nuovo contatto", "edit_title": "Modifica contatto", diff --git a/locales/ja/common.json b/locales/ja/common.json index 9e3c0286..e3f0370c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "証明書をインポートしました", "cert_import_failed": "証明書のインポートに失敗しました" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "新しい連絡先", "edit_title": "連絡先を編集", diff --git a/locales/ko/common.json b/locales/ko/common.json index 74295523..b89fdf01 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "스케줄링 URL", "freebusy_uri": "Free/Busy URL" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "새 연락처", "edit_title": "연락처 수정", diff --git a/locales/lv/common.json b/locales/lv/common.json index 987c6632..070e79db 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1812,6 +1812,15 @@ "scheduling_uri": "Plānošanas URL", "freebusy_uri": "Pieejamības URL" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Jauns kontakts", "edit_title": "Rediģēt kontaktu", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b6f09e9..9db32a32 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Certificaat geïmporteerd", "cert_import_failed": "Importeren van certificaat mislukt" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Nieuw contact", "edit_title": "Contact bewerken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7dc7b153..018589c6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "Adres URL planowania", "freebusy_uri": "Adres URL wolny/zajęty" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Nowy kontakt", "edit_title": "Edytuj kontakt", diff --git a/locales/pt/common.json b/locales/pt/common.json index c6c9c494..820f7d59 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1816,6 +1816,15 @@ "cert_imported": "Certificado importado", "cert_import_failed": "Falha ao importar o certificado" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Novo contato", "edit_title": "Editar contato", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3b20f3b1..596dc97b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "URL планирования", "freebusy_uri": "URL доступности" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Новый контакт", "edit_title": "Редактировать контакт", diff --git a/locales/uk/common.json b/locales/uk/common.json index 0ba4ef08..32356e0b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "URL-адреса планування", "freebusy_uri": "Вільний/зайнятий URL" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "Новий контакт", "edit_title": "Редагувати контакт", diff --git a/locales/zh/common.json b/locales/zh/common.json index 8909df13..208af976 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1816,6 +1816,15 @@ "scheduling_uri": "调度 URL", "freebusy_uri": "空闲/忙碌 URL" }, + "activity": { + "recent_emails": "Recent Emails", + "upcoming_events": "Upcoming Events", + "no_emails": "No recent emails", + "no_events": "No upcoming events", + "no_subject": "(No subject)", + "no_title": "(No title)", + "load_failed": "Failed to load" + }, "form": { "create_title": "新联系人", "edit_title": "编辑联系人",