mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Data> {
|
||||
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<Data>[]): 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<Data>(
|
||||
[
|
||||
{
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user