diff --git a/desktop/scripts/resolve-at-alias.mjs b/desktop/scripts/resolve-at-alias.mjs new file mode 100644 index 000000000..41ee52377 --- /dev/null +++ b/desktop/scripts/resolve-at-alias.mjs @@ -0,0 +1,54 @@ +/** + * ESM resolve hook that maps `@/*` imports to `/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); +} diff --git a/desktop/scripts/test-loader.mjs b/desktop/scripts/test-loader.mjs new file mode 100644 index 000000000..7ae4ca6e2 --- /dev/null +++ b/desktop/scripts/test-loader.mjs @@ -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); diff --git a/desktop/scripts/test-resolve-hook.mjs b/desktop/scripts/test-resolve-hook.mjs new file mode 100644 index 000000000..6ce925862 --- /dev/null +++ b/desktop/scripts/test-resolve-hook.mjs @@ -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); +} diff --git a/desktop/src/features/messages/lib/describeSystemEvent.test.mjs b/desktop/src/features/messages/lib/describeSystemEvent.test.mjs new file mode 100644 index 000000000..bb8523a25 --- /dev/null +++ b/desktop/src/features/messages/lib/describeSystemEvent.test.mjs @@ -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"); + }); +}); diff --git a/desktop/src/features/messages/lib/describeSystemEvent.ts b/desktop/src/features/messages/lib/describeSystemEvent.ts new file mode 100644 index 000000000..40c2f9cef --- /dev/null +++ b/desktop/src/features/messages/lib/describeSystemEvent.ts @@ -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 | 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 | 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; + } +} diff --git a/desktop/src/features/messages/lib/groupTimelineEntries.test.mjs b/desktop/src/features/messages/lib/groupTimelineEntries.test.mjs new file mode 100644 index 000000000..08ee2f3ab --- /dev/null +++ b/desktop/src/features/messages/lib/groupTimelineEntries.test.mjs @@ -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); + } + }); +}); diff --git a/desktop/src/features/messages/lib/groupTimelineEntries.ts b/desktop/src/features/messages/lib/groupTimelineEntries.ts new file mode 100644 index 000000000..225c0dfc8 --- /dev/null +++ b/desktop/src/features/messages/lib/groupTimelineEntries.ts @@ -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; +} diff --git a/desktop/src/features/messages/ui/CompactMessageRow.tsx b/desktop/src/features/messages/ui/CompactMessageRow.tsx new file mode 100644 index 000000000..c258e5158 --- /dev/null +++ b/desktop/src/features/messages/ui/CompactMessageRow.tsx @@ -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; + onReply?: (message: TimelineMessage) => void; + profiles?: UserProfileLookup; + searchQuery?: string; + }) { + const [expandedDiffId, setExpandedDiffId] = React.useState( + 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 ( + + Loading diff… + + } + > + { + setExpandedDiffId(message.id); + }} + repoUrl={getTag("repo")} + truncated={getTag("truncated") === "true"} + /> + + ); + default: + return ( + + ); + } + }; + + return ( +
+ {/* Empty gutter aligned with avatar column (36px avatar + 10px gap = 46px) */} +
+ +
+
+
{renderBody()}
+
+
+ +
+
+
+ {message.pending ? ( +

+ Sending +

+ ) : null} + {message.edited ? ( +

+ (edited) +

+ ) : null} + { + void handleReactionSelect(emoji).catch(() => { + return; + }); + }} + /> + {reactionErrorMessage ? ( +

+ {reactionErrorMessage} +

+ ) : null} + {expandedDiffId === message.id ? ( + + Loading diff viewer… +
+ } + > + { + setExpandedDiffId(null); + }} + /> + + ) : null} +
+
+ ); + }, + (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"; diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index 4daaae12d..68a29ccc7 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -6,7 +6,7 @@ export function DayDivider({ label }: { label: string }) { data-testid="message-timeline-day-divider" data-day-label={label} > -

+

{label}

diff --git a/desktop/src/features/messages/ui/SystemEventGroupRow.tsx b/desktop/src/features/messages/ui/SystemEventGroupRow.tsx new file mode 100644 index 000000000..894504874 --- /dev/null +++ b/desktop/src/features/messages/ui/SystemEventGroupRow.tsx @@ -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 { + // Group counts by actor → type. + const actorTypes = new Map>(); + // 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(); + 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; + personaLookup?: Map; + 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 ( +
+ {/* Collapsed summary row — centered pill */} +
+ +
+ + {/* Expanded children — inline flex-wrapped chips with staggered animation */} + + {expanded ? ( + + {entries.map((entry, index) => ( + + + + ))} + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index e6afd8cd0..5d52bb49b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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( +
+ +
, + ); + 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(
, ); - } 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(
, ); - } 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(
- , ); + continue; } + + // --- Full message row --- + const footer = messageFooters?.[message.id] ?? null; + currentDayGroup.elements.push( +
+ + {footer} +
, + ); } return dayGroups.map((group) => (