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