feat(desktop): grouped system events with collapsible UI

This commit is contained in:
Taylor Ho
2026-05-19 18:50:54 -07:00
parent 582cfc8885
commit 7637e557c3
11 changed files with 1286 additions and 22 deletions

View 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);
}

View 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);

View 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);
}

View 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");
});
});

View 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;
}
}

View 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);
}
});
});

View 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;
}

View 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";

View File

@@ -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>

View 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>
);
}

View File

@@ -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) => (