Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485// 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<MessageRecord>
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<string, ThreadRow>() 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))}