feat: add repost system messages with grouping in chat (#194)

* feat: add repost system messages with grouping in chat

- Add kind 6 and 16 (reposts) to NIP-10 adapter filters
- Convert simple reposts (no content) to system messages
- Implement consecutive system message grouping
  - Groups consecutive system messages with same action
  - Format: "alice, bob and 3 others reposted"
  - Works for all system messages (reposts, join, leave, etc.)
- Update scroll-to-message to handle grouped messages
- Ignore reposts with content (quotes) to keep it simple

System message grouping UX:
- 1 person: "alice reposted"
- 2 people: "alice and bob reposted"
- 3 people: "alice, bob and charlie reposted"
- 4+ people: "alice, bob and 3 others reposted"

* feat: show all reposts regardless of content

Remove content filtering for reposts - now all kind 6 and 16 events
are shown as system messages. Quote reposts (with content) will just
display as 'reposted' without showing the quote text, keeping the
chat interface clean and consistent.

* 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.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-22 13:39:15 +01:00
committed by GitHub
parent b3aaabfd5c
commit 93ffd365f5
4 changed files with 652 additions and 13 deletions

View File

@@ -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";
@@ -252,6 +257,58 @@ const ComposerReplyPreview = memo(function ComposerReplyPreview({
);
});
/**
* GroupedSystemMessageItem - Renders multiple users performing the same action
* Example: "alice, bob and 3 others reposted"
*/
const GroupedSystemMessageItem = memo(function GroupedSystemMessageItem({
grouped,
}: {
grouped: GroupedSystemMessage;
}) {
const { authors, content } = grouped;
// Format the authors list based on count
const formatAuthors = () => {
if (authors.length === 1) {
return <UserName pubkey={authors[0]} className="text-xs" />;
} else if (authors.length === 2) {
return (
<>
<UserName pubkey={authors[0]} className="text-xs" /> and{" "}
<UserName pubkey={authors[1]} className="text-xs" />
</>
);
} else if (authors.length === 3) {
return (
<>
<UserName pubkey={authors[0]} className="text-xs" />,{" "}
<UserName pubkey={authors[1]} className="text-xs" /> and{" "}
<UserName pubkey={authors[2]} className="text-xs" />
</>
);
} else {
// 4 or more: show first 2 and "X others"
const othersCount = authors.length - 2;
return (
<>
<UserName pubkey={authors[0]} className="text-xs" />,{" "}
<UserName pubkey={authors[1]} className="text-xs" /> and {othersCount}{" "}
{othersCount === 1 ? "other" : "others"}
</>
);
}
};
return (
<div className="flex items-center px-3 py-1">
<span className="text-xs text-muted-foreground">
* {formatAuthors()} {content}
</span>
</div>
);
});
/**
* MessageItem - Memoized message component for performance
*/
@@ -547,36 +604,51 @@ export function ChatViewer({
[adapter, conversation],
);
// Process messages to include day markers
// Process messages to include day markers and group system messages
const messagesWithMarkers = useMemo(() => {
if (!messages || messages.length === 0) return [];
// First, group consecutive system messages
const groupedMessages = groupSystemMessages(messages);
const items: Array<
| { type: "message"; data: Message }
| { type: "grouped-system"; data: GroupedSystemMessage }
| { type: "day-marker"; data: string; timestamp: number }
> = [];
messages.forEach((message, index) => {
groupedMessages.forEach((item, index) => {
const timestamp = isGroupedSystemMessage(item)
? item.timestamp
: item.timestamp;
// Add day marker if this is the first message or if day changed
if (index === 0) {
items.push({
type: "day-marker",
data: formatDayMarker(message.timestamp),
timestamp: message.timestamp,
data: formatDayMarker(timestamp),
timestamp,
});
} else {
const prevMessage = messages[index - 1];
if (isDifferentDay(prevMessage.timestamp, message.timestamp)) {
const prevItem = groupedMessages[index - 1];
const prevTimestamp = isGroupedSystemMessage(prevItem)
? prevItem.timestamp
: prevItem.timestamp;
if (isDifferentDay(prevTimestamp, timestamp)) {
items.push({
type: "day-marker",
data: formatDayMarker(message.timestamp),
timestamp: message.timestamp,
data: formatDayMarker(timestamp),
timestamp,
});
}
}
// Add the message itself
items.push({ type: "message", data: message });
// Add the message or grouped system message
if (isGroupedSystemMessage(item)) {
items.push({ type: "grouped-system", data: item });
} else {
items.push({ type: "message", data: item });
}
});
return items;
@@ -700,9 +772,12 @@ export function ChatViewer({
const handleScrollToMessage = useCallback(
(messageId: string) => {
if (!messagesWithMarkers) return;
// Find index in the rendered array (which includes day markers)
// Find index in the rendered array (which includes day markers and grouped messages)
const index = messagesWithMarkers.findIndex(
(item) => item.type === "message" && item.data.id === messageId,
(item) =>
(item.type === "message" && item.data.id === messageId) ||
(item.type === "grouped-system" &&
item.data.messageIds.includes(messageId)),
);
if (index !== -1 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
@@ -1031,6 +1106,16 @@ export function ChatViewer({
</div>
);
}
if (item.type === "grouped-system") {
return (
<GroupedSystemMessageItem
key={item.data.messageIds.join("-")}
grouped={item.data}
/>
);
}
// For NIP-10 threads, check if this is the root message
const isRootMessage =
protocol === "nip-10" &&

View File

@@ -195,7 +195,9 @@ export class Nip10Adapter extends ChatProtocolAdapter {
// Build filter for all thread events:
// - kind 1: replies to root
// - kind 6: reposts (legacy)
// - kind 7: reactions
// - kind 16: generic reposts
// - kind 9735: zap receipts
const filters: Filter[] = [
// Replies: kind 1 events with e-tag pointing to root
@@ -210,6 +212,12 @@ export class Nip10Adapter extends ChatProtocolAdapter {
"#e": [rootEventId],
limit: 200, // Reactions are small, fetch more
},
// Reposts: kind 6 and 16 events with e-tag pointing to root or replies
{
kinds: [6, 16],
"#e": [rootEventId],
limit: 100,
},
// Zaps: kind 9735 receipts with e-tag pointing to root or replies
{
kinds: [9735],
@@ -245,7 +253,7 @@ export class Nip10Adapter extends ChatProtocolAdapter {
// Combine root event with replies
const rootEvent$ = eventStore.event(rootEventId);
const replies$ = eventStore.timeline({
kinds: [1, 7, 9735],
kinds: [1, 6, 7, 16, 9735],
"#e": [rootEventId],
});
@@ -308,6 +316,12 @@ export class Nip10Adapter extends ChatProtocolAdapter {
until: before,
limit: 100,
},
{
kinds: [6, 16],
"#e": [rootEventId],
until: before,
limit: 50,
},
{
kinds: [9735],
"#e": [rootEventId],
@@ -802,6 +816,12 @@ export class Nip10Adapter extends ChatProtocolAdapter {
return this.zapToMessage(event, conversationId);
}
// Handle reposts (kind 6, 16) - simple system messages
// Content is ignored even if present (quotes)
if (event.kind === 6 || event.kind === 16) {
return this.repostToMessage(event, conversationId);
}
// Handle reactions (kind 7) - skip for now, handled via MessageReactions
if (event.kind === 7) {
return null;
@@ -892,4 +912,29 @@ export class Nip10Adapter extends ChatProtocolAdapter {
event: zapReceipt,
};
}
/**
* Convert repost event to system Message object
*/
private repostToMessage(
repostEvent: NostrEvent,
conversationId: string,
): Message {
// Find what event is being reposted (e-tag)
const eTag = repostEvent.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
return {
id: repostEvent.id,
conversationId,
author: repostEvent.pubkey,
content: "reposted",
timestamp: repostEvent.created_at,
type: "system",
replyTo,
protocol: "nip-10",
metadata: {},
event: repostEvent,
};
}
}

View File

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

View File

@@ -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<Message | GroupedSystemMessage> {
const result: Array<Message | GroupedSystemMessage> = [];
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<string, unknown>;
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
);
}