"use client"; import { useEffect, useState, useTransition } from "react"; import Link from "next/link"; import { CalendarIcon, ChevronDownIcon, ExternalLinkIcon, MailIcon, RefreshCwIcon, VideoIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { correspondenceAction, emailThreadAction } from "@/server/actions"; import type { CorrespondenceSubject } from "@/lib/data/accounts"; import { agoDay, cn } from "@/lib/utils"; import { useViewerZone } from "@/components/viewer-zone"; import { formatIn } from "@/lib/time"; /** * The threads and meetings in somebody's own mail and calendar accounts that * touch one record — a contact, a company, an application, a resume. * * Fetched after the page paints, never during it: this is a round trip to * Google, and a contact page should not wait on Gmail to show a phone * number. Nothing shown here is stored on the instance, which is why there * is a refresh button and no "last synced" — every open is live. */ type Loaded = Extract>, { ok: true }>["correspondence"]; type ThreadMessages = Extract>, { ok: true }>["thread"]["messages"]; type Thread = Loaded["mail"] extends (infer T)[] | null ? T : never; type Event = Loaded["calendar"] extends (infer T)[] | null ? T : never; /** What the page already knows about the connection, so this can render the right empty state. */ export type CorrespondenceAccess = { mail: boolean; calendar: boolean } | null; export function CorrespondenceCard({ subject, access, /** Where the card is: inside a Card on a page, or bare inside a sheet. */ bare = false, }: { subject: CorrespondenceSubject; access: CorrespondenceAccess; bare?: boolean; }) { const [state, setState] = useState< | { status: "idle" } | { status: "loading" } | { status: "error"; error: string; notConnected: boolean } | { status: "ready"; data: Loaded } >({ status: "idle" }); const [pending, startTransition] = useTransition(); const load = () => { setState((current) => (current.status === "ready" ? current : { status: "loading" })); startTransition(async () => { const result = await correspondenceAction(subject); if (result.ok) setState({ status: "ready", data: result.correspondence }); else setState({ status: "error", error: result.error, notConnected: result.notConnected }); }); }; useEffect(() => { if (access) load(); // The subject is the identity of this card; the page remounts it when it changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [subject.kind, subject.id, access?.mail, access?.calendar]); const body = !access ? ( ) : state.status === "error" ? (

{state.error}{" "} {state.notConnected && ( Open Settings → Connections )}

) : state.status === "ready" ? ( ) : (
); if (bare) return
{body}
; return ( Email & calendar {state.status === "ready" && ( {(state.data.mail?.length ?? 0) + (state.data.calendar?.length ?? 0) || ""} )} {access && ( )} {body} ); } function NotConnected() { return (

Connect your mail and calendar — Google, Microsoft 365, or any IMAP and CalDAV provider — and every thread and meeting with these people shows up here, read live. Nothing is copied to this server.

); } function LoadedView({ data, access }: { data: Loaded; access: NonNullable }) { const mailCount = data.mail?.length ?? 0; const calendarCount = data.calendar?.length ?? 0; const [tab, setTab] = useState<"mail" | "calendar">( mailCount === 0 && calendarCount > 0 ? "calendar" : "mail", ); return (
{data.notes.map((note) => (

{note}

))} setTab(value as "mail" | "calendar")}> Email {mailCount} Calendar {calendarCount} {!access.mail ? ( None of your connected accounts provides mail.{" "} Connections ) : data.mail === null ? ( {data.warnings.find((w) => w.startsWith("Mail:")) ?? "Mail did not answer."} ) : data.mail.length === 0 ? ( No threads in the last year match. ) : (
    {data.mail.map((thread) => ( ))}
)}
{!access.calendar ? ( None of your connected accounts provides a calendar.{" "} Connections ) : data.calendar === null ? ( {data.warnings.find((w) => w.startsWith("Calendar:")) ?? "Calendar did not answer."} ) : data.calendar.length === 0 ? ( No meetings with anyone here, past or upcoming. ) : (
    {data.calendar.map((event) => ( ))}
)}
); } function Hint({ children }: { children: React.ReactNode }) { return

{children}

; } function ThreadRow({ thread }: { thread: Thread }) { const zone = useViewerZone(); const [open, setOpen] = useState(false); const [messages, setMessages] = useState< | { status: "idle" } | { status: "loading" } | { status: "error"; error: string } | { status: "ready"; messages: ThreadMessages } >({ status: "idle" }); const [, startTransition] = useTransition(); const toggle = () => { const next = !open; setOpen(next); if (next && messages.status === "idle") { setMessages({ status: "loading" }); startTransition(async () => { const result = await emailThreadAction(thread.id); if (result.ok) setMessages({ status: "ready", messages: result.thread.messages }); else setMessages({ status: "error", error: result.error }); }); } }; const who = thread.lastFrom?.name || thread.lastFrom?.email || ""; return (
  • {open && (
    {messages.status === "loading" && (
    )} {messages.status === "error" && (

    {messages.error}

    )} {messages.status === "ready" && messages.messages.map((message) => (
    {message.from?.name || message.from?.email} to {message.to.map((p) => p.name || p.email).join(", ") || "—"} {formatIn(new Date(message.date), zone, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", })}

    {message.body || "(no text)"}

    ))} Open in Gmail
    )}
  • ); } function EventRow({ event }: { event: Event }) { const zone = useViewerZone(); const start = new Date(event.start); const end = new Date(event.end); const past = end.getTime() < Date.now(); // A meeting reads in the hours of whoever is looking at it. On a UTC host // this used to tell somebody in Chicago their 2pm call was at 8pm. const day = { weekday: "short", month: "short", day: "numeric" } as const; const time = { hour: "numeric", minute: "2-digit" } as const; const when = event.allDay ? formatIn(start, zone, day) : `${formatIn(start, zone, day)} · ${formatIn(start, zone, time)}–${formatIn(end, zone, time)}`; const others = event.attendees.filter((attendee) => !attendee.self); return (
  • {event.title} {when}
    {others.length > 0 ? others.map((attendee) => attendee.name || attendee.email).join(", ") : event.organizer?.name || event.organizer?.email || ""} {event.location && · {event.location}}
    {event.meetingUrl && !past && ( Join )} {event.url && ( Open in Calendar )}
  • ); }