mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
feat(desktop): grouped system events with collapsible UI
This commit is contained in:
54
desktop/scripts/resolve-at-alias.mjs
Normal file
54
desktop/scripts/resolve-at-alias.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* ESM resolve hook that maps `@/*` imports to `<projectRoot>/src/*`.
|
||||
* Also resolves extensionless imports to `.ts` files (TypeScript convention).
|
||||
*/
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const srcDir = path.join(projectRoot, "src");
|
||||
|
||||
/**
|
||||
* Try to resolve a file path that may be missing its extension.
|
||||
* Returns the resolved file URL or null.
|
||||
*/
|
||||
function tryResolveFile(filePath) {
|
||||
// Try exact path first
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
return pathToFileURL(filePath).href;
|
||||
}
|
||||
// Try with .ts extension
|
||||
const withTs = `${filePath}.ts`;
|
||||
if (fs.existsSync(withTs)) {
|
||||
return pathToFileURL(withTs).href;
|
||||
}
|
||||
// Try with .tsx extension
|
||||
const withTsx = `${filePath}.tsx`;
|
||||
if (fs.existsSync(withTsx)) {
|
||||
return pathToFileURL(withTsx).href;
|
||||
}
|
||||
// Try as directory with index.ts
|
||||
const indexTs = path.join(filePath, "index.ts");
|
||||
if (fs.existsSync(indexTs)) {
|
||||
return pathToFileURL(indexTs).href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith("@/")) {
|
||||
const bare = path.join(srcDir, specifier.slice(2));
|
||||
const resolved = tryResolveFile(bare);
|
||||
if (resolved) {
|
||||
return nextResolve(resolved, context);
|
||||
}
|
||||
// Fallback — let Node try (will likely fail with a clear error)
|
||||
return nextResolve(pathToFileURL(bare).href, context);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
11
desktop/scripts/test-loader.mjs
Normal file
11
desktop/scripts/test-loader.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Custom Node ESM loader that resolves `@/` path aliases to `./src/`
|
||||
* relative to the desktop project root.
|
||||
*
|
||||
* Usage:
|
||||
* node --experimental-strip-types --import ./scripts/test-loader.mjs --test src/path/to.test.mjs
|
||||
*/
|
||||
|
||||
import { register } from "node:module";
|
||||
|
||||
register("./resolve-at-alias.mjs", import.meta.url);
|
||||
35
desktop/scripts/test-resolve-hook.mjs
Normal file
35
desktop/scripts/test-resolve-hook.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Node.js customization hooks — resolves @/ path alias and .ts extensions.
|
||||
// Loaded via: node --import ./scripts/test-loader.mjs
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as pathResolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const srcDir = pathResolve(fileURLToPath(import.meta.url), "../../src");
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
let mapped = specifier;
|
||||
|
||||
// Resolve @/ alias to src/
|
||||
if (mapped.startsWith("@/")) {
|
||||
mapped = pathToFileURL(pathResolve(srcDir, mapped.slice(2))).href;
|
||||
}
|
||||
|
||||
// If the specifier (after alias resolution) looks like a local/file path
|
||||
// without an extension, try appending .ts
|
||||
if (
|
||||
mapped.startsWith("file://") &&
|
||||
!mapped.endsWith(".ts") &&
|
||||
!mapped.endsWith(".mjs") &&
|
||||
!mapped.endsWith(".js")
|
||||
) {
|
||||
const filePath = fileURLToPath(mapped);
|
||||
if (!existsSync(filePath) && existsSync(`${filePath}.ts`)) {
|
||||
mapped = `${mapped}.ts`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapped !== specifier) {
|
||||
return nextResolve(mapped, context);
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
136
desktop/src/features/messages/lib/describeSystemEvent.test.mjs
Normal file
136
desktop/src/features/messages/lib/describeSystemEvent.test.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
import {
|
||||
describeSystemEvent,
|
||||
parseSystemMessagePayload,
|
||||
} from "./describeSystemEvent.ts";
|
||||
|
||||
// ── parseSystemMessagePayload ─────────────────────────────────────────
|
||||
|
||||
describe("parseSystemMessagePayload", () => {
|
||||
test("returns payload for valid JSON", () => {
|
||||
const result = parseSystemMessagePayload(
|
||||
'{"type":"member_joined","actor":"abc","target":"def"}',
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
type: "member_joined",
|
||||
actor: "abc",
|
||||
target: "def",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
assert.equal(parseSystemMessagePayload("not json"), null);
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
assert.equal(parseSystemMessagePayload(""), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── describeSystemEvent ───────────────────────────────────────────────
|
||||
|
||||
describe("describeSystemEvent", () => {
|
||||
test("member_joined self-join shows 'joined the channel'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_joined", actor: "aaa", target: "aaa" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /joined the channel/);
|
||||
});
|
||||
|
||||
test("member_joined with different target shows 'added ... to the channel'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_joined", actor: "aaa", target: "bbb" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /added .* to the channel/);
|
||||
});
|
||||
|
||||
test("member_left shows 'left the channel'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_left", actor: "aaa" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /left the channel/);
|
||||
});
|
||||
|
||||
test("member_removed shows 'removed ... from the channel'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_removed", actor: "aaa", target: "bbb" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /removed .* from the channel/);
|
||||
});
|
||||
|
||||
test("topic_changed includes the topic text", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "topic_changed", actor: "aaa", topic: "New Topic" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /changed the topic to "New Topic"/);
|
||||
});
|
||||
|
||||
test("purpose_changed includes the purpose text", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "purpose_changed", actor: "aaa", purpose: "Ship stuff" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /changed the purpose to "Ship stuff"/);
|
||||
});
|
||||
|
||||
test("channel_created shows 'created this channel'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "channel_created", actor: "aaa" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.match(result, /created this channel/);
|
||||
});
|
||||
|
||||
test("unknown type returns null", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "unknown_type", actor: "aaa" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("currentPubkey resolves to 'You'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_left", actor: "aaa" },
|
||||
"aaa",
|
||||
undefined,
|
||||
);
|
||||
assert.equal(result, "You left the channel");
|
||||
});
|
||||
|
||||
test("profiles resolve display names", () => {
|
||||
const profiles = {
|
||||
bbb: { displayName: "Wes", avatarUrl: null, nip05Handle: null },
|
||||
};
|
||||
const result = describeSystemEvent(
|
||||
{ type: "member_joined", actor: "aaa", target: "bbb" },
|
||||
undefined,
|
||||
profiles,
|
||||
);
|
||||
assert.match(result, /added Wes to the channel/);
|
||||
});
|
||||
|
||||
test("missing actor shows 'Someone'", () => {
|
||||
const result = describeSystemEvent(
|
||||
{ type: "channel_created" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(result, "Someone created this channel");
|
||||
});
|
||||
});
|
||||
82
desktop/src/features/messages/lib/describeSystemEvent.ts
Normal file
82
desktop/src/features/messages/lib/describeSystemEvent.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
|
||||
export type SystemMessagePayload = {
|
||||
type: string;
|
||||
actor?: string;
|
||||
target?: string;
|
||||
topic?: string;
|
||||
purpose?: string;
|
||||
};
|
||||
|
||||
function resolveLabel(
|
||||
pubkey: string | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): string {
|
||||
if (!pubkey) {
|
||||
return "Someone";
|
||||
}
|
||||
return resolveUserLabel({ pubkey, currentPubkey, profiles });
|
||||
}
|
||||
|
||||
function resolvePersonaSuffix(
|
||||
pubkey: string | undefined,
|
||||
personaLookup: Map<string, string> | undefined,
|
||||
): string {
|
||||
if (!pubkey || !personaLookup) return "";
|
||||
const personaName = personaLookup.get(pubkey.toLowerCase());
|
||||
return personaName ? ` (${personaName})` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a human-readable description for a single system event payload.
|
||||
* Returns `null` for unknown event types.
|
||||
*/
|
||||
export function describeSystemEvent(
|
||||
payload: SystemMessagePayload,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
personaLookup?: Map<string, string>,
|
||||
): string | null {
|
||||
const actor = resolveLabel(payload.actor, currentPubkey, profiles);
|
||||
|
||||
switch (payload.type) {
|
||||
case "member_joined": {
|
||||
const target = resolveLabel(payload.target, currentPubkey, profiles);
|
||||
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
|
||||
if (payload.actor === payload.target) {
|
||||
return `${actor}${personaSuffix} joined the channel`;
|
||||
}
|
||||
return `${actor} added ${target}${personaSuffix} to the channel`;
|
||||
}
|
||||
case "member_left": {
|
||||
return `${actor} left the channel`;
|
||||
}
|
||||
case "member_removed": {
|
||||
const target = resolveLabel(payload.target, currentPubkey, profiles);
|
||||
return `${actor} removed ${target} from the channel`;
|
||||
}
|
||||
case "topic_changed":
|
||||
return `${actor} changed the topic to "${payload.topic}"`;
|
||||
case "purpose_changed":
|
||||
return `${actor} changed the purpose to "${payload.purpose}"`;
|
||||
case "channel_created":
|
||||
return `${actor} created this channel`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a system message body into a payload. Returns `null` on failure.
|
||||
*/
|
||||
export function parseSystemMessagePayload(
|
||||
body: string,
|
||||
): SystemMessagePayload | null {
|
||||
try {
|
||||
return JSON.parse(body) as SystemMessagePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
181
desktop/src/features/messages/lib/groupTimelineEntries.test.mjs
Normal file
181
desktop/src/features/messages/lib/groupTimelineEntries.test.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
|
||||
import { groupTimelineEntries } from "./groupTimelineEntries.ts";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const KIND_SYSTEM_MESSAGE = 40099;
|
||||
const KIND_CHAT = 9;
|
||||
|
||||
function makeEntry(overrides = {}) {
|
||||
return {
|
||||
message: {
|
||||
id: `msg-${Math.random().toString(36).slice(2)}`,
|
||||
createdAt: 1000,
|
||||
pubkey: "author1",
|
||||
author: "Author 1",
|
||||
time: "12:00",
|
||||
body: "hello",
|
||||
depth: 0,
|
||||
kind: KIND_CHAT,
|
||||
...overrides,
|
||||
},
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSystemEntry(type, actor, target) {
|
||||
return makeEntry({
|
||||
kind: KIND_SYSTEM_MESSAGE,
|
||||
body: JSON.stringify({ type, actor, target }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Empty input ───────────────────────────────────────────────────────
|
||||
|
||||
describe("groupTimelineEntries", () => {
|
||||
test("returns empty array for empty input", () => {
|
||||
assert.deepEqual(groupTimelineEntries([]), []);
|
||||
});
|
||||
|
||||
// ── Single entries (no grouping) ────────────────────────────────────
|
||||
|
||||
test("single chat message is not compacted", () => {
|
||||
const entry = makeEntry();
|
||||
const result = groupTimelineEntries([entry]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].entryType, "message");
|
||||
if (result[0].entryType === "message") {
|
||||
assert.equal(result[0].isGroupContinuation, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("single system event renders as normal message (no accordion)", () => {
|
||||
const entry = makeSystemEntry("member_joined", "a", "b");
|
||||
const result = groupTimelineEntries([entry]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].entryType, "message");
|
||||
});
|
||||
|
||||
// ── System event grouping ───────────────────────────────────────────
|
||||
|
||||
test("2+ consecutive system events form a group", () => {
|
||||
const entries = [
|
||||
makeSystemEntry("member_joined", "a", "b"),
|
||||
makeSystemEntry("member_joined", "a", "c"),
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].entryType, "system-event-group");
|
||||
if (result[0].entryType === "system-event-group") {
|
||||
assert.equal(result[0].entries.length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("system events separated by a chat message form separate groups", () => {
|
||||
const entries = [
|
||||
makeSystemEntry("member_joined", "a", "b"),
|
||||
makeSystemEntry("member_joined", "a", "c"),
|
||||
makeEntry({ createdAt: 1001 }),
|
||||
makeSystemEntry("member_left", "d"),
|
||||
makeSystemEntry("member_left", "e"),
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
assert.equal(result.length, 3);
|
||||
assert.equal(result[0].entryType, "system-event-group");
|
||||
assert.equal(result[1].entryType, "message");
|
||||
assert.equal(result[2].entryType, "system-event-group");
|
||||
});
|
||||
|
||||
// ── Message compacting ──────────────────────────────────────────────
|
||||
|
||||
test("same author within 2 min is compacted", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
makeEntry({ createdAt: 1060, pubkey: "a" }),
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
assert.equal(result.length, 2);
|
||||
if (result[0].entryType === "message") {
|
||||
assert.equal(result[0].isGroupContinuation, false);
|
||||
}
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("same author beyond 2 min is NOT compacted", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
makeEntry({ createdAt: 1121, pubkey: "a" }), // 121 seconds > 120
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("different authors are NOT compacted", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
makeEntry({ createdAt: 1010, pubkey: "b" }),
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("message with thread summary breaks compacting", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
{
|
||||
message: {
|
||||
id: "msg-2",
|
||||
createdAt: 1010,
|
||||
pubkey: "a",
|
||||
author: "Author",
|
||||
time: "12:00",
|
||||
body: "reply",
|
||||
depth: 0,
|
||||
kind: KIND_CHAT,
|
||||
},
|
||||
summary: {
|
||||
threadHeadId: "msg-1",
|
||||
replyCount: 3,
|
||||
participants: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("system message after chat message breaks compacting", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
makeSystemEntry("topic_changed", "a"),
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
assert.equal(result[1].entryType, "message");
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, false);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Boundary: exactly 120 seconds ───────────────────────────────────
|
||||
|
||||
test("exactly 120 seconds gap is still compacted", () => {
|
||||
const entries = [
|
||||
makeEntry({ createdAt: 1000, pubkey: "a" }),
|
||||
makeEntry({ createdAt: 1120, pubkey: "a" }), // exactly 120
|
||||
];
|
||||
const result = groupTimelineEntries(entries);
|
||||
if (result[1].entryType === "message") {
|
||||
assert.equal(result[1].isGroupContinuation, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
129
desktop/src/features/messages/lib/groupTimelineEntries.ts
Normal file
129
desktop/src/features/messages/lib/groupTimelineEntries.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
|
||||
/**
|
||||
* Maximum gap (in seconds) between two consecutive messages from the same
|
||||
* author before the grouping breaks and a new "full chrome" row is shown.
|
||||
*/
|
||||
const COMPACT_MESSAGE_GAP_SECONDS = 120; // 2 minutes
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotated entry types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AnnotatedTimelineEntry =
|
||||
| AnnotatedMessageEntry
|
||||
| AnnotatedSystemEventGroup;
|
||||
|
||||
export type AnnotatedMessageEntry = MainTimelineEntry & {
|
||||
entryType: "message";
|
||||
/** True when this message should render without avatar / author / timestamp. */
|
||||
isGroupContinuation: boolean;
|
||||
};
|
||||
|
||||
export type AnnotatedSystemEventGroup = {
|
||||
entryType: "system-event-group";
|
||||
/** The individual system events in this group (always ≥ 2). */
|
||||
entries: MainTimelineEntry[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Annotates a flat list of `MainTimelineEntry` items with grouping metadata.
|
||||
*
|
||||
* Two behaviours:
|
||||
* 1. **Consecutive message compacting** — same author, same message kind,
|
||||
* within `COMPACT_MESSAGE_GAP_SECONDS` → subsequent messages marked as
|
||||
* `isGroupContinuation: true` (no avatar / name chrome).
|
||||
* 2. **System event grouping** — consecutive runs of system events (≥ 2)
|
||||
* are collapsed into a single `AnnotatedSystemEventGroup`.
|
||||
*/
|
||||
export function groupTimelineEntries(
|
||||
entries: MainTimelineEntry[],
|
||||
): AnnotatedTimelineEntry[] {
|
||||
const result: AnnotatedTimelineEntry[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < entries.length) {
|
||||
const entry = entries[i];
|
||||
|
||||
// --- System event run detection ---
|
||||
if (entry.message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const groupStart = i;
|
||||
while (
|
||||
i < entries.length &&
|
||||
entries[i].message.kind === KIND_SYSTEM_MESSAGE
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
|
||||
const run = entries.slice(groupStart, i);
|
||||
if (run.length >= 2) {
|
||||
result.push({ entryType: "system-event-group", entries: run });
|
||||
} else {
|
||||
// Single system event — render normally (no accordion).
|
||||
result.push({
|
||||
...run[0],
|
||||
entryType: "message",
|
||||
isGroupContinuation: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Chat message compacting ---
|
||||
const prev = result.length > 0 ? result[result.length - 1] : null;
|
||||
const isCompact = shouldCompact(prev, entry);
|
||||
|
||||
result.push({
|
||||
...entry,
|
||||
entryType: "message",
|
||||
isGroupContinuation: isCompact,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether `current` should render in compact mode (no avatar /
|
||||
* author line) based on the previous annotated entry.
|
||||
*/
|
||||
function shouldCompact(
|
||||
prev: AnnotatedTimelineEntry | null,
|
||||
current: MainTimelineEntry,
|
||||
): boolean {
|
||||
if (!prev) return false;
|
||||
|
||||
// Can only compact after another message entry (not after a system group).
|
||||
if (prev.entryType !== "message") return false;
|
||||
|
||||
const prevMsg = prev.message;
|
||||
const curMsg = current.message;
|
||||
|
||||
// Must be the same author.
|
||||
if (prevMsg.pubkey !== curMsg.pubkey) return false;
|
||||
|
||||
// Don't compact if either is a system message.
|
||||
if (
|
||||
prevMsg.kind === KIND_SYSTEM_MESSAGE ||
|
||||
curMsg.kind === KIND_SYSTEM_MESSAGE
|
||||
)
|
||||
return false;
|
||||
|
||||
// Must be within the time window.
|
||||
if (curMsg.createdAt - prevMsg.createdAt > COMPACT_MESSAGE_GAP_SECONDS)
|
||||
return false;
|
||||
|
||||
// Don't compact if the current message has a thread summary (visual break).
|
||||
if (current.summary) return false;
|
||||
|
||||
// Don't compact if the previous message had a thread summary.
|
||||
if (prev.summary) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
256
desktop/src/features/messages/ui/CompactMessageRow.tsx
Normal file
256
desktop/src/features/messages/ui/CompactMessageRow.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
/**
|
||||
* Compact message row — renders body only (no avatar / author line).
|
||||
* Used for consecutive messages from the same author within 2 minutes.
|
||||
* A subtle timestamp appears on hover in the left gutter where the avatar
|
||||
* would normally sit.
|
||||
*/
|
||||
export const CompactMessageRow = React.memo(
|
||||
function CompactMessageRow({
|
||||
activeReplyTargetId = null,
|
||||
highlighted = false,
|
||||
message,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
profiles,
|
||||
searchQuery,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
highlighted?: boolean;
|
||||
message: TimelineMessage;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
searchQuery?: string;
|
||||
}) {
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
|
||||
const mentionNames = React.useMemo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
);
|
||||
|
||||
const imetaByUrl = React.useMemo(
|
||||
() => (message.tags ? parseImetaTags(message.tags) : undefined),
|
||||
[message.tags],
|
||||
);
|
||||
|
||||
const { channels } = useChannelNavigation();
|
||||
const channelNames = React.useMemo(
|
||||
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) =>
|
||||
reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Loading diff…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
className="max-w-full"
|
||||
content={message.body}
|
||||
imetaByUrl={imetaByUrl}
|
||||
mentionNames={mentionNames}
|
||||
searchQuery={searchQuery}
|
||||
tight
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex items-start gap-2.5 rounded-2xl px-2 py-0.5 transition-colors",
|
||||
highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="compact-message-row"
|
||||
>
|
||||
{/* Empty gutter aligned with avatar column (36px avatar + 10px gap = 46px) */}
|
||||
<div className="w-[46px] shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start">
|
||||
<div className="min-w-0 flex-1">{renderBody()}</div>
|
||||
<div className="relative ml-2 shrink-0">
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{message.pending ? (
|
||||
<p className="text-xs font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
{message.edited ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground/70"
|
||||
title="This message has been edited"
|
||||
>
|
||||
(edited)
|
||||
</p>
|
||||
) : null}
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
reactions={reactions}
|
||||
canToggle={canToggleReactions}
|
||||
pending={reactionPending}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{expandedDiffId === message.id ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.message.id === next.message.id &&
|
||||
prev.message.pubkey === next.message.pubkey &&
|
||||
prev.message.body === next.message.body &&
|
||||
prev.message.author === next.message.author &&
|
||||
prev.message.avatarUrl === next.message.avatarUrl &&
|
||||
prev.message.accent === next.message.accent &&
|
||||
prev.message.time === next.message.time &&
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
prev.message.edited === next.message.edited &&
|
||||
prev.message.reactions === next.message.reactions &&
|
||||
prev.message.tags === next.message.tags &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.activeReplyTargetId === next.activeReplyTargetId &&
|
||||
prev.profiles === next.profiles &&
|
||||
prev.searchQuery === next.searchQuery,
|
||||
);
|
||||
|
||||
CompactMessageRow.displayName = "CompactMessageRow";
|
||||
@@ -6,7 +6,7 @@ export function DayDivider({ label }: { label: string }) {
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
<p className="shrink-0 rounded-full bg-background/90 px-2 py-0.5 text-[10px] font-medium tracking-[0.02em] text-muted-foreground/65 backdrop-blur-sm">
|
||||
<p className="shrink-0 rounded-full bg-background/90 px-2 py-0.5 text-xs font-medium tracking-[0.02em] text-muted-foreground/65 backdrop-blur-sm">
|
||||
{label}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
302
desktop/src/features/messages/ui/SystemEventGroupRow.tsx
Normal file
302
desktop/src/features/messages/ui/SystemEventGroupRow.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
type UserProfileLookup,
|
||||
resolveUserLabel,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import {
|
||||
parseSystemMessagePayload,
|
||||
type SystemMessagePayload,
|
||||
} from "@/features/messages/lib/describeSystemEvent";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
/** Max avatars to show in the stacked ingress before showing +N. */
|
||||
const MAX_STACKED_AVATARS = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve an actor pubkey to a display name. */
|
||||
function resolveActorName(
|
||||
pubkey: string | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): string {
|
||||
if (!pubkey) return "Someone";
|
||||
return resolveUserLabel({ pubkey, currentPubkey, profiles });
|
||||
}
|
||||
|
||||
/** Describe a single action type + count as a fragment (no actor prefix). */
|
||||
function describeAction(type: string, count: number): string | null {
|
||||
switch (type) {
|
||||
case "member_joined_self":
|
||||
return count === 1
|
||||
? "joined the channel"
|
||||
: `joined the channel (×${count})`;
|
||||
case "member_joined":
|
||||
return `added ${count} member${count === 1 ? "" : "s"}`;
|
||||
case "member_left":
|
||||
return count === 1 ? "left the channel" : `left the channel (×${count})`;
|
||||
case "member_removed":
|
||||
return `removed ${count} member${count === 1 ? "" : "s"}`;
|
||||
case "topic_changed":
|
||||
return `changed the topic`;
|
||||
case "purpose_changed":
|
||||
return `changed the purpose`;
|
||||
case "channel_created":
|
||||
return "created this channel";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a summary grouped by actor.
|
||||
*
|
||||
* Single actor, one action: "tho added 5 members"
|
||||
* Single actor, mixed: "tho added 3 members, removed 2 members"
|
||||
* Multi actor (semicolons): "tho added 5 members; wes added 2 members"
|
||||
* Self-join: "tho joined the channel"
|
||||
*/
|
||||
function buildSummary(
|
||||
payloads: SystemMessagePayload[],
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
_personaLookup?: Map<string, string>,
|
||||
): string {
|
||||
// Group counts by actor → type.
|
||||
const actorTypes = new Map<string, Map<string, number>>();
|
||||
// Preserve insertion order of actors.
|
||||
const actorOrder: string[] = [];
|
||||
|
||||
for (const p of payloads) {
|
||||
const actorKey = p.actor ?? "__unknown__";
|
||||
let typeMap = actorTypes.get(actorKey);
|
||||
if (!typeMap) {
|
||||
typeMap = new Map();
|
||||
actorTypes.set(actorKey, typeMap);
|
||||
actorOrder.push(actorKey);
|
||||
}
|
||||
// Distinguish self-joins ("joined") from adds ("added N members").
|
||||
const type =
|
||||
p.type === "member_joined" && p.actor === p.target
|
||||
? "member_joined_self"
|
||||
: p.type;
|
||||
typeMap.set(type, (typeMap.get(type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const clauses: string[] = [];
|
||||
|
||||
for (const actorKey of actorOrder) {
|
||||
const name = resolveActorName(
|
||||
actorKey === "__unknown__" ? undefined : actorKey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
);
|
||||
const typeMap = actorTypes.get(actorKey);
|
||||
if (!typeMap) continue;
|
||||
const actions: string[] = [];
|
||||
|
||||
for (const [type, count] of typeMap) {
|
||||
const desc = describeAction(type, count);
|
||||
if (desc) actions.push(desc);
|
||||
}
|
||||
|
||||
if (actions.length === 0) continue;
|
||||
|
||||
// First action gets the actor name; subsequent actions for the same actor
|
||||
// omit the name to read naturally: "tho added 3 members, removed 2 members"
|
||||
clauses.push(`${name} ${actions.join(", ")}`);
|
||||
}
|
||||
|
||||
return clauses.length > 0
|
||||
? clauses.join("; ")
|
||||
: `${payloads.length} system event${payloads.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Avatar helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract unique pubkeys to display as stacked avatars.
|
||||
* For add/remove: show targets. For topic/purpose/channel: show actors.
|
||||
*/
|
||||
function extractAvatarPubkeys(payloads: SystemMessagePayload[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const p of payloads) {
|
||||
const key =
|
||||
p.type === "member_joined" ||
|
||||
p.type === "member_removed" ||
|
||||
p.type === "member_left"
|
||||
? // For member_left the actor IS the target (they left themselves)
|
||||
p.type === "member_left"
|
||||
? p.actor
|
||||
: p.target
|
||||
: p.actor;
|
||||
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveAvatarUrl(
|
||||
pubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): string | null {
|
||||
if (!pubkey || !profiles) return null;
|
||||
return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SystemEventGroupRow({
|
||||
entries,
|
||||
currentPubkey,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
}: {
|
||||
entries: MainTimelineEntry[];
|
||||
currentPubkey?: string;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const payloads = React.useMemo(
|
||||
() =>
|
||||
entries
|
||||
.map((e) => parseSystemMessagePayload(e.message.body))
|
||||
.filter((p): p is SystemMessagePayload => p !== null),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const summary = React.useMemo(
|
||||
() => buildSummary(payloads, currentPubkey, profiles, personaLookup),
|
||||
[payloads, currentPubkey, profiles, personaLookup],
|
||||
);
|
||||
|
||||
const avatarPubkeys = React.useMemo(
|
||||
() => extractAvatarPubkeys(payloads),
|
||||
[payloads],
|
||||
);
|
||||
|
||||
const visibleAvatars = avatarPubkeys.slice(0, MAX_STACKED_AVATARS);
|
||||
const overflowCount = avatarPubkeys.length - visibleAvatars.length;
|
||||
|
||||
const groupId = React.useId();
|
||||
const panelId = `${groupId}-panel`;
|
||||
|
||||
return (
|
||||
<div data-testid="system-event-group">
|
||||
{/* Collapsed summary row — centered pill */}
|
||||
<div className="flex justify-center py-1 px-4">
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="flex items-center gap-1.5 rounded-full border border-border/50 pl-1 pr-2.5 py-1 transition-colors hover:bg-muted/30"
|
||||
data-testid="system-event-group-toggle"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
type="button"
|
||||
>
|
||||
{/* Stacked avatars */}
|
||||
<div className="flex shrink-0 items-center">
|
||||
{visibleAvatars.map((pubkey, index) => (
|
||||
<div
|
||||
key={pubkey}
|
||||
className={index > 0 ? "-ml-1.5" : ""}
|
||||
style={{ zIndex: 10 - index }}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={resolveAvatarUrl(pubkey, profiles)}
|
||||
className="!h-5 !w-5 rounded-full border border-background text-[7px]"
|
||||
displayName={resolveActorName(
|
||||
pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{overflowCount > 0 ? (
|
||||
<div
|
||||
className="-ml-1.5 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-muted text-[7px] font-medium text-muted-foreground"
|
||||
style={{ zIndex: 10 - visibleAvatars.length }}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground/70">{summary}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform duration-150",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded children — inline flex-wrapped chips with staggered animation */}
|
||||
<AnimatePresence>
|
||||
{expanded ? (
|
||||
<motion.section
|
||||
className="flex flex-wrap justify-center gap-1 px-4 pb-1"
|
||||
data-testid="system-event-group-children"
|
||||
id={panelId}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<motion.div
|
||||
key={entry.message.id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{
|
||||
duration: 0.12,
|
||||
delay: index * 0.03,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<SystemMessageRow
|
||||
message={entry.message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
compact
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.section>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,19 @@ import {
|
||||
formatDayHeading,
|
||||
isSameDay,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
groupTimelineEntries,
|
||||
type AnnotatedTimelineEntry,
|
||||
} from "@/features/messages/lib/groupTimelineEntries";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { CompactMessageRow } from "./CompactMessageRow";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemEventGroupRow } from "./SystemEventGroupRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
@@ -40,6 +46,14 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
/** Return the first message's createdAt for a given annotated entry. */
|
||||
function getEntryLeadTimestamp(entry: AnnotatedTimelineEntry): number {
|
||||
if (entry.entryType === "system-event-group") {
|
||||
return entry.entries[0].message.createdAt;
|
||||
}
|
||||
return entry.message.createdAt;
|
||||
}
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
activeReplyTargetId = null,
|
||||
channelId,
|
||||
@@ -58,10 +72,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(messages),
|
||||
[messages],
|
||||
);
|
||||
const annotated = React.useMemo(() => {
|
||||
const raw = buildMainTimelineEntries(messages);
|
||||
return groupTimelineEntries(raw);
|
||||
}, [messages]);
|
||||
|
||||
const dayGroups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -69,22 +84,48 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
}> = [];
|
||||
let currentDayGroup: (typeof dayGroups)[number] | null = null;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const prev = i > 0 ? entries[i - 1]?.message : null;
|
||||
for (let i = 0; i < annotated.length; i++) {
|
||||
const entry = annotated[i];
|
||||
const leadTimestamp = getEntryLeadTimestamp(entry);
|
||||
|
||||
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
|
||||
// Day divider — start a new day group when the day changes
|
||||
if (
|
||||
!currentDayGroup ||
|
||||
(i > 0 &&
|
||||
!isSameDay(getEntryLeadTimestamp(annotated[i - 1]), leadTimestamp))
|
||||
) {
|
||||
currentDayGroup = {
|
||||
key: `day-${message.createdAt}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
key: `day-${leadTimestamp}`,
|
||||
label: formatDayHeading(leadTimestamp),
|
||||
elements: [],
|
||||
};
|
||||
dayGroups.push(currentDayGroup);
|
||||
}
|
||||
|
||||
// --- System event group (accordion) ---
|
||||
if (entry.entryType === "system-event-group") {
|
||||
const groupKey = entry.entries.map((e) => e.message.id).join(",");
|
||||
currentDayGroup.elements.push(
|
||||
<div key={`sys-group-${groupKey}`} className="my-1">
|
||||
<SystemEventGroupRow
|
||||
entries={entry.entries}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Single entries ---
|
||||
const { message, summary } = entry;
|
||||
|
||||
// --- Single system message (not grouped) ---
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup?.elements.push(
|
||||
currentDayGroup.elements.push(
|
||||
<div key={message.id} className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
@@ -96,9 +137,17 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else if (summary && onReply) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Search highlight state ---
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
|
||||
// --- Message with thread summary ---
|
||||
if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup?.elements.push(
|
||||
currentDayGroup.elements.push(
|
||||
<div key={message.id} className="flex flex-col gap-0">
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
@@ -129,16 +178,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentDayGroup?.elements.push(
|
||||
// --- Compact message (continuation from same author) ---
|
||||
if (entry.isGroupContinuation) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup.elements.push(
|
||||
<div key={message.id} className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
<CompactMessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
@@ -151,7 +200,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
@@ -160,7 +208,37 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Full message row ---
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup.elements.push(
|
||||
<div key={message.id} className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return dayGroups.map((group) => (
|
||||
|
||||
Reference in New Issue
Block a user