mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 18:21:28 +02:00
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:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
150
src/lib/chat/adapters/nip-53-adapter.test.ts
Normal file
150
src/lib/chat/adapters/nip-53-adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
526
src/lib/chat/adapters/nip-53-adapter.ts
Normal file
526
src/lib/chat/adapters/nip-53-adapter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user