From ec72d45a46202a9e6c470a4c9f2e6ad712b1fc89 Mon Sep 17 00:00:00 2001
From: cartermp
Date: Mon, 23 Mar 2026 15:34:16 -0700
Subject: [PATCH] threads
---
src/app/(inbox)/thread/[threadId]/loading.tsx | 25 +++
src/app/(inbox)/thread/[threadId]/page.tsx | 34 +++
src/components/EmailListPanel.tsx | 133 ++++++++----
src/components/ThreadView.tsx | 203 ++++++++++++++++++
src/lib/emailList.ts | 69 +++++-
src/lib/jmap.ts | 42 ++++
6 files changed, 461 insertions(+), 45 deletions(-)
create mode 100644 src/app/(inbox)/thread/[threadId]/loading.tsx
create mode 100644 src/app/(inbox)/thread/[threadId]/page.tsx
create mode 100644 src/components/ThreadView.tsx
diff --git a/src/app/(inbox)/thread/[threadId]/loading.tsx b/src/app/(inbox)/thread/[threadId]/loading.tsx
new file mode 100644
index 0000000..6c4e8a2
--- /dev/null
+++ b/src/app/(inbox)/thread/[threadId]/loading.tsx
@@ -0,0 +1,25 @@
+export default function Loading() {
+ return (
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/(inbox)/thread/[threadId]/page.tsx b/src/app/(inbox)/thread/[threadId]/page.tsx
new file mode 100644
index 0000000..c357fab
--- /dev/null
+++ b/src/app/(inbox)/thread/[threadId]/page.tsx
@@ -0,0 +1,34 @@
+import { getSession, getAccountId, getThreadEmails } from "@/lib/jmap";
+import { notFound } from "next/navigation";
+import MobileBackButton from "@/components/MobileBackButton";
+import ThreadView from "@/components/ThreadView";
+
+export const dynamic = "force-dynamic";
+
+interface Props {
+ params: Promise<{ threadId: string }>;
+}
+
+export default async function ThreadPage({ params }: Props) {
+ const { threadId } = await params;
+ const session = await getSession();
+ const accountId = getAccountId(session);
+ const emails = await getThreadEmails(session.apiUrl, accountId, threadId);
+
+ if (!emails.length) return notFound();
+
+ // Subject from the most recent email in the thread
+ const subject = emails[emails.length - 1].subject ?? "(no subject)";
+
+ return (
+
+
+
+
+ {subject}
+
+
+
+
+ );
+}
diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx
index f01d3a8..3e07868 100644
--- a/src/components/EmailListPanel.tsx
+++ b/src/components/EmailListPanel.tsx
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import SenderAvatar from "@/components/SenderAvatar";
import { usePathname, useRouter } from "next/navigation";
import { Email } from "@/lib/types";
-import { isPinned, mergeEmailUpdates } from "@/lib/emailList";
+import { isPinned, mergeEmailUpdates, groupIntoThreads, ThreadSummary } from "@/lib/emailList";
import { formatAddressList, formatDate } from "@/lib/format";
import { markEmailAsRead } from "@/app/(inbox)/email/[id]/actions";
import { loadMoreUnreads, loadMoreReads, searchEmailsAction, bulkMarkAsRead, bulkMarkAsUnread, bulkSetPin, bulkMoveToMailbox } from "@/app/(inbox)/actions";
@@ -101,8 +101,8 @@ export default function EmailListPanel({
}: Props) {
const pathname = usePathname();
const router = useRouter();
- const selectedId = pathname.startsWith("/email/")
- ? pathname.slice("/email/".length)
+ const selectedThreadId = pathname.startsWith("/thread/")
+ ? pathname.slice("/thread/".length)
: undefined;
const view: View = pathname.startsWith("/drafts") ? "drafts" : "inbox";
@@ -267,6 +267,13 @@ export default function EmailListPanel({
return base.filter((e) => !archivedIds.has(e.id));
}, [isInSearchMode, searchResults, allInboxEmails, archivedIds]);
+ // Group emails into thread summaries for the list view.
+ // Search results are also grouped so every row navigates to the thread view.
+ const visibleThreads = useMemo(
+ () => groupIntoThreads(visibleEmails),
+ [visibleEmails]
+ );
+
// -------------------------------------------------------------------------
// Pagination
// -------------------------------------------------------------------------
@@ -277,7 +284,9 @@ export default function EmailListPanel({
const hasMore = !isInSearchMode && (hasMoreUnreads || hasMoreReads);
const unreadCount = unreadTotal;
- const pinnedCount = isInSearchMode ? 0 : pinnedEmails.length;
+ const pinnedThreadCount = isInSearchMode
+ ? 0
+ : visibleThreads.filter((t) => t.isPinned).length;
// -------------------------------------------------------------------------
// Debounced search
@@ -298,18 +307,24 @@ export default function EmailListPanel({
}, [searchQuery]);
// -------------------------------------------------------------------------
- // Auto-mark as read when navigating to an email
+ // Auto-mark as read when navigating to a thread
// -------------------------------------------------------------------------
useEffect(() => {
- if (!selectedId) return;
- const email = visibleEmails.find((e) => e.id === selectedId);
- if (!email) return;
- const alreadyRead = !!email.keywords?.["$seen"] || clientReadIds.has(selectedId);
- if (!alreadyRead) {
- setClientReadIds((prev) => new Set([...prev, selectedId]));
- markEmailAsRead(selectedId);
+ if (!selectedThreadId) return;
+ const thread = visibleThreads.find((t) => t.threadId === selectedThreadId);
+ if (!thread) return;
+ const unreadIds = thread.allEmails
+ .filter(
+ (e) =>
+ (!e.keywords?.["$seen"] || clientUnreadIds.has(e.id)) &&
+ !clientReadIds.has(e.id)
+ )
+ .map((e) => e.id);
+ if (unreadIds.length > 0) {
+ setClientReadIds((prev) => new Set([...prev, ...unreadIds]));
+ bulkMarkAsRead(unreadIds);
}
- }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [selectedThreadId]); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// Load more
@@ -632,21 +647,31 @@ export default function EmailListPanel({
)}
{!isSearching &&
- visibleEmails.map((email, idx) => {
- const isRouteSelected = email.id === selectedId;
- const isChecked = selectedIds.has(email.id);
- const pinned = isPinned(email);
+ visibleThreads.map((thread, idx) => {
+ const { latestEmail, senders } = thread;
+ const isRouteSelected = thread.threadId === selectedThreadId;
+ const isChecked = thread.allEmails.some((e) => selectedIds.has(e.id));
const isUnread =
- (clientUnreadIds.has(email.id) || !email.keywords?.["$seen"]) &&
- !clientReadIds.has(email.id) &&
- email.id !== selectedId;
-
- const showPinnedDivider = !isInSearchMode && pinnedCount > 0 && idx === 0;
+ thread.allEmails.some(
+ (e) =>
+ (clientUnreadIds.has(e.id) || !e.keywords?.["$seen"]) &&
+ !clientReadIds.has(e.id)
+ ) && thread.threadId !== selectedThreadId;
+
+ const showPinnedDivider =
+ !isInSearchMode && pinnedThreadCount > 0 && idx === 0;
const showRestDivider =
- !isInSearchMode && pinnedCount > 0 && idx === pinnedCount;
+ !isInSearchMode && pinnedThreadCount > 0 && idx === pinnedThreadCount;
+
+ // Sender display: comma-separated unique names, truncated to 3
+ const senderLabel =
+ senders
+ .slice(0, 3)
+ .map((s) => s.name?.split(" ")[0] ?? s.email.split("@")[0])
+ .join(", ") || "(no sender)";
return (
-
+
{showPinnedDivider && (
Pinned
@@ -658,7 +683,7 @@ export default function EmailListPanel({
)}
- {/* Row */}
+ {/* Thread row */}
startLongPress(e, email.id)}
+ onPointerDown={(e) => startLongPress(e, latestEmail.id)}
onPointerMove={checkLongPressMove}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
>
- {/* Unread / pin dot — fixed-width gutter so avatar never shifts */}
+ {/* Unread / pin indicator */}
{!selectionMode && isUnread && (
)}
- {!selectionMode && pinned && !isUnread && (
+ {!selectionMode && thread.isPinned && !isUnread && (
)}
- {/* Avatar — morphs to checkbox on hover (desktop) or in selection mode */}
+ {/* Avatar — morphs to checkbox on hover / in selection mode */}
{
if (!selectionMode) setSelectionMode(true);
- toggleSelection(email.id);
+ // Toggle all emails in this thread
+ const allIds = thread.allEmails.map((e) => e.id);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ const allChecked = allIds.every((id) => prev.has(id));
+ allIds.forEach((id) =>
+ allChecked ? next.delete(id) : next.add(id)
+ );
+ return next;
+ });
}}
>
- {/* Avatar */}
-
+
- {/* Checkbox circle */}
{
if (selectionMode) {
e.preventDefault();
- toggleSelection(email.id);
+ const allIds = thread.allEmails.map((em) => em.id);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ const allChecked = allIds.every((id) => prev.has(id));
+ allIds.forEach((id) =>
+ allChecked ? next.delete(id) : next.add(id)
+ );
+ return next;
+ });
return;
}
if (longPressActive.current) {
@@ -729,7 +769,7 @@ export default function EmailListPanel({
}}
className="flex flex-col gap-0.5 flex-1 min-w-0"
>
- {/* Sender + date */}
+ {/* Senders + date */}
- {formatAddressList(email.from) || "(no sender)"}
+ {senderLabel}
- {formatDate(email.receivedAt)}
+ {formatDate(latestEmail.receivedAt)}
- {/* Subject */}
-
+ {/* Subject + thread count */}
+
- {email.subject || "(no subject)"}
+ {latestEmail.subject || "(no subject)"}
- {pinned && !selectionMode && (
+ {thread.count > 1 && (
+
+ {thread.count}
+
+ )}
+ {thread.isPinned && !selectionMode && (
)}
{/* Preview */}
- {email.preview && (
+ {latestEmail.preview && (
- {email.preview}
+ {latestEmail.preview}
)}
diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx
new file mode 100644
index 0000000..79c4cad
--- /dev/null
+++ b/src/components/ThreadView.tsx
@@ -0,0 +1,203 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import SenderAvatar from "@/components/SenderAvatar";
+import EmailBody from "@/components/EmailBody";
+import { Email } from "@/lib/types";
+import { formatAddressList, formatFullDate } from "@/lib/format";
+
+// ---------------------------------------------------------------------------
+// Body resolution (mirrors email/[id]/page.tsx logic)
+// ---------------------------------------------------------------------------
+
+function resolveBody(email: Email): { body: string; type: "html" | "text" } | null {
+ if (email.htmlBody?.length > 0) {
+ const part = email.htmlBody[0];
+ if (part.partId && email.bodyValues?.[part.partId]) {
+ return { body: email.bodyValues[part.partId].value, type: "html" };
+ }
+ }
+ if (email.textBody?.length > 0) {
+ const part = email.textBody[0];
+ if (part.partId && email.bodyValues?.[part.partId]) {
+ const raw = email.bodyValues[part.partId].value;
+ // Skip text/plain parts that are actually raw HTML source
+ if (!/^\s* void;
+}
+
+function EmailStackItem({ email, expanded, onToggle }: ItemProps) {
+ const isUnread = !email.keywords?.["$seen"];
+ const resolved = resolveBody(email);
+ const hasMultipleRecipients =
+ (email.to?.length ?? 0) + (email.cc?.length ?? 0) > 1;
+
+ return (
+
+ {/* Header — always visible, tap to expand/collapse */}
+
+
+ {/* Expanded content */}
+ {expanded && (
+
+ {/* To / Cc metadata */}
+
+ {email.to && email.to.length > 0 && (
+
+ To
+ {formatAddressList(email.to)}
+
+ )}
+ {email.cc && email.cc.length > 0 && (
+
+ Cc
+ {formatAddressList(email.cc)}
+
+ )}
+
+
+ {/* Action buttons */}
+
+
+ Reply
+
+ {hasMultipleRecipients && (
+
+ Reply All
+
+ )}
+
+ Forward
+
+
+
+ {/* Body */}
+ {resolved ? (
+
+ ) : (
+
+ No content.
+
+ )}
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Thread view — the full stacked list
+// ---------------------------------------------------------------------------
+
+interface Props {
+ emails: Email[]; // sorted oldest → newest
+}
+
+export default function ThreadView({ emails }: Props) {
+ // Start with the most recent email expanded
+ const lastId = emails[emails.length - 1]?.id;
+ const [expandedIds, setExpandedIds] = useState
>(
+ () => new Set(lastId ? [lastId] : [])
+ );
+
+ function toggle(id: string) {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ }
+
+ return (
+
+ {emails.map((email) => (
+ toggle(email.id)}
+ />
+ ))}
+
+ );
+}
diff --git a/src/lib/emailList.ts b/src/lib/emailList.ts
index ed3e0f3..e83d11c 100644
--- a/src/lib/emailList.ts
+++ b/src/lib/emailList.ts
@@ -1,4 +1,71 @@
-import { Email } from "./types";
+import { Email, EmailAddress } from "./types";
+
+// ---------------------------------------------------------------------------
+// Thread grouping
+// ---------------------------------------------------------------------------
+
+export interface ThreadSummary {
+ threadId: string;
+ /** The most recently received email in the thread. */
+ latestEmail: Email;
+ /** All emails we have for this thread, in the order they appeared in the source list. */
+ allEmails: Email[];
+ count: number;
+ isUnread: boolean;
+ isPinned: boolean;
+ /** Unique senders across all thread emails, most-recent-first. */
+ senders: EmailAddress[];
+}
+
+/**
+ * Group a flat email list into per-thread summaries.
+ *
+ * Thread order in the result matches the position of each thread's
+ * first occurrence in the input, so caller-controlled sort order
+ * (pinned → unread → read) is preserved.
+ */
+export function groupIntoThreads(emails: Email[]): ThreadSummary[] {
+ const order: string[] = [];
+ const groups = new Map();
+
+ for (const email of emails) {
+ if (!groups.has(email.threadId)) {
+ groups.set(email.threadId, []);
+ order.push(email.threadId);
+ }
+ groups.get(email.threadId)!.push(email);
+ }
+
+ return order.map((threadId) => {
+ const group = groups.get(threadId)!;
+ const latestEmail = group.reduce((a, b) =>
+ a.receivedAt > b.receivedAt ? a : b
+ );
+
+ const seenAddrs = new Set();
+ const senders: EmailAddress[] = [];
+ for (const e of [...group].sort((a, b) =>
+ b.receivedAt.localeCompare(a.receivedAt)
+ )) {
+ for (const addr of e.from ?? []) {
+ if (!seenAddrs.has(addr.email)) {
+ seenAddrs.add(addr.email);
+ senders.push(addr);
+ }
+ }
+ }
+
+ return {
+ threadId,
+ latestEmail,
+ allEmails: group,
+ count: group.length,
+ isUnread: group.some((e) => !e.keywords?.["$seen"]),
+ isPinned: group.some((e) => !!e.keywords?.["$flagged"]),
+ senders,
+ };
+ });
+}
/** An email is pinned if it has the $flagged keyword. */
export function isPinned(email: Email): boolean {
diff --git a/src/lib/jmap.ts b/src/lib/jmap.ts
index cecf4ef..22629f2 100644
--- a/src/lib/jmap.ts
+++ b/src/lib/jmap.ts
@@ -290,6 +290,48 @@ export async function getEmail(
return list?.[0] ?? null;
}
+/**
+ * Fetch every email in a thread, with full body values, sorted oldest-first.
+ * Uses a two-step JMAP batch: Thread/get → Email/get back-reference.
+ */
+export async function getThreadEmails(
+ apiUrl: string,
+ accountId: string,
+ threadId: string
+): Promise {
+ const data = await jmapCall(apiUrl, [
+ ["Thread/get", { accountId, ids: [threadId] }, "t"],
+ [
+ "Email/get",
+ {
+ accountId,
+ "#ids": {
+ resultOf: "t",
+ name: "Thread/get",
+ path: "/list/*/emailIds",
+ },
+ properties: [
+ "id", "messageId", "threadId", "mailboxIds",
+ "subject", "from", "to", "cc", "replyTo",
+ "receivedAt", "preview", "keywords",
+ "hasAttachment", "size",
+ "htmlBody", "textBody", "attachments", "bodyValues",
+ ],
+ fetchHTMLBodyValues: true,
+ fetchTextBodyValues: true,
+ maxBodyValueBytes: 1024 * 1024,
+ },
+ "e",
+ ],
+ ]);
+ const emails: Email[] =
+ (data.methodResponses.find(([n]) => n === "Email/get")?.[1] as { list?: Email[] })
+ ?.list ?? [];
+ return emails.sort(
+ (a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime()
+ );
+}
+
export async function getIdentities(
apiUrl: string,
accountId: string
--
2.51.2