From 9856a0c243b040ccc86d8a4a61391d1c884e982f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 22:35:14 +0000 Subject: [PATCH] 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" --- src/components/ChatViewer.tsx | 177 ++++++++++++++++++++++-- src/lib/chat/adapters/nip-10-adapter.ts | 47 ++++++- 2 files changed, 211 insertions(+), 13 deletions(-) diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 1f07619..1158148 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -71,6 +71,79 @@ 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 */ @@ -252,6 +325,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 ; + } else if (authors.length === 2) { + return ( + <> + and{" "} + + + ); + } else if (authors.length === 3) { + return ( + <> + ,{" "} + and{" "} + + + ); + } else { + // 4 or more: show first 2 and "X others" + const othersCount = authors.length - 2; + return ( + <> + ,{" "} + and {othersCount}{" "} + {othersCount === 1 ? "other" : "others"} + + ); + } + }; + + return ( +
+ + * {formatAuthors()} {content} + +
+ ); +}); + /** * MessageItem - Memoized message component for performance */ @@ -547,36 +672,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 +840,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 +1174,16 @@ export function ChatViewer({ ); } + + if (item.type === "grouped-system") { + return ( + + ); + } + // For NIP-10 threads, check if this is the root message const isRootMessage = protocol === "nip-10" && diff --git a/src/lib/chat/adapters/nip-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts index 19beac7..5cfefb2 100644 --- a/src/lib/chat/adapters/nip-10-adapter.ts +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -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 + // Ignore reposts with content (quotes) + if ((event.kind === 6 || event.kind === 16) && !event.content.trim()) { + 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, + }; + } }