mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
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"
This commit is contained in:
@@ -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<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
|
||||
*/
|
||||
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) &&
|
||||
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 <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 +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({
|
||||
</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" &&
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user