Add NIP-53 live event chat adapter (#56)

* 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 <identifier>
- 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-12 13:05:09 +01:00
committed by GitHub
parent 59e79959a6
commit 5bc89386ea
8 changed files with 985 additions and 27 deletions

View File

@@ -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 (
<div className="px-3 py-1">
<div
className="rounded-lg p-[1px]"
style={{
background:
"linear-gradient(to right, rgb(250 204 21), rgb(251 146 60), rgb(168 85 247), rgb(34 211 238))",
}}
>
<div className="rounded-lg bg-background px-3 py-1.5">
<div className="flex items-center gap-2">
<UserName
pubkey={message.author}
className="font-semibold text-sm"
/>
<Zap className="size-4 fill-yellow-500 text-yellow-500" />
<span className="text-yellow-500 font-bold">
{(message.metadata?.zapAmount || 0).toLocaleString("en", {
notation: "compact",
})}
</span>
{message.metadata?.zapRecipient && (
<UserName
pubkey={message.metadata.zapRecipient}
className="text-sm"
/>
)}
<span className="text-xs text-muted-foreground">
<Timestamp timestamp={message.timestamp} />
</span>
</div>
{message.content && (
<RichText
content={message.content}
event={zapRequest || undefined}
className="mt-1 text-sm leading-tight break-words"
options={{ showMedia: false, showEventEmbeds: false }}
/>
)}
</div>
</div>
</div>
);
}
// Regular user messages
return (
<div className="group flex items-start hover:bg-muted/50 px-3">
@@ -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 (
<div className="flex h-full items-center justify-center text-muted-foreground">
@@ -348,11 +439,26 @@ export function ChatViewer({
<div className="px-4 border-b w-full py-0.5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-1 min-w-0 items-center gap-2">
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
<h2 className="flex-1 text-base font-semibold">
<div className="flex-1 flex flex-row gap-2 items-center min-w-0">
<h2 className="text-base font-semibold truncate">
{customTitle || conversation.title}
</h2>
{conversation.metadata?.description && (
{/* Live activity status badge - small, icon only */}
{liveActivity?.status && (
<StatusBadge status={liveActivity.status} size="sm" hideLabel />
)}
{/* Show host for live activities */}
{liveActivity?.hostPubkey && (
<span className="text-xs text-muted-foreground flex-shrink-0">
by{" "}
<UserName
pubkey={liveActivity.hostPubkey}
className="text-xs"
/>
</span>
)}
{/* Show description for groups */}
{!liveActivity && conversation.metadata?.description && (
<p className="text-xs text-muted-foreground line-clamp-1">
{conversation.metadata.description}
</p>
@@ -360,9 +466,10 @@ export function ChatViewer({
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={conversation.participants} />
<MembersDropdown participants={derivedParticipants} />
<RelaysDropdown conversation={conversation} />
{conversation.type === "group" && (
{(conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
@@ -461,7 +568,7 @@ export function ChatViewer({
/**
* Get the appropriate adapter for a protocol
* Currently only NIP-29 (relay-based groups) is supported
* Currently NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
@@ -474,8 +581,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
// return new Nip17Adapter();
// case "nip-28": // Phase 3 - Public channels (coming soon)
// return new Nip28Adapter();
// case "nip-53": // Phase 5 - Live activity chat (coming soon)
// return new Nip53Adapter();
case "nip-53":
return new Nip53Adapter();
default:
throw new Error(`Unsupported protocol: ${protocol}`);
}

View File

@@ -24,8 +24,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
// Get relays for this conversation
const relays: string[] = [];
// NIP-29: Single group relay
if (conversation.metadata?.relayUrl) {
// NIP-53: Multiple relays from liveActivity
const liveActivityRelays = conversation.metadata?.liveActivity?.relays as
| string[]
| undefined;
if (liveActivityRelays?.length) {
relays.push(...liveActivityRelays);
}
// NIP-29: Single group relay (fallback)
else if (conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { nip19 } from "nostr-tools";
import { parseChatCommand } from "./chat-parser";
describe("parseChatCommand", () => {
@@ -100,10 +101,76 @@ describe("parseChatCommand", () => {
);
});
it("should throw error for naddr (NIP-53 not implemented)", () => {
it("should throw error for malformed naddr", () => {
expect(() => parseChatCommand(["naddr1xyz"])).toThrow(
/Unable to determine chat protocol/,
);
});
});
describe("NIP-53 live activity chat", () => {
it("should parse NIP-53 live activity naddr", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-stream",
relays: ["wss://relay.example.com"],
});
const result = parseChatCommand([naddr]);
expect(result.protocol).toBe("nip-53");
expect(result.identifier).toEqual({
type: "live-activity",
value: {
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-stream",
},
relays: ["wss://relay.example.com"],
});
expect(result.adapter.protocol).toBe("nip-53");
});
it("should parse NIP-53 live activity naddr with multiple relays", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "podcast-episode-42",
relays: ["wss://relay1.example.com", "wss://relay2.example.com"],
});
const result = parseChatCommand([naddr]);
expect(result.protocol).toBe("nip-53");
expect(result.identifier.value).toEqual({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "podcast-episode-42",
});
expect(result.identifier.relays).toEqual([
"wss://relay1.example.com",
"wss://relay2.example.com",
]);
});
it("should not parse NIP-29 group naddr as NIP-53", () => {
const naddr = nip19.naddrEncode({
kind: 39000,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "test-group",
relays: ["wss://relay.example.com"],
});
// NIP-29 adapter should handle kind 39000
const result = parseChatCommand([naddr]);
expect(result.protocol).toBe("nip-29");
});
});
});

View File

@@ -1,10 +1,10 @@
import type { ChatCommandResult } from "@/types/chat";
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
// Import other adapters as they're implemented
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
// import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
/**
* Parse a chat command identifier and auto-detect the protocol
@@ -38,8 +38,8 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
const adapters = [
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // Phase 4 - Relay groups (currently only enabled)
// new Nip53Adapter(), // Phase 5
new Nip29Adapter(), // Phase 4 - Relay groups
new Nip53Adapter(), // Phase 5 - Live activity chat
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
];
@@ -65,10 +65,12 @@ Currently supported formats:
- naddr1... (NIP-29 group metadata, kind 39000)
Example:
chat naddr1qqxnzdesxqmnxvpexqmny...
- naddr1... (NIP-53 live activity chat, kind 30311)
Example:
chat naddr1... (live stream address)
More formats coming soon:
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
- note/nevent (NIP-28 public channels)
- naddr (NIP-53 live activity chat)`,
- note/nevent (NIP-28 public channels)`,
);
}

View File

@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { nip19 } from "nostr-tools";
import { Nip53Adapter } from "./nip-53-adapter";
describe("Nip53Adapter", () => {
const adapter = new Nip53Adapter();
describe("parseIdentifier", () => {
it("should parse kind 30311 naddr (live activity)", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-live-stream",
relays: ["wss://relay.example.com"],
});
const result = adapter.parseIdentifier(naddr);
expect(result).toEqual({
type: "live-activity",
value: {
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "my-live-stream",
},
relays: ["wss://relay.example.com"],
});
});
it("should handle naddr with multiple relays", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "podcast-episode",
relays: [
"wss://relay1.example.com",
"wss://relay2.example.com",
"wss://relay3.example.com",
],
});
const result = adapter.parseIdentifier(naddr);
expect(result).toEqual({
type: "live-activity",
value: {
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "podcast-episode",
},
relays: [
"wss://relay1.example.com",
"wss://relay2.example.com",
"wss://relay3.example.com",
],
});
});
it("should handle naddr without relay hints", () => {
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "stream-id",
relays: [],
});
const result = adapter.parseIdentifier(naddr);
expect(result).toEqual({
type: "live-activity",
value: {
kind: 30311,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "stream-id",
},
relays: [],
});
});
it("should return null for non-30311 kind naddr", () => {
// kind 39000 (NIP-29 group) should not work for NIP-53
const naddr = nip19.naddrEncode({
kind: 39000,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "some-group",
relays: ["wss://relay.example.com"],
});
expect(adapter.parseIdentifier(naddr)).toBeNull();
});
it("should return null for other naddr kinds", () => {
// kind 30023 (long-form article) should not work
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000001",
identifier: "article-slug",
relays: ["wss://relay.example.com"],
});
expect(adapter.parseIdentifier(naddr)).toBeNull();
});
it("should return null for malformed naddr", () => {
expect(adapter.parseIdentifier("naddr1invaliddata")).toBeNull();
});
it("should return null for non-naddr formats", () => {
// These should not match NIP-53 format
expect(adapter.parseIdentifier("")).toBeNull();
expect(adapter.parseIdentifier("npub1...")).toBeNull();
expect(adapter.parseIdentifier("note1...")).toBeNull();
expect(adapter.parseIdentifier("nevent1...")).toBeNull();
expect(adapter.parseIdentifier("relay.example.com'group")).toBeNull();
expect(adapter.parseIdentifier("alice@example.com")).toBeNull();
});
it("should return null for random strings", () => {
expect(adapter.parseIdentifier("just-a-string")).toBeNull();
expect(adapter.parseIdentifier("naddr")).toBeNull();
expect(adapter.parseIdentifier("naddr1")).toBeNull();
});
});
describe("protocol properties", () => {
it("should have correct protocol and type", () => {
expect(adapter.protocol).toBe("nip-53");
expect(adapter.type).toBe("live-chat");
});
});
describe("getCapabilities", () => {
it("should return correct capabilities", () => {
const capabilities = adapter.getCapabilities();
expect(capabilities.supportsEncryption).toBe(false);
expect(capabilities.supportsThreading).toBe(true);
expect(capabilities.supportsModeration).toBe(false);
expect(capabilities.supportsRoles).toBe(true);
expect(capabilities.supportsGroupManagement).toBe(false);
expect(capabilities.canCreateConversations).toBe(false);
expect(capabilities.requiresRelay).toBe(false);
});
});
});

View File

@@ -0,0 +1,605 @@
import { Observable, combineLatest } from "rxjs";
import { map, first } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
Participant,
ParticipantRole,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import {
parseLiveActivity,
getLiveStatus,
getLiveHost,
} from "@/lib/live-activity";
import {
getZapAmount,
getZapRequest,
getZapSender,
isValidZap,
} from "applesauce-common/helpers/zap";
import { EventFactory } from "applesauce-core/event-factory";
/**
* NIP-53 Adapter - Live Activity Chat
*
* Features:
* - Live streaming event chat (kind 1311)
* - Public, unencrypted messages
* - Host, speaker, and participant roles
* - Multi-relay support (from relays tag or naddr hints)
*
* Identifier format: naddr1... (kind 30311 live activity address)
* Messages reference activity via "a" tag
*/
export class Nip53Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-53" as const;
readonly type = "live-chat" as const;
/**
* Parse identifier - accepts naddr format for kind 30311
* Examples:
* - naddr1... (kind 30311 live activity address)
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
if (!input.startsWith("naddr1")) {
return null;
}
try {
const decoded = nip19.decode(input);
if (decoded.type === "naddr" && decoded.data.kind === 30311) {
const { pubkey, identifier, relays } = decoded.data;
return {
type: "live-activity",
value: {
kind: 30311,
pubkey,
identifier,
},
relays: relays || [],
};
}
} catch {
// Not a valid naddr
}
return null;
}
/**
* Resolve conversation from live activity address
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
const { pubkey, identifier: dTag } = identifier.value as {
kind: number;
pubkey: string;
identifier: string;
};
const relayHints = identifier.relays || [];
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account");
}
console.log(
`[NIP-53] Fetching live activity ${dTag} by ${pubkey.slice(0, 8)}...`,
);
// Use author's outbox relays plus any relay hints
const authorOutboxes = await this.getAuthorOutboxes(pubkey);
const relays = [...new Set([...relayHints, ...authorOutboxes])];
if (relays.length === 0) {
throw new Error("No relays available to fetch live activity");
}
console.log(`[NIP-53] Using relays: ${relays.join(", ")}`);
// Fetch the kind 30311 live activity event
const activityFilter: Filter = {
kinds: [30311],
authors: [pubkey],
"#d": [dTag],
limit: 1,
};
const activityEvents: NostrEvent[] = [];
const activityObs = pool.subscription(relays, [activityFilter], {
eventStore,
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log("[NIP-53] Activity fetch timeout");
resolve();
}, 5000);
const sub = activityObs.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
clearTimeout(timeout);
console.log(
`[NIP-53] Got ${activityEvents.length} activity events`,
);
sub.unsubscribe();
resolve();
} else {
// Event received
activityEvents.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error("[NIP-53] Activity fetch error:", err);
sub.unsubscribe();
reject(err);
},
});
});
const activityEvent = activityEvents[0];
if (!activityEvent) {
throw new Error(`Live activity not found: ${pubkey.slice(0, 8)}:${dTag}`);
}
// Parse the live activity for rich metadata
const activity = parseLiveActivity(activityEvent);
const status = getLiveStatus(activityEvent);
const hostPubkey = getLiveHost(activityEvent);
// Map live activity roles to chat participant roles
const participants: Participant[] = activity.participants.map((p) => ({
pubkey: p.pubkey,
role: this.mapRole(p.role),
}));
// Ensure host is in participants list
if (!participants.some((p) => p.pubkey === hostPubkey)) {
participants.unshift({ pubkey: hostPubkey, role: "host" });
}
// Combine activity relays, relay hints, and host outboxes for comprehensive coverage
const chatRelays = [
...new Set([...activity.relays, ...relayHints, ...authorOutboxes]),
];
console.log(
`[NIP-53] Resolved: "${activity.title}" (${status}), ${participants.length} participants, ${chatRelays.length} relays`,
);
return {
id: `nip-53:${pubkey}:${dTag}`,
type: "live-chat",
protocol: "nip-53",
title: activity.title || "Live Activity",
participants,
metadata: {
activityAddress: {
kind: 30311,
pubkey,
identifier: dTag,
},
// Live activity specific metadata
relayUrl: chatRelays[0], // Primary relay for compatibility
description: activity.summary,
icon: activity.image,
// Extended live activity metadata
liveActivity: {
status,
streaming: activity.streaming,
recording: activity.recording,
starts: activity.starts,
ends: activity.ends,
hostPubkey,
currentParticipants: activity.currentParticipants,
totalParticipants: activity.totalParticipants,
hashtags: activity.hashtags,
relays: chatRelays,
},
},
unreadCount: 0,
};
}
/**
* Load messages for a live activity
*/
loadMessages(
conversation: Conversation,
options?: LoadMessagesOptions,
): Observable<Message[]> {
const activityAddress = conversation.metadata?.activityAddress;
const liveActivity = conversation.metadata?.liveActivity as
| {
relays?: string[];
hostPubkey?: string;
}
| undefined;
if (!activityAddress) {
throw new Error("Activity address required");
}
const { pubkey, identifier } = activityAddress;
const aTagValue = `30311:${pubkey}:${identifier}`;
// Get relays from live activity metadata or fall back to relayUrl
const relays = liveActivity?.relays || [];
if (relays.length === 0 && conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}
if (relays.length === 0) {
throw new Error("No relays available for live chat");
}
console.log(
`[NIP-53] Loading messages for ${aTagValue} from ${relays.length} relays`,
);
// Filter for live chat messages (kind 1311)
const chatFilter: Filter = {
kinds: [1311],
"#a": [aTagValue],
limit: options?.limit || 50,
};
// Filter for zaps (kind 9735) targeting this activity
const zapFilter: Filter = {
kinds: [9735],
"#a": [aTagValue],
limit: options?.limit || 50,
};
if (options?.before) {
chatFilter.until = options.before;
zapFilter.until = options.before;
}
if (options?.after) {
chatFilter.since = options.after;
zapFilter.since = options.after;
}
// Start persistent subscriptions to the relays for both chat and zaps
pool
.subscription(relays, [chatFilter], {
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-53] EOSE received for messages");
} else {
console.log(
`[NIP-53] Received message: ${response.id.slice(0, 8)}...`,
);
}
},
});
pool
.subscription(relays, [zapFilter], {
eventStore,
})
.subscribe({
next: (response) => {
if (typeof response === "string") {
console.log("[NIP-53] EOSE received for zaps");
} else {
console.log(`[NIP-53] Received zap: ${response.id.slice(0, 8)}...`);
}
},
});
// Combine chat messages and zaps from EventStore
const chatMessages$ = eventStore.timeline(chatFilter);
const zapMessages$ = eventStore.timeline(zapFilter);
return combineLatest([chatMessages$, zapMessages$]).pipe(
map(([chatEvents, zapEvents]) => {
const chatMsgs = chatEvents.map((event) =>
this.eventToMessage(event, conversation.id),
);
const zapMsgs = zapEvents
.filter((event) => isValidZap(event))
.map((event) => this.zapToMessage(event, conversation.id));
const allMessages = [...chatMsgs, ...zapMsgs];
console.log(
`[NIP-53] Timeline has ${chatMsgs.length} messages, ${zapMsgs.length} zaps`,
);
return allMessages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
_conversation: Conversation,
_before: number,
): Promise<Message[]> {
// Pagination to be implemented later
return [];
}
/**
* Send a message to the live activity chat
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const activityAddress = conversation.metadata?.activityAddress;
const liveActivity = conversation.metadata?.liveActivity as
| {
relays?: string[];
}
| undefined;
if (!activityAddress) {
throw new Error("Activity address required");
}
const { pubkey, identifier } = activityAddress;
const aTagValue = `30311:${pubkey}:${identifier}`;
// Get relays
const relays = liveActivity?.relays || [];
if (relays.length === 0 && conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}
if (relays.length === 0) {
throw new Error("No relays available for sending message");
}
// Create event factory and sign event
const factory = new EventFactory();
factory.setSigner(activeSigner);
// Build tags: a tag is required, e tag for replies
const tags: string[][] = [["a", aTagValue, relays[0] || ""]];
if (options?.replyTo) {
// NIP-53 uses e-tag for replies (NIP-10 style)
tags.push(["e", options.replyTo, relays[0] || "", "reply"]);
}
// Add NIP-30 emoji tags
if (options?.emojiTags) {
for (const emoji of options.emojiTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
}
// Use kind 1311 for live chat messages
const draft = await factory.build({ kind: 1311, content, tags });
const event = await factory.sign(draft);
// Publish to all activity relays
await publishEventToRelays(event, relays);
}
/**
* Get protocol capabilities
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: false, // kind 1311 messages are public
supportsThreading: true, // e-tag replies
supportsModeration: false, // No built-in moderation (host can pin)
supportsRoles: true, // Host, Speaker, Participant
supportsGroupManagement: false, // No join/leave semantics
canCreateConversations: false, // Activities created via streaming software
requiresRelay: false, // Works across multiple relays
};
}
/**
* Load a replied-to message
* First checks EventStore, then fetches from relays if needed
*/
async loadReplyMessage(
conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// First check EventStore
const cachedEvent = await eventStore
.event(eventId)
.pipe(first())
.toPromise();
if (cachedEvent) {
return cachedEvent;
}
// Not in store, fetch from activity relays
const liveActivity = conversation.metadata?.liveActivity as
| {
relays?: string[];
}
| undefined;
const relays = liveActivity?.relays || [];
if (relays.length === 0 && conversation.metadata?.relayUrl) {
relays.push(conversation.metadata.relayUrl);
}
if (relays.length === 0) {
console.warn("[NIP-53] No relays for loading reply message");
return null;
}
console.log(
`[NIP-53] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length} relays`,
);
const filter: Filter = {
ids: [eventId],
limit: 1,
};
const events: NostrEvent[] = [];
const obs = pool.subscription(relays, [filter], { eventStore });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log(
`[NIP-53] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
);
resolve();
}, 3000);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error(`[NIP-53] Reply message fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Helper: Get author's outbox relays via NIP-65
*/
private async getAuthorOutboxes(pubkey: string): Promise<string[]> {
try {
// Try to get from EventStore first (kind 10002)
const relayListEvent = await eventStore
.replaceable(10002, pubkey)
.pipe(first())
.toPromise();
if (relayListEvent) {
// Extract write relays from r tags
const writeRelays = relayListEvent.tags
.filter((t) => t[0] === "r" && (!t[2] || t[2] === "write"))
.map((t) => t[1])
.filter(Boolean);
if (writeRelays.length > 0) {
return writeRelays.slice(0, 3); // Limit to 3 relays
}
}
} catch {
// Fall through to defaults
}
// Default fallback relays for live activities
return ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"];
}
/**
* Helper: Map live activity role to chat participant role
*/
private mapRole(role: string): ParticipantRole {
const lower = role.toLowerCase();
if (lower === "host") return "host";
if (lower === "speaker") return "moderator"; // Speakers get elevated display
if (lower === "moderator") return "moderator";
return "member";
}
/**
* Helper: Convert Nostr event to Message
*/
private eventToMessage(event: NostrEvent, conversationId: string): Message {
// Look for reply e-tags (NIP-10 style)
const eTags = event.tags.filter((t) => t[0] === "e");
// Find the reply tag (has "reply" marker or is the last e-tag without marker)
const replyTag =
eTags.find((t) => t[3] === "reply") ||
eTags.find((t) => !t[3] && eTags.length === 1);
const replyTo = replyTag?.[1];
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo,
protocol: "nip-53",
metadata: {
encrypted: false,
},
event,
};
}
/**
* Helper: Convert zap receipt to Message
*/
private zapToMessage(event: NostrEvent, conversationId: string): Message {
const zapSender = getZapSender(event);
const zapAmount = getZapAmount(event);
const zapRequest = getZapRequest(event);
// Convert from msats to sats
const amountInSats = zapAmount ? Math.floor(zapAmount / 1000) : 0;
// Get zap comment from request
const zapComment = zapRequest?.content || "";
// The recipient is the pubkey in the p tag of the zap receipt
const pTag = event.tags.find((t) => t[0] === "p");
const zapRecipient = pTag?.[1] || event.pubkey;
return {
id: event.id,
conversationId,
author: zapSender || event.pubkey,
content: zapComment,
timestamp: event.created_at,
type: "zap",
protocol: "nip-53",
metadata: {
encrypted: false,
zapAmount: amountInSats,
zapRecipient,
},
event,
};
}
}

View File

@@ -24,6 +24,22 @@ export interface Participant {
permissions?: string[];
}
/**
* Live activity metadata for NIP-53
*/
export interface LiveActivityMetadata {
status: "planned" | "live" | "ended";
streaming?: string;
recording?: string;
starts?: number;
ends?: number;
hostPubkey: string;
currentParticipants?: number;
totalParticipants?: number;
hashtags: string[];
relays: string[];
}
/**
* Protocol-specific conversation metadata
*/
@@ -43,6 +59,7 @@ export interface ConversationMetadata {
pubkey: string;
identifier: string;
};
liveActivity?: LiveActivityMetadata;
// NIP-17 DM
encrypted?: boolean;
@@ -73,12 +90,15 @@ export interface MessageMetadata {
zaps?: NostrEvent[];
deleted?: boolean;
hidden?: boolean; // NIP-28 channel hide
// Zap-specific metadata (for type: "zap" messages)
zapAmount?: number; // Amount in sats
zapRecipient?: string; // Pubkey of zap recipient
}
/**
* Message type - system messages for events like join/leave, user messages for chat
* Message type - system messages for events like join/leave, user messages for chat, zaps for stream tips
*/
export type MessageType = "user" | "system";
export type MessageType = "user" | "system" | "zap";
/**
* Generic message abstraction

View File

@@ -348,22 +348,22 @@ export const manPages: Record<string, ManPageEntry> = {
chat: {
name: "chat",
section: "1",
synopsis: "chat <group-identifier>",
synopsis: "chat <identifier>",
description:
"Join and participate in NIP-29 relay-based group chats. Groups are hosted on a single relay that enforces membership and moderation rules. Use the format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional) and group-id is the group identifier.",
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups and NIP-53 live activity chat. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event to join its chat.",
options: [
{
flag: "<group-identifier>",
flag: "<identifier>",
description:
"NIP-29 group identifier in format: relay'group-id (wss:// prefix optional)",
"NIP-29 group (relay'group-id) or NIP-53 live activity (naddr1...)",
},
],
examples: [
"chat relay.example.com'bitcoin-dev Join relay group (wss:// prefix optional)",
"chat wss://relay.example.com'nostr-dev Join relay group with explicit protocol",
"chat nos.lol'welcome Join welcome group on nos.lol",
"chat relay.example.com'bitcoin-dev Join NIP-29 relay group",
"chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol",
"chat naddr1... Join NIP-53 live activity chat",
],
seeAlso: ["profile", "open", "req"],
seeAlso: ["profile", "open", "req", "live"],
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {