Skip to content
Open
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
5 changes: 4 additions & 1 deletion desktop/src/features/messages/lib/rowHeightEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export function estimateRowHeight(
// Dividers are short, fixed-height rows; reserving their true height keeps the
// estimate honest without a content scan.
const DIVIDER_HEIGHT = 32;
const SYSTEM_GROUP_HEIGHT = 80;

/**
* `contain-intrinsic-size` for a `timeline-row-cv` wrapper. A credible per-row
Expand All @@ -174,6 +175,8 @@ export function timelineRowReserveStyle(
}) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
: item.kind === "system"
? estimateRowHeight(item.entry.message)
: DIVIDER_HEIGHT;
: item.kind === "system-group"
? SYSTEM_GROUP_HEIGHT
: DIVIDER_HEIGHT;
return { containIntrinsicSize: `auto ${height}px` };
}
104 changes: 104 additions & 0 deletions desktop/src/features/messages/lib/timelineItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ function entry(overrides) {
return { message: message(overrides), summary: null };
}

function memberAddedEntry({ actor = "actor-a", createdAt, id, target }) {
return entry({
id,
createdAt,
kind: KIND_SYSTEM_MESSAGE,
body: JSON.stringify({ type: "member_joined", actor, target }),
});
}

function memberJoinedEntry({ createdAt, id, target }) {
return memberAddedEntry({ actor: target, createdAt, id, target });
}

function kinds(items) {
return items.map((item) => item.kind);
}
Expand Down Expand Up @@ -90,6 +103,97 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => {
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
});

test("buildTimelineItems: member additions by one actor group within five minutes", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 60 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 300 }),
];

const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group"]);
const group = items.find((item) => item.kind === "system-group");
assert.deepEqual(
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
assert.equal(group?.key, "a");
});

test("buildTimelineItems: self-joins group across different members within five minutes", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }),
memberJoinedEntry({
id: "b",
target: "target-b",
createdAt: start + 60,
}),
memberJoinedEntry({
id: "c",
target: "target-c",
createdAt: start + 300,
}),
];

const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group"]);
const group = items.find((item) => item.kind === "system-group");
assert.deepEqual(
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
});

test("buildTimelineItems: member-add window is fixed from the first addition", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 301 }),
];

const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]);
});

test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({
id: "b",
actor: "actor-b",
target: "target-b",
createdAt: start + 30,
}),
entry({ id: "message", createdAt: start + 60 }),
memberAddedEntry({
id: "c",
actor: "actor-b",
target: "target-c",
createdAt: start + 90,
}),
memberAddedEntry({
id: "self-join",
actor: "target-d",
target: "target-d",
createdAt: start + 120,
}),
];

const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), [
"day-divider",
"system",
"system",
"message",
"system",
"system",
]);
});

test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => {
const entries = [
entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }),
Expand Down
88 changes: 87 additions & 1 deletion desktop/src/features/messages/lib/timelineItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";

/**
* One renderable row in the flattened timeline. Dividers carry no message and
* never appear in the index map; the three message-bearing kinds do.
* never appear in the index map; the message-bearing kinds do.
*/
export type TimelineItem =
// `headingTimestamp` (not a prebaked label) so the render still resolves
// "Today"/"Yesterday" relative to the current clock, not to build time.
| { kind: "day-divider"; key: string; headingTimestamp: number }
| { kind: "unread-divider"; key: string }
| { kind: "system"; key: string; entry: MainTimelineEntry }
| {
kind: "system-group";
key: string;
entries: MainTimelineEntry[];
}
| {
kind: "message";
key: string;
Expand Down Expand Up @@ -57,6 +62,45 @@ function entryRenderKey(entry: MainTimelineEntry): string {
return entry.message.renderKey ?? entry.message.id;
}

const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60;

type MembershipChangePayload = {
actor: string | null;
mode: "added" | "joined";
target: string;
};

function parseMembershipChangePayload(
entry: MainTimelineEntry,
): MembershipChangePayload | null {
if (entry.message.kind !== KIND_SYSTEM_MESSAGE) return null;

try {
const payload = JSON.parse(entry.message.body) as {
type?: unknown;
actor?: unknown;
target?: unknown;
};
if (
payload.type !== "member_joined" ||
typeof payload.actor !== "string" ||
typeof payload.target !== "string"
) {
return null;
}

const actor = payload.actor.trim().toLowerCase();
const target = payload.target.trim().toLowerCase();
if (!actor || !target) return null;

return actor === target
? { actor: null, mode: "joined", target }
: { actor, mode: "added", target };
} catch {
return null;
}
}

/**
* Walks the (already top-level-filtered) entries once, emitting a day-divider
* at each calendar-day boundary and an unread-divider above the first unread
Expand All @@ -69,6 +113,7 @@ export function buildTimelineItems(
const items: TimelineItem[] = [];
let previousGroupEntry: MainTimelineEntry | null = null;
let previousMessageItemIndex: number | null = null;
let previousMembershipItemIndex: number | null = null;

// Index boundaries by their start position so the walk below can look up the
// prepend-stable section key (start-of-local-day). Keying the divider by
Expand All @@ -89,6 +134,7 @@ export function buildTimelineItems(
if (dayBoundary) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({
kind: "day-divider",
key: dayBoundary.key,
Expand All @@ -99,17 +145,57 @@ export function buildTimelineItems(
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
}

const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
if (kind === "system") {
previousGroupEntry = null;
previousMessageItemIndex = null;

const membershipChange = parseMembershipChangePayload(entry);
const previousItem =
previousMembershipItemIndex === null
? null
: items[previousMembershipItemIndex];
const previousEntries =
previousItem?.kind === "system-group"
? previousItem.entries
: previousItem?.kind === "system"
? [previousItem.entry]
: [];
const firstPreviousEntry = previousEntries[0];
const firstPreviousPayload = firstPreviousEntry
? parseMembershipChangePayload(firstPreviousEntry)
: null;

if (
membershipChange &&
firstPreviousEntry &&
firstPreviousPayload?.mode === membershipChange.mode &&
(membershipChange.mode === "joined" ||
firstPreviousPayload.actor === membershipChange.actor) &&
message.createdAt >= firstPreviousEntry.message.createdAt &&
message.createdAt - firstPreviousEntry.message.createdAt <=
MEMBERSHIP_GROUP_WINDOW_SECONDS
) {
const groupIndex = previousMembershipItemIndex as number;
items[groupIndex] = {
kind: "system-group",
key: entryRenderKey(firstPreviousEntry),
entries: [...previousEntries, entry],
};
continue;
}

items.push({ kind, key: renderKey, entry });
previousMembershipItemIndex = membershipChange ? items.length - 1 : null;
continue;
}

previousMembershipItemIndex = null;

const isContinuation =
previousGroupEntry !== null &&
hasSameMessageAuthor(previousGroupEntry.message, message) &&
Expand Down
Loading
Loading