diff --git a/src/components/Discussion.svelte b/src/components/Discussion.svelte index 2a36e10f5..086dad86a 100644 --- a/src/components/Discussion.svelte +++ b/src/components/Discussion.svelte @@ -14,6 +14,7 @@ import partial from "lodash/partial"; import * as roles from "@app/lib/roles"; + import { mergeTimeline, timelineRuns } from "@app/lib/timelineRuns"; import { authorForNodeId } from "@app/lib/utils"; import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte"; @@ -77,116 +78,10 @@ let focusReply: boolean = $state(false); let commentFormKey = $state(0); - type TimelineEntry = - | { kind: "thread"; key: string; timestamp: number; thread: Thread } - | { - kind: "activity"; - key: string; - timestamp: number; - data: A; - standalone: boolean; - }; - - const timeline: TimelineEntry[] = $derived( - [ - ...commentThreads.map( - thread => - ({ - kind: "thread", - key: thread.root.id, - timestamp: thread.root.edits[0].timestamp, - thread, - }) satisfies TimelineEntry, - ), - ...(activityItems ?? []).map( - item => - ({ - kind: "activity", - key: item.key, - timestamp: item.timestamp, - data: item.data, - standalone: item.standalone === true, - }) satisfies TimelineEntry, - ), - ].sort((a, b) => a.timestamp - b.timestamp), + const runs = $derived( + timelineRuns(mergeTimeline(commentThreads, activityItems ?? []), authorOf), ); - function entryAuthor(entry: TimelineEntry): Author | undefined { - if (entry.kind === "thread") { - return entry.thread.root.author; - } - return authorOf?.(entry.data); - } - - type Run = - | { kind: "thread"; entry: Extract } - | { - kind: "single"; - entry: Extract; - repeatsAuthor: boolean; - } - | { - kind: "group"; - author: Author; - entries: Extract[]; - repeatsAuthor: boolean; - }; - - function runAuthor(run: Run): Author | undefined { - if (run.kind === "thread") return run.entry.thread.root.author; - if (run.kind === "single") return entryAuthor(run.entry); - return run.author; - } - - const runs: Run[] = $derived.by(() => { - const result: Run[] = []; - for (const entry of timeline) { - if (entry.kind === "thread") { - result.push({ kind: "thread", entry }); - continue; - } - const author = entryAuthor(entry); - const last = result[result.length - 1]; - const groupable = - !entry.standalone && - !(last?.kind === "single" && last.entry.standalone); - if ( - groupable && - author && - last && - ((last.kind === "single" && - entryAuthor(last.entry)?.did === author.did) || - (last.kind === "group" && last.author.did === author.did)) - ) { - if (last.kind === "single") { - result[result.length - 1] = { - kind: "group", - author, - entries: [last.entry, entry], - repeatsAuthor: last.repeatsAuthor, - }; - } else { - last.entries.push(entry); - } - } else { - result.push({ kind: "single", entry, repeatsAuthor: false }); - } - } - // A run only names its author when the run before it was someone else's. - // A comment breaks a run, so without this the same person's actions are - // re-attributed on the other side of their own comment. - for (let i = 1; i < result.length; i++) { - const run = result[i]; - if (run.kind === "thread") continue; - const previous = runAuthor(result[i - 1]); - const current = runAuthor(run); - run.repeatsAuthor = Boolean( - previous && current && previous.did === current.did, - ); - } - return result; - }); - $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions cobId; diff --git a/src/lib/timelineRuns.ts b/src/lib/timelineRuns.ts new file mode 100644 index 000000000..970a52271 --- /dev/null +++ b/src/lib/timelineRuns.ts @@ -0,0 +1,116 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +import type { ActivityItem } from "@app/lib/cobActivity"; + +export type TimelineEntry = + | { kind: "thread"; key: string; timestamp: number; thread: Thread } + | { + kind: "activity"; + key: string; + timestamp: number; + data: A; + standalone: boolean; + }; + +type ActivityEntry = Extract, { kind: "activity" }>; + +export type Run = + | { kind: "thread"; entry: Extract, { kind: "thread" }> } + | { kind: "single"; entry: ActivityEntry; repeatsAuthor: boolean } + | { + kind: "group"; + author: Author; + entries: ActivityEntry[]; + repeatsAuthor: boolean; + }; + +/// Comment threads and activity in one list, oldest first. +export function mergeTimeline( + threads: Thread[], + activity: ActivityItem[], +): TimelineEntry[] { + return [ + ...threads.map( + thread => + ({ + kind: "thread", + key: thread.root.id, + timestamp: thread.root.edits[0].timestamp, + thread, + }) satisfies TimelineEntry, + ), + ...activity.map( + item => + ({ + kind: "activity", + key: item.key, + timestamp: item.timestamp, + data: item.data, + standalone: item.standalone === true, + }) satisfies TimelineEntry, + ), + ].sort((a, b) => a.timestamp - b.timestamp); +} + +/// Groups consecutive activity by the same author under one heading. +/// +/// A thread or a standalone entry breaks a run. A run only names its author +/// when the run before it was someone else's (`repeatsAuthor` is false), so the +/// same person's actions aren't re-attributed on the other side of their own +/// comment. +export function timelineRuns( + timeline: TimelineEntry[], + authorOf: (data: A) => Author | undefined = () => undefined, +): Run[] { + const entryAuthor = (entry: TimelineEntry) => + entry.kind === "thread" ? entry.thread.root.author : authorOf(entry.data); + const runAuthor = (run: Run) => { + if (run.kind === "thread") return run.entry.thread.root.author; + if (run.kind === "single") return entryAuthor(run.entry); + return run.author; + }; + + const result: Run[] = []; + for (const entry of timeline) { + if (entry.kind === "thread") { + result.push({ kind: "thread", entry }); + continue; + } + const author = entryAuthor(entry); + const last = result[result.length - 1]; + const groupable = + !entry.standalone && !(last?.kind === "single" && last.entry.standalone); + if ( + groupable && + author && + last && + ((last.kind === "single" && + entryAuthor(last.entry)?.did === author.did) || + (last.kind === "group" && last.author.did === author.did)) + ) { + if (last.kind === "single") { + result[result.length - 1] = { + kind: "group", + author, + entries: [last.entry, entry], + repeatsAuthor: last.repeatsAuthor, + }; + } else { + last.entries.push(entry); + } + } else { + result.push({ kind: "single", entry, repeatsAuthor: false }); + } + } + for (let i = 1; i < result.length; i++) { + const run = result[i]; + if (run.kind === "thread") continue; + const previous = runAuthor(result[i - 1]); + const current = runAuthor(run); + run.repeatsAuthor = Boolean( + previous && current && previous.did === current.did, + ); + } + return result; +} diff --git a/tests/unit/timelineRuns.test.ts b/tests/unit/timelineRuns.test.ts new file mode 100644 index 000000000..c450d02fe --- /dev/null +++ b/tests/unit/timelineRuns.test.ts @@ -0,0 +1,150 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Thread } from "@bindings/cob/thread/Thread"; + +import { describe, expect, test } from "vitest"; + +import type { Run, TimelineEntry } from "@app/lib/timelineRuns"; +import { mergeTimeline, timelineRuns } from "@app/lib/timelineRuns"; + +import { author, comment } from "./support/cobs"; + +const alice = author("alice"); +const bob = author("bob"); + +type Data = { by: Author }; + +function activity(key: string, by: Author, standalone = false) { + return { + kind: "activity" as const, + key, + timestamp: 0, + data: { by }, + standalone, + }; +} + +function threadEntry(key: string, by: Author): TimelineEntry { + return { + kind: "thread", + key, + timestamp: 0, + thread: { + root: comment({ id: key, author: by }), + replies: [], + } as unknown as Thread, + }; +} + +// `a:x` for a single entry, `a:[x,y]` for a group, `thread:x` for a thread; +// `+` marks a run that repeats the previous run's author. +function shape(runs: Run[]): string[] { + return runs.map(run => { + if (run.kind === "thread") return `thread:${run.entry.key}`; + const repeat = run.repeatsAuthor ? "+" : ""; + if (run.kind === "single") return `${repeat}${run.entry.key}`; + return `${repeat}[${run.entries.map(e => e.key).join(",")}]`; + }); +} + +const authorOf = (data: Data) => data.by; + +describe("mergeTimeline", () => { + test("merges threads and activity, oldest first", () => { + const merged = mergeTimeline( + [ + { + root: comment({ id: "t", timestamp: 2 }), + replies: [], + } as unknown as Thread, + ], + [ + { key: "late", timestamp: 3, data: { by: alice } }, + { key: "early", timestamp: 1, data: { by: alice }, standalone: true }, + ], + ); + + expect(merged.map(e => [e.kind, e.key])).toEqual([ + ["activity", "early"], + ["thread", "t"], + ["activity", "late"], + ]); + expect(merged[0]).toMatchObject({ standalone: true }); + expect(merged[2]).toMatchObject({ standalone: false }); + }); +}); + +describe("timelineRuns", () => { + test("groups consecutive activity by the same author", () => { + expect( + shape( + timelineRuns( + [activity("a1", alice), activity("a2", alice), activity("a3", alice)], + authorOf, + ), + ), + ).toEqual(["[a1,a2,a3]"]); + }); + + test("starts a new run for another author", () => { + expect( + shape( + timelineRuns( + [activity("a1", alice), activity("b1", bob), activity("a2", alice)], + authorOf, + ), + ), + ).toEqual(["a1", "b1", "a2"]); + }); + + test("a thread breaks a run, and the author isn't named again after it", () => { + const thread = threadEntry("t", alice); + const runs = timelineRuns( + [ + activity("a1", alice), + thread, + activity("a2", alice), + activity("a3", alice), + ], + authorOf, + ); + + expect(shape(runs)).toEqual(["a1", "thread:t", "+[a2,a3]"]); + expect(runs[1]).toEqual({ kind: "thread", entry: thread }); + }); + + test("standalone entries are never grouped", () => { + expect( + shape( + timelineRuns( + [ + activity("a1", alice), + activity("card", alice, true), + activity("a2", alice), + ], + authorOf, + ), + ), + ).toEqual(["a1", "+card", "+a2"]); + }); + + test("a group keeps the repeat flag of the entry it grew from", () => { + expect( + shape( + timelineRuns( + [ + activity("card", alice, true), + activity("a1", alice), + activity("a2", alice), + ], + authorOf, + ), + ), + ).toEqual(["card", "+[a1,a2]"]); + }); + + test("activity without an author is never grouped", () => { + expect( + shape(timelineRuns([activity("x", alice), activity("y", alice)])), + ).toEqual(["x", "y"]); + }); +});