Move discussion timeline grouping into lib

The rules for grouping a person's consecutive activity and when to repeat
their name lived in an untested loop in the component.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 18:20:24 +02:00
parent ca3b059a62
commit 6e1176ffcf
3 changed files with 269 additions and 108 deletions
+3 -108
View File
@@ -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<TimelineEntry, { kind: "thread" }> }
| {
kind: "single";
entry: Extract<TimelineEntry, { kind: "activity" }>;
repeatsAuthor: boolean;
}
| {
kind: "group";
author: Author;
entries: Extract<TimelineEntry, { kind: "activity" }>[];
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;
+116
View File
@@ -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<A> =
| { kind: "thread"; key: string; timestamp: number; thread: Thread }
| {
kind: "activity";
key: string;
timestamp: number;
data: A;
standalone: boolean;
};
type ActivityEntry<A> = Extract<TimelineEntry<A>, { kind: "activity" }>;
export type Run<A> =
| { kind: "thread"; entry: Extract<TimelineEntry<A>, { kind: "thread" }> }
| { kind: "single"; entry: ActivityEntry<A>; repeatsAuthor: boolean }
| {
kind: "group";
author: Author;
entries: ActivityEntry<A>[];
repeatsAuthor: boolean;
};
/// Comment threads and activity in one list, oldest first.
export function mergeTimeline<A>(
threads: Thread[],
activity: ActivityItem<A>[],
): TimelineEntry<A>[] {
return [
...threads.map(
thread =>
({
kind: "thread",
key: thread.root.id,
timestamp: thread.root.edits[0].timestamp,
thread,
}) satisfies TimelineEntry<A>,
),
...activity.map(
item =>
({
kind: "activity",
key: item.key,
timestamp: item.timestamp,
data: item.data,
standalone: item.standalone === true,
}) satisfies TimelineEntry<A>,
),
].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<A>(
timeline: TimelineEntry<A>[],
authorOf: (data: A) => Author | undefined = () => undefined,
): Run<A>[] {
const entryAuthor = (entry: TimelineEntry<A>) =>
entry.kind === "thread" ? entry.thread.root.author : authorOf(entry.data);
const runAuthor = (run: Run<A>) => {
if (run.kind === "thread") return run.entry.thread.root.author;
if (run.kind === "single") return entryAuthor(run.entry);
return run.author;
};
const result: Run<A>[] = [];
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;
}