/** * Issue threads, read from the statements in every voice's own repo. * * An issue is a record in its filer's repo, a reply is a record naming its * parent, and the repository owner's disposition record is what a reader * shows as the issue's state. Nothing here is a mutable field on a shared * object: closed is a statement the owner signed, displayed as such, and the * filer's disagreement can sit beside it. * * These are the parts of that reading that touch no network. * * Workers-clean: no node imports. */ /** * The state each disposition names. A Map rather than an object, so a * statement carrying `constructor` reads as the comment it is. * @type {Map} */ const STATE_OF = new Map([ ['resolved', 'resolved'], ['declined', 'declined'], ['reopened', 'open'], ]); /** * The state an issue thread displays, which is the repository owner's latest * word on it and nobody else's. Anyone may publish a disposition; anyone may * read it; only the owner's names the state, because the repository is what * the issue is about and the owner is who the repository answers to. * * The latest wins, so every word the owner says is reversible by saying * another one. `reopened` is how they get back to the state a thread starts * in, which no amount of `resolved` and `declined` can reach. * * The statement that names the state comes back with it, because when the * owner said it is a fact a row wants and only this read knows which of the * statements it was. * @param {Array>} thread - every statement in one thread * @param {string} ownerDid * @returns {{state: 'open'|'resolved'|'declined', * said?: Record}} */ export function issueState(thread, ownerDid) { let latest = null; for (const statement of thread) { if (statement.by?.did !== ownerDid) continue; if (!STATE_OF.has(statement.disposition)) continue; if (!latest || String(statement.createdAt) > String(latest.createdAt)) { latest = statement; } } if (!latest) return { state: 'open' }; return { state: STATE_OF.get(latest.disposition) ?? 'open', said: latest }; } /** * Group statements into threads: each opener with everything that answers * it, however deep the reply chain, ordered oldest first inside a thread and * newest activity first across threads. * @param {Array>} statements * @param {string} ownerDid * @returns {Array<{root: Record, thread: Array>, * state: ReturnType, latest: string}>} */ export function issueThreads(statements, ownerDid) { const byUri = new Map(statements.filter((s) => s.uri).map((s) => [s.uri, s])); /** @param {Record} statement */ const rootOf = (statement) => { let here = statement; for (let step = 0; step < 20; step++) { const parent = here.replyTo?.uri && byUri.get(here.replyTo.uri); if (!parent) return here; here = parent; } return here; }; /** @type {Map, Array>>} */ const groups = new Map(); for (const statement of statements) { const root = rootOf(statement); groups.set(root, [...(groups.get(root) ?? []), statement]); } const threads = [...groups.entries()].map(([root, thread]) => { thread.sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')), ); return { root, thread, state: issueState(thread, ownerDid), latest: String(thread[thread.length - 1]?.createdAt ?? ''), }; }); return threads.sort((a, b) => b.latest.localeCompare(a.latest)); }