// The thread fold. // // `materialize()` gives a target's messages as one flat, trusted list. A conversation is not a flat // list — it is things said and things said back — and `parent` is the field that says which is // which. Turning the one into the other is this file. // // It lives in core for the same reason `timeline()` does: two consumers read the same thread (a // browser tab drawing it, `radial message list` printing it), and "what answers what" must not come // to mean two different things in two places. It is a pure function over a view's messages, so both // compute the identical exchange. import type { MessageRecord } from './generated/records.js' import type { IndexedRecord } from './materializer.js' export type ThreadMessage = IndexedRecord const byCreatedAt = (left: ThreadMessage, right: ThreadMessage): number => left.value.createdAt.localeCompare(right.value.createdAt) || left.uri.localeCompare(right.uri) export interface ThreadReply { message: ThreadMessage /** * Set when this reply answers another REPLY rather than the row's own message. The thread nests one * level and no further (a conversation is not a tree a person can read), so a deeper chain is * flattened onto the row and says who it was actually answering. */ inReplyTo?: ThreadMessage } export interface ThreadRow { message: ThreadMessage replies: ThreadReply[] } /** * The thread as an exchange rather than a list: top-level messages, each carrying its replies. * * A message whose `parent` names something this view does not hold (a reply to a message on another * target, say) is shown as a top-level row rather than dropped — the view's job is to show what was * said, and hiding a message because its parent is elsewhere would be the reader's loss. * * Structurally typed on `messages` alone so a `GoalView` and a `ProjectView` both pass: a thread is * the messages filed under a target, and nothing here needs the rest of the view. */ export function threadRows(view: { messages: ThreadMessage[] }): ThreadRow[] { const byUri = new Map(view.messages.map((message) => [message.uri, message])) const rows = new Map() const ordered = [...view.messages].sort(byCreatedAt) /** The top-level ancestor of a message within this view; itself when it has none. Cycle-safe. */ const ancestorOf = (message: ThreadMessage): ThreadMessage => { const seen = new Set([message.uri]) let cursor = message while (cursor.value.parent) { const parent = byUri.get(cursor.value.parent.uri) if (!parent || seen.has(parent.uri)) break seen.add(parent.uri) cursor = parent } return cursor } for (const message of ordered) { const ancestor = ancestorOf(message) if (ancestor.uri === message.uri) { rows.set(message.uri, { message, replies: [] }) continue } const row = rows.get(ancestor.uri) if (!row) { // The ancestor sorts after this reply only if timestamps disagree with threading; keep the // reply visible as its own row rather than losing it. rows.set(message.uri, { message, replies: [] }) continue } const parent = message.value.parent ? byUri.get(message.value.parent.uri) : undefined row.replies.push({ message, ...(parent && parent.uri !== row.message.uri ? { inReplyTo: parent } : {}), }) } return [...rows.values()].sort((left, right) => byCreatedAt(left.message, right.message)) }