Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions desktop/src/features/channels/lib/threadAttention.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
areThreadAttentionRowsEqual,
buildHeadPreview,
buildThreadAttentionRows,
formatCoarseUptime,
totalUnreadCount,
} from "./threadAttention.ts";

describe("formatCoarseUptime", () => {
it("renders whole seconds under a minute", () => {
assert.equal(formatCoarseUptime(0), "0s");
assert.equal(formatCoarseUptime(999), "0s");
assert.equal(formatCoarseUptime(45_000), "45s");
assert.equal(formatCoarseUptime(59_999), "59s");
});

it("renders whole minutes under an hour, no seconds", () => {
assert.equal(formatCoarseUptime(60_000), "1m");
assert.equal(formatCoarseUptime(3 * 60_000 + 12_000), "3m");
assert.equal(formatCoarseUptime(59 * 60_000 + 59_000), "59m");
});

it("renders whole hours beyond an hour, no minutes", () => {
assert.equal(formatCoarseUptime(60 * 60_000), "1h");
assert.equal(formatCoarseUptime(2 * 60 * 60_000 + 31 * 60_000), "2h");
});

it("clamps negative durations to 0s", () => {
assert.equal(formatCoarseUptime(-5_000), "0s");
});
});

describe("buildHeadPreview", () => {
it("collapses whitespace to one line", () => {
assert.equal(buildHeadPreview("a\nb\t c"), "a b c");
});

it("returns null for blank bodies", () => {
assert.equal(buildHeadPreview(" \n "), null);
});

it("caps long bodies with an ellipsis", () => {
const preview = buildHeadPreview("x".repeat(200));
assert.equal(preview.length, 141);
assert.ok(preview.endsWith("…"));
});
});

function build({
active = new Map(),
heads = new Map(),
summaries = new Map(),
unread = new Map(),
} = {}) {
return buildThreadAttentionRows({
activeSinceByThread: active,
getHeadMessage: (id) => heads.get(id),
getThreadSummary: (id) => summaries.get(id),
threadUnreadCounts: unread,
});
}

describe("buildThreadAttentionRows", () => {
it("returns empty for no unread and no active threads", () => {
assert.deepEqual(build(), []);
});

it("drops threads whose unread count is zero", () => {
const rows = build({ unread: new Map([["t1", 0]]) });
assert.deepEqual(rows, []);
});

it("merges a thread that is both unread and active into one row", () => {
const rows = build({
active: new Map([["t1", 1_000]]),
unread: new Map([["t1", 3]]),
});
assert.equal(rows.length, 1);
assert.equal(rows[0].threadHeadId, "t1");
assert.equal(rows[0].unreadCount, 3);
assert.equal(rows[0].activeSince, 1_000);
});

it("sorts active rows first, newest activity on top", () => {
const rows = build({
active: new Map([
["t1", 1_000],
["t2", 5_000],
]),
summaries: new Map([["t3", { descendantCount: 2, lastReplyAt: 99 }]]),
unread: new Map([["t3", 1]]),
});
assert.deepEqual(
rows.map((row) => row.threadHeadId),
["t2", "t1", "t3"],
);
});

it("sorts unread-only rows by reply recency, newest first", () => {
const rows = build({
summaries: new Map([
["t1", { descendantCount: 1, lastReplyAt: 10 }],
["t2", { descendantCount: 1, lastReplyAt: 30 }],
["t3", { descendantCount: 1, lastReplyAt: 20 }],
]),
unread: new Map([
["t1", 1],
["t2", 1],
["t3", 1],
]),
});
assert.deepEqual(
rows.map((row) => row.threadHeadId),
["t2", "t3", "t1"],
);
});

it("breaks recency ties deterministically by thread id", () => {
const rows = build({
unread: new Map([
["b", 1],
["a", 1],
]),
});
assert.deepEqual(
rows.map((row) => row.threadHeadId),
["a", "b"],
);
});

it("carries author, preview, and reply count when the head is loaded", () => {
const rows = build({
heads: new Map([["t1", { author: "Bart", body: "hi\nthere" }]]),
summaries: new Map([["t1", { descendantCount: 7, lastReplyAt: 5 }]]),
unread: new Map([["t1", 2]]),
});
assert.equal(rows[0].headAuthor, "Bart");
assert.equal(rows[0].headPreview, "hi there");
assert.equal(rows[0].replyCount, 7);
});

it("degrades gracefully when the head message is not loaded", () => {
const rows = build({ unread: new Map([["t1", 1]]) });
assert.equal(rows[0].headAuthor, null);
assert.equal(rows[0].headPreview, null);
assert.equal(rows[0].replyCount, 0);
});
});

describe("areThreadAttentionRowsEqual", () => {
const row = {
threadHeadId: "t1",
headAuthor: "Bart",
headPreview: "hi",
replyCount: 1,
unreadCount: 2,
activeSince: null,
};

it("treats field-identical arrays as equal", () => {
assert.ok(areThreadAttentionRowsEqual([row], [{ ...row }]));
});

it("detects a changed field", () => {
assert.ok(
!areThreadAttentionRowsEqual([row], [{ ...row, unreadCount: 3 }]),
);
});

it("detects length changes", () => {
assert.ok(!areThreadAttentionRowsEqual([row], []));
});
});

describe("totalUnreadCount", () => {
it("sums unread across rows", () => {
const base = {
threadHeadId: "t",
headAuthor: null,
headPreview: null,
replyCount: 0,
activeSince: null,
};
assert.equal(
totalUnreadCount([
{ ...base, threadHeadId: "t1", unreadCount: 2 },
{ ...base, threadHeadId: "t2", unreadCount: 0 },
{ ...base, threadHeadId: "t3", unreadCount: 5 },
]),
7,
);
});
});
126 changes: 126 additions & 0 deletions desktop/src/features/channels/lib/threadAttention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Header-level thread attention derivation: the combined "unread or active"
* list behind the channel header's threads control. Pure functions only —
* first-seen tracking and React wiring live in useThreadAttentionRows.
*/

export type ThreadAttentionRow = {
threadHeadId: string;
/** Resolved display author of the thread head, when loaded. */
headAuthor: string | null;
/** Single-line preview of the thread head body, when loaded. */
headPreview: string | null;
/** Total replies in the thread (descendants, not just direct children). */
replyCount: number;
/** Unread replies in the thread; 0 for active-only rows. */
unreadCount: number;
/** Desktop-clock ms when the thread was first seen active; null if idle. */
activeSince: number | null;
};

/**
* Uptime at the coarsest useful fidelity — whole seconds, then whole minutes,
* then whole hours. Never mixes units ("3m", not "3m 12s").
*/
export function formatCoarseUptime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return `${totalMinutes}m`;
return `${Math.floor(totalMinutes / 60)}h`;
}

/** Single-line preview of a message body: collapsed whitespace, hard cap. */
export function buildHeadPreview(body: string): string | null {
const collapsed = body.replace(/\s+/g, " ").trim();
if (!collapsed) return null;
return collapsed.length > 140 ? `${collapsed.slice(0, 140)}…` : collapsed;
}

/**
* Merge unread counts and active threads into one attention list. Active
* threads sort first (most recently started on top), then unread threads by
* reply recency. A thread that is both active and unread appears once, in the
* active block, carrying its unread count.
*/
export function buildThreadAttentionRows({
activeSinceByThread,
getHeadMessage,
getThreadSummary,
threadUnreadCounts,
}: {
activeSinceByThread: ReadonlyMap<string, number>;
getHeadMessage: (
threadHeadId: string,
) => { author: string; body: string } | undefined;
getThreadSummary: (
threadHeadId: string,
) => { descendantCount: number; lastReplyAt: number | null } | undefined;
threadUnreadCounts: ReadonlyMap<string, number>;
}): ThreadAttentionRow[] {
const ids = new Set<string>(activeSinceByThread.keys());
for (const [threadHeadId, count] of threadUnreadCounts) {
if (count > 0) ids.add(threadHeadId);
}

const rows = [...ids].map((threadHeadId) => {
const head = getHeadMessage(threadHeadId);
const summary = getThreadSummary(threadHeadId);
return {
threadHeadId,
headAuthor: head?.author ?? null,
headPreview: head ? buildHeadPreview(head.body) : null,
replyCount: summary?.descendantCount ?? 0,
unreadCount: threadUnreadCounts.get(threadHeadId) ?? 0,
activeSince: activeSinceByThread.get(threadHeadId) ?? null,
lastReplyAt: summary?.lastReplyAt ?? null,
};
});

rows.sort((left, right) => {
if ((left.activeSince === null) !== (right.activeSince === null)) {
return left.activeSince === null ? 1 : -1;
}
if (left.activeSince !== null && right.activeSince !== null) {
if (left.activeSince !== right.activeSince) {
return right.activeSince - left.activeSince;
}
}
const leftRecency = left.lastReplyAt ?? 0;
const rightRecency = right.lastReplyAt ?? 0;
if (leftRecency !== rightRecency) return rightRecency - leftRecency;
return left.threadHeadId.localeCompare(right.threadHeadId);
});

return rows.map(({ lastReplyAt: _lastReplyAt, ...row }) => row);
}

/** Field-wise equality for the stabilized rows array (see useStableRows). */
export function areThreadAttentionRowsEqual(
a: readonly ThreadAttentionRow[],
b: readonly ThreadAttentionRow[],
): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (
left.threadHeadId !== right.threadHeadId ||
left.headAuthor !== right.headAuthor ||
left.headPreview !== right.headPreview ||
left.replyCount !== right.replyCount ||
left.unreadCount !== right.unreadCount ||
left.activeSince !== right.activeSince
) {
return false;
}
}
return true;
}

/** Badge total for the header trigger: unread replies across all threads. */
export function totalUnreadCount(rows: readonly ThreadAttentionRow[]): number {
let total = 0;
for (const row of rows) total += row.unreadCount;
return total;
}
Loading
Loading