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
This commit is contained in:
Claude
2026-01-12 11:15:46 +00:00
parent 385f599b67
commit 3c4ce4956e
6 changed files with 800 additions and 12 deletions

View File

@@ -9,9 +9,11 @@ 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 +22,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 {
@@ -331,9 +334,16 @@ 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;
if (!conversation) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
@@ -348,11 +358,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">
{/* Live activity status badge */}
{liveActivity?.status && (
<StatusBadge status={liveActivity.status} size="sm" />
)}
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
<h2 className="flex-1 text-base font-semibold">
<h2 className="flex-1 text-base font-semibold truncate">
{customTitle || conversation.title}
</h2>
{conversation.metadata?.description && (
{/* 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>
@@ -362,7 +387,8 @@ export function ChatViewer({
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={conversation.participants} />
<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 +487,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 +500,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

@@ -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,526 @@
import { Observable } 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 { 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" });
}
// Determine relays for chat - prefer activity's relays tag, fallback to relay hints
const chatRelays =
activity.relays.length > 0 ? activity.relays : relayHints;
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[];
}
| 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`,
);
// Subscribe to live chat messages (kind 1311)
const filter: Filter = {
kinds: [1311],
"#a": [aTagValue],
limit: options?.limit || 50,
};
if (options?.before) {
filter.until = options.before;
}
if (options?.after) {
filter.since = options.after;
}
// Start a persistent subscription to the relays
pool
.subscription(relays, [filter], {
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)}...`,
);
}
},
});
// Return observable from EventStore which will update automatically
return eventStore.timeline(filter).pipe(
map((events) => {
console.log(`[NIP-53] Timeline has ${events.length} messages`);
return events
.map((event) => this.eventToMessage(event, conversation.id))
.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,
};
}
}

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;