From 5bc89386ead228eac80661b8a014d9b28f507836 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Mon, 12 Jan 2026 13:05:09 +0100 Subject: [PATCH] Add NIP-53 live event chat adapter (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add NIP-53 live activity chat adapter Add support for joining live stream chat via naddr (kind 30311): - Create Nip53Adapter with parseIdentifier, resolveConversation, loadMessages, sendMessage - Show live activity status badge (LIVE/UPCOMING/ENDED) in chat header - Display host name and stream metadata from the live activity event - Support kind 1311 live chat messages with a-tag references - Use relays from activity's relays tag or naddr relay hints - Add tests for adapter identifier parsing and chat-parser integration * ui: derive live chat participants from messages, icon-only status badge - Derive participants list from unique pubkeys in chat messages for NIP-53 - Move status badge after title with hideLabel for compact icon-only display * feat: show zaps in NIP-53 live chat with gradient border - Fetch kind 9735 zaps with #a tag matching the live activity - Combine zaps and chat messages in the timeline, sorted by timestamp - Display zap messages with gradient border (yellow → orange → purple → cyan) - Show zapper, amount, recipient, and optional comment - Add "zap" message type with zapAmount and zapRecipient metadata * fix: use RichText for zap comments and remove arrow in chat - Use RichText with zap request event for zap comments (renders emoji tags) - Remove the arrow (→) between zapper and recipient in zap messages * refactor: simplify zap message rendering in chat - Put timestamp right next to recipient (removed ml-auto) - Use RichText with content prop and event for emoji resolution - Inline simple expressions, remove unnecessary variables - Follow codebase patterns from ZapCompactPreview * docs: update chat command to include NIP-53 live activity - Update synopsis to use generic - Add NIP-53 live activity chat to description - Update option description to cover both protocols - Add naddr example for live activity chat - Add 'live' to seeAlso references * fix: use host outbox relays for NIP-53 live chat events Combine activity relays, naddr hints, and host's outbox relays when subscribing to chat messages and zaps. This ensures events are fetched from all relevant sources where they may be published. * ui: show host first in members list, all relays in dropdown - derivedParticipants now puts host first with 'host' role - Other participants from messages follow as 'member' - RelaysDropdown shows all NIP-53 liveActivity.relays --------- Co-authored-by: Claude --- src/components/ChatViewer.tsx | 125 +++- src/components/chat/RelaysDropdown.tsx | 11 +- src/lib/chat-parser.test.ts | 69 ++- src/lib/chat-parser.ts | 12 +- src/lib/chat/adapters/nip-53-adapter.test.ts | 150 +++++ src/lib/chat/adapters/nip-53-adapter.ts | 605 +++++++++++++++++++ src/types/chat.ts | 24 +- src/types/man.ts | 16 +- 8 files changed, 985 insertions(+), 27 deletions(-) create mode 100644 src/lib/chat/adapters/nip-53-adapter.test.ts create mode 100644 src/lib/chat/adapters/nip-53-adapter.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 8216063..8cf190a 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -2,16 +2,19 @@ import { useMemo, useState, memo, useCallback, useRef } from "react"; import { use$ } from "applesauce-react/hooks"; import { from } from "rxjs"; import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; -import { Reply } from "lucide-react"; +import { Reply, Zap } from "lucide-react"; +import { getZapRequest } from "applesauce-common/helpers/zap"; import accountManager from "@/services/accounts"; import eventStore from "@/services/event-store"; import type { ChatProtocol, ProtocolIdentifier, Conversation, + LiveActivityMetadata, } from "@/types/chat"; // import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter"; +import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter"; import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter"; import type { Message } from "@/types/chat"; import { UserName } from "./nostr/UserName"; @@ -20,6 +23,7 @@ import Timestamp from "./Timestamp"; import { ReplyPreview } from "./chat/ReplyPreview"; import { MembersDropdown } from "./chat/MembersDropdown"; import { RelaysDropdown } from "./chat/RelaysDropdown"; +import { StatusBadge } from "./live/StatusBadge"; import { useGrimoire } from "@/core/state"; import { Button } from "./ui/button"; import { @@ -168,6 +172,55 @@ const MessageItem = memo(function MessageItem({ ); } + // Zap messages have special styling with gradient border + if (message.type === "zap") { + const zapRequest = message.event ? getZapRequest(message.event) : null; + + return ( +
+
+
+
+ + + + {(message.metadata?.zapAmount || 0).toLocaleString("en", { + notation: "compact", + })} + + {message.metadata?.zapRecipient && ( + + )} + + + +
+ {message.content && ( + + )} +
+
+
+ ); + } + // Regular user messages return (
@@ -331,9 +384,47 @@ export function ChatViewer({ const handleNipClick = useCallback(() => { if (conversation?.protocol === "nip-29") { addWindow("nip", { number: 29 }); + } else if (conversation?.protocol === "nip-53") { + addWindow("nip", { number: 53 }); } }, [conversation?.protocol, addWindow]); + // Get live activity metadata if this is a NIP-53 chat + const liveActivity = conversation?.metadata?.liveActivity as + | LiveActivityMetadata + | undefined; + + // Derive participants from messages for live activities (unique pubkeys who have chatted) + const derivedParticipants = useMemo(() => { + if (conversation?.type !== "live-chat" || !messages) { + return conversation?.participants || []; + } + + const hostPubkey = liveActivity?.hostPubkey; + const participants: { pubkey: string; role: "host" | "member" }[] = []; + + // Host always first + if (hostPubkey) { + participants.push({ pubkey: hostPubkey, role: "host" }); + } + + // Add other participants from messages (excluding host) + const seen = new Set(hostPubkey ? [hostPubkey] : []); + for (const msg of messages) { + if (msg.type !== "system" && !seen.has(msg.author)) { + seen.add(msg.author); + participants.push({ pubkey: msg.author, role: "member" }); + } + } + + return participants; + }, [ + conversation?.type, + conversation?.participants, + messages, + liveActivity?.hostPubkey, + ]); + if (!conversation) { return (
@@ -348,11 +439,26 @@ export function ChatViewer({
-
-

+
+

{customTitle || conversation.title}

- {conversation.metadata?.description && ( + {/* Live activity status badge - small, icon only */} + {liveActivity?.status && ( + + )} + {/* Show host for live activities */} + {liveActivity?.hostPubkey && ( + + by{" "} + + + )} + {/* Show description for groups */} + {!liveActivity && conversation.metadata?.description && (

{conversation.metadata.description}

@@ -360,9 +466,10 @@ export function ChatViewer({

- + - {conversation.type === "group" && ( + {(conversation.type === "group" || + conversation.type === "live-chat") && (