88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
/**
|
|
* Group timeline entries by reply thread for the mobile flat list.
|
|
*
|
|
* Mirrors the partition step of web's
|
|
* `packages/views/issues/components/issue-detail.tsx:301-339`, but the output
|
|
* shape differs: instead of emitting each reply as its own row (web renders a
|
|
* recursive tree with indentation), we **bundle a comment's entire descendant
|
|
* chain into the parent row** so the renderer can draw one bubble that
|
|
* contains the whole thread. This matches the visual rule the user asked for
|
|
* — no "Replying to" headers, the bubble boundary itself indicates the
|
|
* thread.
|
|
*
|
|
* Two important rules carried over from web:
|
|
*
|
|
* - **Orphan rescue (web #1857):** a reply whose parent is NOT in the
|
|
* loaded timeline gets promoted to top-level instead of disappearing.
|
|
* Without this the entire reply subtree would silently vanish and break
|
|
* the "Counts must agree" parity rule from apps/mobile/CLAUDE.md.
|
|
* - **Reply-to-reply still belongs to the same bundle.** A nested chain
|
|
* (A → B → C) gets flattened into A's row with `replies: [B, C]`. Mobile
|
|
* is a flat list (CLAUDE.md), so we don't preserve depth — just keep
|
|
* them all inside the same bubble in chronological order.
|
|
*
|
|
* Total comment+activity count emitted is identical to the input (just
|
|
* fewer rows because replies are folded into parents). That preserves the
|
|
* "Counts must agree" parity rule against web.
|
|
*/
|
|
import type { TimelineEntry } from "@multica/core/types";
|
|
|
|
export interface TimelineRow {
|
|
entry: TimelineEntry;
|
|
/** Flattened descendant chain in BFS / chronological order. Empty for
|
|
* activity rows and for top-level comments without replies. */
|
|
replies: TimelineEntry[];
|
|
}
|
|
|
|
export function buildTimelineRows(
|
|
entries: TimelineEntry[],
|
|
): TimelineRow[] {
|
|
const commentIds = new Set<string>();
|
|
for (const e of entries) {
|
|
if (e.type === "comment") commentIds.add(e.id);
|
|
}
|
|
|
|
const topLevel: TimelineEntry[] = [];
|
|
const childrenByParent = new Map<string, TimelineEntry[]>();
|
|
|
|
for (const e of entries) {
|
|
if (
|
|
e.type === "comment" &&
|
|
e.parent_id &&
|
|
commentIds.has(e.parent_id)
|
|
) {
|
|
const list = childrenByParent.get(e.parent_id) ?? [];
|
|
list.push(e);
|
|
childrenByParent.set(e.parent_id, list);
|
|
} else {
|
|
// Activity OR top-level comment OR orphan reply (parent not in batch).
|
|
topLevel.push(e);
|
|
}
|
|
}
|
|
|
|
function collectDescendants(parentId: string): TimelineEntry[] {
|
|
// BFS — children are inserted in chronological order during the scan
|
|
// above, so the bundle preserves "first reply first" without extra
|
|
// sorting. Reply-to-reply gets appended after all top-level replies of
|
|
// the same parent.
|
|
const out: TimelineEntry[] = [];
|
|
const queue: string[] = [parentId];
|
|
while (queue.length > 0) {
|
|
const pid = queue.shift()!;
|
|
const kids = childrenByParent.get(pid);
|
|
if (!kids) continue;
|
|
for (const child of kids) {
|
|
out.push(child);
|
|
queue.push(child.id);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
return topLevel.map((entry) => ({
|
|
entry,
|
|
replies:
|
|
entry.type === "comment" ? collectDescendants(entry.id) : [],
|
|
}));
|
|
}
|