From 2599c0ddea053d68423ff3c7957be6023e241891 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 12:30:39 +0000 Subject: [PATCH] refactor: extract and test system message grouping logic Extract grouping logic to separate module: - Created src/lib/chat/group-system-messages.ts with: - groupSystemMessages(): Groups consecutive system messages - isGroupedSystemMessage(): Type guard with validation - GroupedSystemMessage interface - Updated ChatViewer to import from new module - Improved type guard with additional validation: - Check array lengths match (authors.length === messageIds.length) - Ensure arrays are not empty - Validate all field types Added comprehensive test coverage (26 tests): - Basic grouping behavior - Edge cases (empty arrays, single messages) - Mixed message types (user, zap, system) - Timestamp preservation - Large group handling (100+ items) - Type guard validation All tests pass (1006 total), build succeeds. Production ready. --- src/components/ChatViewer.tsx | 78 +--- src/lib/chat/group-system-messages.test.ts | 414 +++++++++++++++++++++ src/lib/chat/group-system-messages.ts | 95 +++++ 3 files changed, 514 insertions(+), 73 deletions(-) create mode 100644 src/lib/chat/group-system-messages.test.ts create mode 100644 src/lib/chat/group-system-messages.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 1158148..1b716c4 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -32,6 +32,11 @@ import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; import type { ChatAction } from "@/types/chat-actions"; import { parseSlashCommand } from "@/lib/chat/slash-command-parser"; +import { + groupSystemMessages, + isGroupedSystemMessage, + type GroupedSystemMessage, +} from "@/lib/chat/group-system-messages"; import { UserName } from "./nostr/UserName"; import { RichText } from "./nostr/RichText"; import Timestamp from "./Timestamp"; @@ -71,79 +76,6 @@ interface ChatViewerProps { headerPrefix?: React.ReactNode; } -/** - * Grouped system message - multiple users doing the same action - */ -interface GroupedSystemMessage { - authors: string[]; // pubkeys of users who performed the action - content: string; // action text (e.g., "reposted", "joined", "left") - timestamp: number; // timestamp of the first message in the group - messageIds: string[]; // IDs of all messages in the group -} - -/** - * Helper: Group consecutive system messages with the same content - * Example: [alice reposted, bob reposted, charlie reposted] -> "alice, bob, charlie reposted" - */ -function groupSystemMessages( - messages: Message[], -): Array { - const result: Array = []; - let currentGroup: GroupedSystemMessage | null = null; - - for (const message of messages) { - // Only group system messages (not user or zap messages) - if (message.type === "system") { - // Check if we can add to current group - if (currentGroup && currentGroup.content === message.content) { - // Add to existing group - currentGroup.authors.push(message.author); - currentGroup.messageIds.push(message.id); - } else { - // Finalize current group if exists - if (currentGroup) { - result.push(currentGroup); - } - // Start new group - currentGroup = { - authors: [message.author], - content: message.content, - timestamp: message.timestamp, - messageIds: [message.id], - }; - } - } else { - // Non-system message - finalize any pending group - if (currentGroup) { - result.push(currentGroup); - currentGroup = null; - } - result.push(message); - } - } - - // Don't forget the last group if exists - if (currentGroup) { - result.push(currentGroup); - } - - return result; -} - -/** - * Type guard to check if item is a grouped system message - */ -function isGroupedSystemMessage(item: unknown): item is GroupedSystemMessage { - if (!item || typeof item !== "object") return false; - const obj = item as Record; - return ( - Array.isArray(obj.authors) && - typeof obj.content === "string" && - typeof obj.timestamp === "number" && - Array.isArray(obj.messageIds) - ); -} - /** * Helper: Format timestamp as a readable day marker */ diff --git a/src/lib/chat/group-system-messages.test.ts b/src/lib/chat/group-system-messages.test.ts new file mode 100644 index 0000000..784694c --- /dev/null +++ b/src/lib/chat/group-system-messages.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from "vitest"; +import { + groupSystemMessages, + isGroupedSystemMessage, + type GroupedSystemMessage, +} from "./group-system-messages"; +import type { Message } from "@/types/chat"; + +// Helper to create test messages +function createMessage( + id: string, + type: Message["type"], + content: string, + author: string, + timestamp: number, +): Message { + return { + id, + conversationId: "test-conversation", + author, + content, + timestamp, + type, + protocol: "nip-10", + event: {} as any, // Mock event + }; +} + +describe("groupSystemMessages", () => { + describe("basic grouping", () => { + it("should group consecutive system messages with same content", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "system", "reposted", "bob", 1001), + createMessage("3", "system", "reposted", "charlie", 1002), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + expect(isGroupedSystemMessage(result[0])).toBe(true); + + const group = result[0] as GroupedSystemMessage; + expect(group.authors).toEqual(["alice", "bob", "charlie"]); + expect(group.content).toBe("reposted"); + expect(group.timestamp).toBe(1000); + expect(group.messageIds).toEqual(["1", "2", "3"]); + }); + + it("should not group non-consecutive system messages", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "user", "hello", "bob", 1001), + createMessage("3", "system", "reposted", "charlie", 1002), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(3); + + // First group (alice) + expect(isGroupedSystemMessage(result[0])).toBe(true); + const group1 = result[0] as GroupedSystemMessage; + expect(group1.authors).toEqual(["alice"]); + + // User message (bob) + expect(isGroupedSystemMessage(result[1])).toBe(false); + expect((result[1] as Message).id).toBe("2"); + + // Second group (charlie) + expect(isGroupedSystemMessage(result[2])).toBe(true); + const group2 = result[2] as GroupedSystemMessage; + expect(group2.authors).toEqual(["charlie"]); + }); + + it("should not group system messages with different content", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "system", "joined", "bob", 1001), + createMessage("3", "system", "reposted", "charlie", 1002), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(3); + + // Each should be its own group + expect(isGroupedSystemMessage(result[0])).toBe(true); + expect((result[0] as GroupedSystemMessage).content).toBe("reposted"); + expect((result[0] as GroupedSystemMessage).authors).toEqual(["alice"]); + + expect(isGroupedSystemMessage(result[1])).toBe(true); + expect((result[1] as GroupedSystemMessage).content).toBe("joined"); + expect((result[1] as GroupedSystemMessage).authors).toEqual(["bob"]); + + expect(isGroupedSystemMessage(result[2])).toBe(true); + expect((result[2] as GroupedSystemMessage).content).toBe("reposted"); + expect((result[2] as GroupedSystemMessage).authors).toEqual(["charlie"]); + }); + }); + + describe("edge cases", () => { + it("should handle empty array", () => { + const result = groupSystemMessages([]); + expect(result).toEqual([]); + }); + + it("should handle single system message", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + expect(isGroupedSystemMessage(result[0])).toBe(true); + + const group = result[0] as GroupedSystemMessage; + expect(group.authors).toEqual(["alice"]); + expect(group.messageIds).toEqual(["1"]); + }); + + it("should handle single user message", () => { + const messages: Message[] = [ + createMessage("1", "user", "hello", "alice", 1000), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + expect(isGroupedSystemMessage(result[0])).toBe(false); + expect((result[0] as Message).id).toBe("1"); + }); + + it("should handle only user messages", () => { + const messages: Message[] = [ + createMessage("1", "user", "hello", "alice", 1000), + createMessage("2", "user", "world", "bob", 1001), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(2); + expect(isGroupedSystemMessage(result[0])).toBe(false); + expect(isGroupedSystemMessage(result[1])).toBe(false); + }); + + it("should handle only system messages", () => { + const messages: Message[] = [ + createMessage("1", "system", "joined", "alice", 1000), + createMessage("2", "system", "joined", "bob", 1001), + createMessage("3", "system", "joined", "charlie", 1002), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + expect(isGroupedSystemMessage(result[0])).toBe(true); + + const group = result[0] as GroupedSystemMessage; + expect(group.authors).toEqual(["alice", "bob", "charlie"]); + }); + }); + + describe("mixed message types", () => { + it("should not group system messages separated by user messages", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "system", "reposted", "bob", 1001), + createMessage("3", "user", "hello", "charlie", 1002), + createMessage("4", "system", "reposted", "dave", 1003), + createMessage("5", "system", "reposted", "eve", 1004), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(3); + + // First group (alice, bob) + expect(isGroupedSystemMessage(result[0])).toBe(true); + expect((result[0] as GroupedSystemMessage).authors).toEqual([ + "alice", + "bob", + ]); + + // User message (charlie) + expect(isGroupedSystemMessage(result[1])).toBe(false); + expect((result[1] as Message).id).toBe("3"); + + // Second group (dave, eve) + expect(isGroupedSystemMessage(result[2])).toBe(true); + expect((result[2] as GroupedSystemMessage).authors).toEqual([ + "dave", + "eve", + ]); + }); + + it("should not group system messages separated by zap messages", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "zap", "zapped 1000 sats", "bob", 1001), + createMessage("3", "system", "reposted", "charlie", 1002), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(3); + + expect(isGroupedSystemMessage(result[0])).toBe(true); + expect((result[0] as GroupedSystemMessage).authors).toEqual(["alice"]); + + expect(isGroupedSystemMessage(result[1])).toBe(false); + expect((result[1] as Message).type).toBe("zap"); + + expect(isGroupedSystemMessage(result[2])).toBe(true); + expect((result[2] as GroupedSystemMessage).authors).toEqual(["charlie"]); + }); + + it("should handle complex alternating pattern", () => { + const messages: Message[] = [ + createMessage("1", "system", "joined", "alice", 1000), + createMessage("2", "system", "joined", "bob", 1001), + createMessage("3", "user", "hello", "alice", 1002), + createMessage("4", "system", "reposted", "charlie", 1003), + createMessage("5", "user", "world", "bob", 1004), + createMessage("6", "system", "left", "dave", 1005), + createMessage("7", "system", "left", "eve", 1006), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(5); + + // joined group + expect(isGroupedSystemMessage(result[0])).toBe(true); + expect((result[0] as GroupedSystemMessage).content).toBe("joined"); + expect((result[0] as GroupedSystemMessage).authors).toEqual([ + "alice", + "bob", + ]); + + // user message + expect(isGroupedSystemMessage(result[1])).toBe(false); + + // reposted group (single) + expect(isGroupedSystemMessage(result[2])).toBe(true); + expect((result[2] as GroupedSystemMessage).content).toBe("reposted"); + expect((result[2] as GroupedSystemMessage).authors).toEqual(["charlie"]); + + // user message + expect(isGroupedSystemMessage(result[3])).toBe(false); + + // left group + expect(isGroupedSystemMessage(result[4])).toBe(true); + expect((result[4] as GroupedSystemMessage).content).toBe("left"); + expect((result[4] as GroupedSystemMessage).authors).toEqual([ + "dave", + "eve", + ]); + }); + }); + + describe("timestamp preservation", () => { + it("should use first message timestamp in group", () => { + const messages: Message[] = [ + createMessage("1", "system", "reposted", "alice", 1000), + createMessage("2", "system", "reposted", "bob", 2000), + createMessage("3", "system", "reposted", "charlie", 3000), + ]; + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + const group = result[0] as GroupedSystemMessage; + expect(group.timestamp).toBe(1000); // Should be first message timestamp + }); + }); + + describe("large groups", () => { + it("should handle large groups efficiently", () => { + const messages: Message[] = []; + for (let i = 0; i < 100; i++) { + messages.push( + createMessage(`${i}`, "system", "reposted", `user${i}`, 1000 + i), + ); + } + + const result = groupSystemMessages(messages); + + expect(result).toHaveLength(1); + const group = result[0] as GroupedSystemMessage; + expect(group.authors).toHaveLength(100); + expect(group.messageIds).toHaveLength(100); + }); + }); +}); + +describe("isGroupedSystemMessage", () => { + it("should return true for valid grouped system message", () => { + const group: GroupedSystemMessage = { + authors: ["alice", "bob"], + content: "reposted", + timestamp: 1000, + messageIds: ["1", "2"], + }; + + expect(isGroupedSystemMessage(group)).toBe(true); + }); + + it("should return false for regular message", () => { + const message: Message = createMessage("1", "user", "hello", "alice", 1000); + + expect(isGroupedSystemMessage(message)).toBe(false); + }); + + it("should return false for null", () => { + expect(isGroupedSystemMessage(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isGroupedSystemMessage(undefined)).toBe(false); + }); + + it("should return false for non-object types", () => { + expect(isGroupedSystemMessage("string")).toBe(false); + expect(isGroupedSystemMessage(123)).toBe(false); + expect(isGroupedSystemMessage(true)).toBe(false); + }); + + it("should return false for objects missing required fields", () => { + expect(isGroupedSystemMessage({})).toBe(false); + expect(isGroupedSystemMessage({ authors: [] })).toBe(false); + expect(isGroupedSystemMessage({ authors: [], content: "test" })).toBe( + false, + ); + }); + + it("should return false if authors is not an array", () => { + expect( + isGroupedSystemMessage({ + authors: "alice", + content: "reposted", + timestamp: 1000, + messageIds: ["1"], + }), + ).toBe(false); + }); + + it("should return false if authors array is empty", () => { + expect( + isGroupedSystemMessage({ + authors: [], + content: "reposted", + timestamp: 1000, + messageIds: [], + }), + ).toBe(false); + }); + + it("should return false if messageIds is not an array", () => { + expect( + isGroupedSystemMessage({ + authors: ["alice"], + content: "reposted", + timestamp: 1000, + messageIds: "1", + }), + ).toBe(false); + }); + + it("should return false if messageIds array is empty", () => { + expect( + isGroupedSystemMessage({ + authors: ["alice"], + content: "reposted", + timestamp: 1000, + messageIds: [], + }), + ).toBe(false); + }); + + it("should return false if authors and messageIds length mismatch", () => { + expect( + isGroupedSystemMessage({ + authors: ["alice", "bob"], + content: "reposted", + timestamp: 1000, + messageIds: ["1"], // Only 1 ID for 2 authors + }), + ).toBe(false); + }); + + it("should return false if content is not a string", () => { + expect( + isGroupedSystemMessage({ + authors: ["alice"], + content: 123, + timestamp: 1000, + messageIds: ["1"], + }), + ).toBe(false); + }); + + it("should return false if timestamp is not a number", () => { + expect( + isGroupedSystemMessage({ + authors: ["alice"], + content: "reposted", + timestamp: "1000", + messageIds: ["1"], + }), + ).toBe(false); + }); +}); diff --git a/src/lib/chat/group-system-messages.ts b/src/lib/chat/group-system-messages.ts new file mode 100644 index 0000000..a153a52 --- /dev/null +++ b/src/lib/chat/group-system-messages.ts @@ -0,0 +1,95 @@ +import type { Message } from "@/types/chat"; + +/** + * Grouped system message - multiple users doing the same action + */ +export interface GroupedSystemMessage { + authors: string[]; // pubkeys of users who performed the action + content: string; // action text (e.g., "reposted", "joined", "left") + timestamp: number; // timestamp of the first message in the group + messageIds: string[]; // IDs of all messages in the group +} + +/** + * Helper: Group consecutive system messages with the same content + * + * Takes a list of messages and groups consecutive system messages that have + * the same action (content). Non-system messages break the grouping. + * + * @example + * Input: [ + * { type: "system", content: "reposted", author: "alice" }, + * { type: "system", content: "reposted", author: "bob" }, + * { type: "user", content: "hello" }, + * { type: "system", content: "reposted", author: "charlie" } + * ] + * + * Output: [ + * { authors: ["alice", "bob"], content: "reposted", ... }, + * { type: "user", content: "hello" }, + * { authors: ["charlie"], content: "reposted", ... } + * ] + */ +export function groupSystemMessages( + messages: Message[], +): Array { + const result: Array = []; + let currentGroup: GroupedSystemMessage | null = null; + + for (const message of messages) { + // Only group system messages (not user or zap messages) + if (message.type === "system") { + // Check if we can add to current group + if (currentGroup && currentGroup.content === message.content) { + // Add to existing group + currentGroup.authors.push(message.author); + currentGroup.messageIds.push(message.id); + } else { + // Finalize current group if exists + if (currentGroup) { + result.push(currentGroup); + } + // Start new group + currentGroup = { + authors: [message.author], + content: message.content, + timestamp: message.timestamp, + messageIds: [message.id], + }; + } + } else { + // Non-system message - finalize any pending group + if (currentGroup) { + result.push(currentGroup); + currentGroup = null; + } + result.push(message); + } + } + + // Don't forget the last group if exists + if (currentGroup) { + result.push(currentGroup); + } + + return result; +} + +/** + * Type guard to check if item is a grouped system message + */ +export function isGroupedSystemMessage( + item: unknown, +): item is GroupedSystemMessage { + if (!item || typeof item !== "object") return false; + const obj = item as Record; + return ( + Array.isArray(obj.authors) && + obj.authors.length > 0 && + typeof obj.content === "string" && + typeof obj.timestamp === "number" && + Array.isArray(obj.messageIds) && + obj.messageIds.length > 0 && + obj.authors.length === obj.messageIds.length + ); +}