mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
feat(chat): add Communikey fallback for NIP-29 groups
Implements Communikey chat support as a fallback for NIP-29 groups where: - Group ID is a valid pubkey (64-char hex) - Pubkey has published kind 10222 (community definition) - Chat works regardless of content sections (assumes chat is supported) Key changes: - Add Communikey protocol to chat types with CommunikeyIdentifier - Create CommunikeyAdapter extending ChatProtocolAdapter - Fetches kind 10222 (community definition) and kind 0 (profile) - Uses r-tags for multi-relay support (main + backups) - Community pubkey acts as admin, members derived from chat participants - Client-side moderation only (no relay enforcement) - Update chat parser to detect Communikey via kind 10222 lookup - Made parseChatCommand async for fallback detection - 2-second timeout for quick detection - Add kind 10222 feed and detail renderers - Feed renderer shows community card with name, description, content sections, relay count - Detail renderer shows full metadata, content sections, relays, features - Register Communikey adapter in ChatViewer getAdapter Technical notes: - Uses same kind 9 messages with #h tag (compatible with NIP-29 format) - No join/leave semantics (open participation) - Bookmark/unbookmark actions use kind 10009 group list - Multi-relay subscriptions for messages
This commit is contained in:
@@ -28,6 +28,7 @@ import { CHAT_KINDS } from "@/types/chat";
|
||||
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
|
||||
import { CommunikeyAdapter } from "@/lib/chat/adapters/communikey-adapter";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Message } from "@/types/chat";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
@@ -1138,7 +1139,7 @@ export function ChatViewer({
|
||||
|
||||
/**
|
||||
* Get the appropriate adapter for a protocol
|
||||
* Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
|
||||
* Currently NIP-10 (thread chat), NIP-29 (relay-based groups), Communikey, and NIP-53 (live activity chat) are supported
|
||||
* Other protocols will be enabled in future phases
|
||||
*/
|
||||
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
@@ -1149,6 +1150,8 @@ function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
case "communikey":
|
||||
return new CommunikeyAdapter();
|
||||
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
|
||||
// return new Nip17Adapter();
|
||||
// case "nip-28": // Phase 3 - Public channels (coming soon)
|
||||
|
||||
274
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
274
src/components/nostr/kinds/CommunikeyDetailRenderer.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue, getTagValues } from "@/lib/nostr-utils";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { UserName } from "../UserName";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
MessageSquare,
|
||||
Server,
|
||||
Shield,
|
||||
FileText,
|
||||
MapPin,
|
||||
Coins,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CommunikeyDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Communikey Community Definition events (kind 10222)
|
||||
* Shows full community metadata, content sections, relays, and features
|
||||
*/
|
||||
export function CommunikeyDetailRenderer({
|
||||
event,
|
||||
}: CommunikeyDetailRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get community pubkey (the event author = community admin)
|
||||
const communityPubkey = event.pubkey;
|
||||
|
||||
// Fetch community profile for name/picture
|
||||
const profile = useProfile(communityPubkey);
|
||||
|
||||
// Extract community metadata from kind 10222
|
||||
const descriptionOverride = getTagValue(event, "description");
|
||||
const relays = getTagValues(event, "r").filter((url) => url);
|
||||
const blossomServers = getTagValues(event, "blossom").filter((url) => url);
|
||||
const mints = getTagValues(event, "mint").filter((url) => url);
|
||||
const tosPointer = getTagValue(event, "tos");
|
||||
const location = getTagValue(event, "location");
|
||||
const geoHash = getTagValue(event, "g");
|
||||
|
||||
// Use profile metadata or fallback
|
||||
const name = profile?.name || communityPubkey.slice(0, 8);
|
||||
const about = descriptionOverride || profile?.about;
|
||||
const picture = profile?.picture;
|
||||
|
||||
// Parse content sections
|
||||
// Content sections are groups of tags between "content" tags
|
||||
const contentSections: Array<{
|
||||
name: string;
|
||||
kinds: number[];
|
||||
badges: string[];
|
||||
}> = [];
|
||||
|
||||
let currentSection: {
|
||||
name: string;
|
||||
kinds: number[];
|
||||
badges: string[];
|
||||
} | null = null;
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "content" && tag[1]) {
|
||||
// Save previous section if exists
|
||||
if (currentSection) {
|
||||
contentSections.push(currentSection);
|
||||
}
|
||||
// Start new section
|
||||
currentSection = {
|
||||
name: tag[1],
|
||||
kinds: [],
|
||||
badges: [],
|
||||
};
|
||||
} else if (currentSection) {
|
||||
// Add tags to current section
|
||||
if (tag[0] === "k" && tag[1]) {
|
||||
const kind = parseInt(tag[1], 10);
|
||||
if (!isNaN(kind)) {
|
||||
currentSection.kinds.push(kind);
|
||||
}
|
||||
} else if (tag[0] === "a" && tag[1]) {
|
||||
currentSection.badges.push(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection) {
|
||||
contentSections.push(currentSection);
|
||||
}
|
||||
|
||||
const handleOpenChat = () => {
|
||||
if (!relays.length) return;
|
||||
|
||||
addWindow("chat", {
|
||||
protocol: "communikey",
|
||||
identifier: {
|
||||
type: "communikey",
|
||||
value: communityPubkey,
|
||||
relays,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const canOpenChat = relays.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background overflow-y-auto">
|
||||
{/* Header with picture */}
|
||||
{picture && (
|
||||
<div className="relative aspect-[3/1] flex-shrink-0">
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/90 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Title and Admin */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-balance">{name}</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Shield className="size-4" />
|
||||
<span>Admin:</span>
|
||||
<UserName pubkey={communityPubkey} className="text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{about && (
|
||||
<p className="text-base text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{about}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
<span>{location}</span>
|
||||
{geoHash && <span className="text-xs">({geoHash})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Content Sections</h2>
|
||||
<div className="space-y-2">
|
||||
{contentSections.map((section, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-3 border border-border rounded bg-muted/30 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{section.name}</span>
|
||||
{section.kinds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{section.kinds.map((kind) => (
|
||||
<Label key={kind} size="xs">
|
||||
kind {kind}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{section.badges.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<span className="font-medium">Badge requirements:</span>
|
||||
{section.badges.map((badge, j) => (
|
||||
<div key={j} className="font-mono text-[10px] truncate">
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{relays.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Relays
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{relays.map((relay, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
|
||||
>
|
||||
{i === 0 && (
|
||||
<span className="text-primary font-semibold mr-2">
|
||||
[main]
|
||||
</span>
|
||||
)}
|
||||
{relay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Features */}
|
||||
{(blossomServers.length > 0 || mints.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Features</h2>
|
||||
<div className="space-y-2">
|
||||
{blossomServers.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<ImageIcon className="size-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<span className="font-medium">Blossom servers:</span>
|
||||
{blossomServers.map((server, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
|
||||
>
|
||||
{server}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mints.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<Coins className="size-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<span className="font-medium">Cashu mints:</span>
|
||||
{mints.map((mint, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="font-mono text-muted-foreground px-2 py-1 bg-muted rounded"
|
||||
>
|
||||
{mint}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tosPointer && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileText className="size-4" />
|
||||
<span>Terms of service: {tosPointer.slice(0, 16)}...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open Chat Button */}
|
||||
{canOpenChat && (
|
||||
<Button onClick={handleOpenChat} className="w-full" size="lg">
|
||||
<MessageSquare className="size-4 mr-2" />
|
||||
Open Community Chat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
102
src/components/nostr/kinds/CommunikeyRenderer.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue, getTagValues } from "@/lib/nostr-utils";
|
||||
import { BaseEventContainer, ClickableEventTitle } from "./BaseEventRenderer";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { MessageSquare, Server } from "lucide-react";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
|
||||
interface CommunikeyRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Communikey Community Definition events (kind 10222)
|
||||
* Displays community info, content sections, and links to chat
|
||||
*/
|
||||
export function CommunikeyRenderer({ event }: CommunikeyRendererProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get community pubkey (the event author)
|
||||
const communityPubkey = event.pubkey;
|
||||
|
||||
// Fetch community profile for name/picture
|
||||
const profile = useProfile(communityPubkey);
|
||||
|
||||
// Extract community metadata from kind 10222
|
||||
const descriptionOverride = getTagValue(event, "description");
|
||||
const relays = getTagValues(event, "r").filter((url) => url);
|
||||
|
||||
// Use profile metadata or fallback
|
||||
const name = profile?.name || communityPubkey.slice(0, 8);
|
||||
const about = descriptionOverride || profile?.about;
|
||||
|
||||
// Parse content sections (groups of tags between "content" tags)
|
||||
const contentTags = event.tags.filter((t) => t[0] === "content");
|
||||
const contentSections = contentTags.map((t) => t[1]).filter((s) => s);
|
||||
|
||||
const handleOpenChat = () => {
|
||||
if (!relays.length) return;
|
||||
|
||||
// Use relay'pubkey format for compatibility with NIP-29 parser
|
||||
const primaryRelay = relays[0].replace(/^wss?:\/\//, "");
|
||||
const identifier = `${primaryRelay}'${communityPubkey}`;
|
||||
|
||||
// Open chat command - parser will detect it's a Communikey
|
||||
addWindow("chat", {
|
||||
protocol: "communikey",
|
||||
identifier: {
|
||||
type: "communikey",
|
||||
value: communityPubkey,
|
||||
relays,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const canOpenChat = relays.length > 0;
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ClickableEventTitle event={event} className="font-semibold">
|
||||
{name}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{about && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{about}</p>
|
||||
)}
|
||||
|
||||
{contentSections.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{contentSections.map((section, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-muted rounded border border-border"
|
||||
>
|
||||
{section}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{canOpenChat && (
|
||||
<button
|
||||
onClick={handleOpenChat}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<MessageSquare className="size-3" />
|
||||
Open Chat
|
||||
</button>
|
||||
)}
|
||||
|
||||
{relays.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{relays.length} relay{relays.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import { ZapstoreAppSetDetailRenderer } from "./ZapstoreAppSetDetailRenderer";
|
||||
import { ZapstoreReleaseRenderer } from "./ZapstoreReleaseRenderer";
|
||||
import { ZapstoreReleaseDetailRenderer } from "./ZapstoreReleaseDetailRenderer";
|
||||
import { GroupMetadataRenderer } from "./GroupMetadataRenderer";
|
||||
import { CommunikeyRenderer } from "./CommunikeyRenderer";
|
||||
import { CommunikeyDetailRenderer } from "./CommunikeyDetailRenderer";
|
||||
import {
|
||||
RelayMembersRenderer,
|
||||
RelayMembersDetailRenderer,
|
||||
@@ -198,6 +200,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
|
||||
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
|
||||
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
|
||||
10222: CommunikeyRenderer, // Communikey Community Definition (kind 10222)
|
||||
10317: Kind10317Renderer, // User Grasp List (NIP-34)
|
||||
13534: RelayMembersRenderer, // Relay Members (NIP-43)
|
||||
30000: FollowSetRenderer, // Follow Sets (NIP-51)
|
||||
@@ -296,6 +299,7 @@ const detailRenderers: Record<
|
||||
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
|
||||
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
|
||||
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
|
||||
10222: CommunikeyDetailRenderer, // Communikey Community Definition Detail (kind 10222)
|
||||
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
|
||||
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
|
||||
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
|
||||
|
||||
@@ -3,11 +3,80 @@ import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
|
||||
import { Nip10Adapter } from "./chat/adapters/nip-10-adapter";
|
||||
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
|
||||
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
|
||||
import { CommunikeyAdapter } from "./chat/adapters/communikey-adapter";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import pool from "@/services/relay-pool";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { toArray } from "rxjs/operators";
|
||||
// Import other adapters as they're implemented
|
||||
// import { Nip17Adapter } from "./chat/adapters/nip-17-adapter";
|
||||
// import { Nip28Adapter } from "./chat/adapters/nip-28-adapter";
|
||||
|
||||
/**
|
||||
* Check if a string is a valid hex pubkey (64 hex characters)
|
||||
*/
|
||||
function isValidPubkey(str: string): boolean {
|
||||
return /^[0-9a-f]{64}$/i.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to detect if a group ID is actually a Communikey (kind 10222)
|
||||
* Returns true if kind 10222 event found, false otherwise
|
||||
*/
|
||||
async function isCommunikey(
|
||||
pubkey: string,
|
||||
relayHints: string[],
|
||||
): Promise<boolean> {
|
||||
if (!isValidPubkey(pubkey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Chat Parser] Checking if ${pubkey.slice(0, 8)}... is a Communikey...`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [10222],
|
||||
authors: [pubkey.toLowerCase()],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
// Use available relays for detection (relay hints + some connected relays)
|
||||
const relays = [
|
||||
...relayHints,
|
||||
...Array.from(pool.connectedRelays.keys()).slice(0, 3),
|
||||
].filter((r) => r);
|
||||
|
||||
if (relays.length === 0) {
|
||||
console.log("[Chat Parser] No relays available for Communikey detection");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check with 2 second timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Timeout")), 2000);
|
||||
});
|
||||
|
||||
const fetchPromise = firstValueFrom(
|
||||
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
const events = await Promise.race([fetchPromise, timeoutPromise]);
|
||||
|
||||
const hasCommunikey = events.length > 0;
|
||||
console.log(
|
||||
`[Chat Parser] Communikey detection: ${hasCommunikey ? "found" : "not found"}`,
|
||||
);
|
||||
return hasCommunikey;
|
||||
} catch (err) {
|
||||
console.log("[Chat Parser] Communikey detection failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chat command identifier and auto-detect the protocol
|
||||
*
|
||||
@@ -16,6 +85,7 @@ import { nip19 } from "nostr-tools";
|
||||
* 2. NIP-17 (encrypted DMs) - prioritized for privacy
|
||||
* 3. NIP-28 (channels) - specific event format (kind 40)
|
||||
* 4. NIP-29 (groups) - specific group ID format
|
||||
* - Communikey fallback: if group ID is valid pubkey with kind 10222
|
||||
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
|
||||
* 6. NIP-C7 (simple chat) - fallback for generic pubkeys
|
||||
*
|
||||
@@ -23,7 +93,9 @@ import { nip19 } from "nostr-tools";
|
||||
* @returns Parsed result with protocol and identifier
|
||||
* @throws Error if no adapter can parse the identifier
|
||||
*/
|
||||
export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
export async function parseChatCommand(
|
||||
args: string[],
|
||||
): Promise<ChatCommandResult> {
|
||||
if (args.length === 0) {
|
||||
throw new Error("Chat identifier required. Usage: chat <identifier>");
|
||||
}
|
||||
@@ -75,6 +147,28 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
for (const adapter of adapters) {
|
||||
const parsed = adapter.parseIdentifier(identifier);
|
||||
if (parsed) {
|
||||
// Special case: NIP-29 group fallback to Communikey
|
||||
if (parsed.type === "group" && adapter.protocol === "nip-29") {
|
||||
const groupId = parsed.value;
|
||||
const relays = parsed.relays || [];
|
||||
|
||||
// Check if group ID is a valid pubkey with kind 10222
|
||||
if (await isCommunikey(groupId, relays)) {
|
||||
console.log("[Chat Parser] Using Communikey adapter for", groupId);
|
||||
const communikeyAdapter = new CommunikeyAdapter();
|
||||
return {
|
||||
protocol: "communikey",
|
||||
identifier: {
|
||||
type: "communikey",
|
||||
value: groupId.toLowerCase(),
|
||||
relays, // Use relays from NIP-29 format as hints
|
||||
},
|
||||
adapter: communikeyAdapter,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original adapter result
|
||||
return {
|
||||
protocol: adapter.protocol,
|
||||
identifier: parsed,
|
||||
@@ -95,6 +189,9 @@ Currently supported formats:
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
chat wss://relay.example.com'nostr-dev
|
||||
- relay.com'pubkey (Communikey fallback, if pubkey has kind 10222)
|
||||
Examples:
|
||||
chat relay.example.com'<64-char-hex-pubkey>
|
||||
- naddr1... (NIP-29 group metadata, kind 39000)
|
||||
Example:
|
||||
chat naddr1qqxnzdesxqmnxvpexqmny...
|
||||
|
||||
784
src/lib/chat/adapters/communikey-adapter.ts
Normal file
784
src/lib/chat/adapters/communikey-adapter.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first, toArray } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
Participant,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { ChatAction, GetActionsOptions } from "@/types/chat-actions";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEventToRelays, publishEvent } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
|
||||
/**
|
||||
* Communikey Adapter - NIP-29 fallback using kind 10222 communities
|
||||
*
|
||||
* Features:
|
||||
* - Fallback when NIP-29 group ID is a valid pubkey with kind 10222 definition
|
||||
* - Community pubkey acts as admin
|
||||
* - Members derived from chat participants (unique message authors)
|
||||
* - Multi-relay support (main + backups from r-tags)
|
||||
* - Client-side moderation only
|
||||
*
|
||||
* Identifier format: pubkey (hex) with relays from kind 10222
|
||||
* Events use "h" tag with community pubkey (same as NIP-29)
|
||||
*/
|
||||
export class CommunikeyAdapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "communikey" as const;
|
||||
readonly type = "group" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - only accepts valid hex pubkeys
|
||||
* Relay list comes from kind 10222, not the identifier
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Check if input is a valid 64-character hex pubkey
|
||||
if (!/^[0-9a-f]{64}$/i.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return minimal identifier - relays will be fetched from kind 10222
|
||||
return {
|
||||
type: "communikey",
|
||||
value: input.toLowerCase(),
|
||||
relays: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from communikey identifier
|
||||
* Fetches kind 10222 (community definition) and kind 0 (profile)
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
// This adapter only handles communikey identifiers
|
||||
if (identifier.type !== "communikey") {
|
||||
throw new Error(
|
||||
`Communikey adapter cannot handle identifier type: ${identifier.type}`,
|
||||
);
|
||||
}
|
||||
const communikeyPubkey = identifier.value;
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikey] Fetching community definition for ${communikeyPubkey.slice(0, 8)}...`,
|
||||
);
|
||||
|
||||
// Fetch kind 10222 (community definition)
|
||||
const definitionFilter: Filter = {
|
||||
kinds: [10222],
|
||||
authors: [communikeyPubkey],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
// Use user's outbox/general relays for fetching
|
||||
// TODO: Could use more sophisticated relay selection
|
||||
const definitionEvents = await firstValueFrom(
|
||||
pool
|
||||
.request(
|
||||
identifier.relays.length > 0
|
||||
? identifier.relays
|
||||
: Array.from(pool.connectedRelays.keys()).slice(0, 5),
|
||||
[definitionFilter],
|
||||
{ eventStore },
|
||||
)
|
||||
.pipe(toArray()),
|
||||
);
|
||||
|
||||
const definitionEvent = definitionEvents[0];
|
||||
if (!definitionEvent) {
|
||||
throw new Error(
|
||||
`No community definition found for ${communikeyPubkey.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikey] Found community definition, tags:`,
|
||||
definitionEvent.tags,
|
||||
);
|
||||
|
||||
// Extract relays from r-tags
|
||||
const relays = getTagValues(definitionEvent, "r")
|
||||
.map((url) => {
|
||||
// Add wss:// prefix if not present
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
return `wss://${url}`;
|
||||
}
|
||||
return url;
|
||||
})
|
||||
.filter((url) => url); // Remove empty strings
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error("Community definition has no relay URLs (r-tags)");
|
||||
}
|
||||
|
||||
console.log(`[Communikey] Community relays:`, relays);
|
||||
|
||||
// Fetch kind 0 (profile) for community name/picture
|
||||
const profileFilter: Filter = {
|
||||
kinds: [0],
|
||||
authors: [communikeyPubkey],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const profileEvents = await firstValueFrom(
|
||||
pool.request(relays, [profileFilter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
const profileEvent = profileEvents[0];
|
||||
|
||||
// Parse profile metadata
|
||||
let profileName = communikeyPubkey.slice(0, 8);
|
||||
let profilePicture: string | undefined;
|
||||
let profileAbout: string | undefined;
|
||||
|
||||
if (profileEvent) {
|
||||
try {
|
||||
const metadata = JSON.parse(profileEvent.content);
|
||||
profileName = metadata.name || profileName;
|
||||
profilePicture = metadata.picture;
|
||||
profileAbout = metadata.about;
|
||||
} catch (err) {
|
||||
console.warn("[Communikey] Failed to parse profile metadata:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for description override in kind 10222
|
||||
const descriptionOverride = getTagValues(definitionEvent, "description")[0];
|
||||
const description = descriptionOverride || profileAbout;
|
||||
|
||||
console.log(`[Communikey] Community name: ${profileName}`);
|
||||
|
||||
// Community pubkey is the admin
|
||||
const participants: Participant[] = [
|
||||
{
|
||||
pubkey: communikeyPubkey,
|
||||
role: "admin",
|
||||
},
|
||||
];
|
||||
|
||||
// Note: Additional members will be derived dynamically from message authors
|
||||
// We'll add them as we see messages in the loadMessages observable
|
||||
|
||||
return {
|
||||
id: `communikey:${communikeyPubkey}`,
|
||||
type: "group",
|
||||
protocol: "communikey",
|
||||
title: profileName,
|
||||
participants,
|
||||
metadata: {
|
||||
communikeyPubkey,
|
||||
communikeyDefinition: definitionEvent,
|
||||
communikeyRelays: relays,
|
||||
...(description && { description }),
|
||||
...(profilePicture && { icon: profilePicture }),
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a communikey group
|
||||
* Uses same kind 9 format as NIP-29 with #h tag
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikey] Loading messages for ${communikeyPubkey.slice(0, 8)}... from ${relays.length} relays`,
|
||||
);
|
||||
|
||||
// Filter for chat messages (kind 9) and nutzaps (kind 9321)
|
||||
// Same as NIP-29 but without system events (no relay-enforced moderation)
|
||||
const filter: Filter = {
|
||||
kinds: [9, 9321],
|
||||
"#h": [communikeyPubkey],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
|
||||
if (options?.before) {
|
||||
filter.until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Clean up any existing subscription for this conversation
|
||||
const conversationId = `communikey:${communikeyPubkey}`;
|
||||
this.cleanup(conversationId);
|
||||
|
||||
// Start a persistent subscription to all community relays
|
||||
const subscription = pool
|
||||
.subscription(relays, [filter], {
|
||||
eventStore,
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[Communikey] EOSE received");
|
||||
} else {
|
||||
console.log(
|
||||
`[Communikey] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
|
||||
// Return observable from EventStore which will update automatically
|
||||
return eventStore.timeline(filter).pipe(
|
||||
map((events) => {
|
||||
const messages = events.map((event) => {
|
||||
// Convert nutzaps (kind 9321) using nutzapToMessage
|
||||
if (event.kind === 9321) {
|
||||
return this.nutzapToMessage(event, conversation.id);
|
||||
}
|
||||
// All other events use eventToMessage
|
||||
return this.eventToMessage(event, conversation.id);
|
||||
});
|
||||
|
||||
console.log(`[Communikey] Timeline has ${messages.length} events`);
|
||||
// EventStore timeline returns events sorted by created_at desc,
|
||||
// we need ascending order for chat. Since it's already sorted,
|
||||
// just reverse instead of full sort (O(n) vs O(n log n))
|
||||
return messages.reverse();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
): Promise<Message[]> {
|
||||
const communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikey] Loading older messages for ${communikeyPubkey.slice(0, 8)}... before ${before}`,
|
||||
);
|
||||
|
||||
// Same filter as loadMessages but with until for pagination
|
||||
const filter: Filter = {
|
||||
kinds: [9, 9321],
|
||||
"#h": [communikeyPubkey],
|
||||
until: before,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
// One-shot request to fetch older messages
|
||||
const events = await firstValueFrom(
|
||||
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
|
||||
);
|
||||
|
||||
console.log(`[Communikey] Loaded ${events.length} older events`);
|
||||
|
||||
// Convert events to messages
|
||||
const messages = events.map((event) => {
|
||||
if (event.kind === 9321) {
|
||||
return this.nutzapToMessage(event, conversation.id);
|
||||
}
|
||||
return this.eventToMessage(event, conversation.id);
|
||||
});
|
||||
|
||||
// loadMoreMessages returns events in desc order from relay,
|
||||
// reverse for ascending chronological order
|
||||
return messages.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the communikey group
|
||||
*/
|
||||
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 communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [["h", communikeyPubkey]];
|
||||
|
||||
if (options?.replyTo) {
|
||||
// Use q-tag for replies (same as NIP-29/NIP-C7)
|
||||
tags.push(["q", options.replyTo]);
|
||||
}
|
||||
|
||||
// Add NIP-30 emoji tags
|
||||
if (options?.emojiTags) {
|
||||
for (const emoji of options.emojiTags) {
|
||||
tags.push(["emoji", emoji.shortcode, emoji.url]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add NIP-92 imeta tags for blob attachments
|
||||
if (options?.blobAttachments) {
|
||||
for (const blob of options.blobAttachments) {
|
||||
const imetaParts = [`url ${blob.url}`];
|
||||
if (blob.sha256) imetaParts.push(`x ${blob.sha256}`);
|
||||
if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`);
|
||||
if (blob.size) imetaParts.push(`size ${blob.size}`);
|
||||
tags.push(["imeta", ...imetaParts]);
|
||||
}
|
||||
}
|
||||
|
||||
// Use kind 9 for group chat messages
|
||||
const draft = await factory.build({ kind: 9, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to all community relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message in the communikey group
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): 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 communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["h", communikeyPubkey], // Communikey context
|
||||
["k", "9"], // Kind of event being reacted to (chat message)
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Use kind 7 for reactions
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to all community relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false, // kind 9 messages are public
|
||||
supportsThreading: true, // q-tag replies
|
||||
supportsModeration: false, // Client-side only, no relay enforcement
|
||||
supportsRoles: true, // Admin role for community pubkey
|
||||
supportsGroupManagement: false, // No join/leave - open participation
|
||||
canCreateConversations: false, // Communities created via kind 10222
|
||||
requiresRelay: true, // Multi-relay (main + backups)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available actions for Communikey groups
|
||||
* Currently only bookmark/unbookmark (no join/leave - open participation)
|
||||
*/
|
||||
getActions(options?: GetActionsOptions): ChatAction[] {
|
||||
const actions: ChatAction[] = [];
|
||||
|
||||
// Bookmark/unbookmark actions (same as NIP-29)
|
||||
actions.push({
|
||||
name: "bookmark",
|
||||
description: "Add community to your group list",
|
||||
handler: async (context) => {
|
||||
try {
|
||||
await this.bookmarkCommunity(
|
||||
context.conversation,
|
||||
context.activePubkey,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "Community added to your list",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to bookmark community",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
actions.push({
|
||||
name: "unbookmark",
|
||||
description: "Remove community from your group list",
|
||||
handler: async (context) => {
|
||||
try {
|
||||
await this.unbookmarkCommunity(
|
||||
context.conversation,
|
||||
context.activePubkey,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "Community removed from your list",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to unbookmark community",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
* First checks EventStore, then fetches from community relays if needed
|
||||
*/
|
||||
async loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null> {
|
||||
// First check EventStore - might already be loaded
|
||||
const cachedEvent = await eventStore
|
||||
.event(eventId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
// Not in store, fetch from community relays
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
if (!relays || relays.length === 0) {
|
||||
console.warn("[Communikey] No relays for loading reply message");
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Communikey] 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(
|
||||
`[Communikey] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
|
||||
);
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
const sub = obs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timeout);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
// Event received
|
||||
events.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[Communikey] Reply message fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a communikey to the user's group list (kind 10009)
|
||||
* Uses same format as NIP-29 bookmark but with communikey pubkey
|
||||
*/
|
||||
async bookmarkCommunity(
|
||||
conversation: Conversation,
|
||||
activePubkey: string,
|
||||
): Promise<void> {
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activeSigner) {
|
||||
throw new Error("No active signer");
|
||||
}
|
||||
|
||||
const communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
// Use first relay as primary
|
||||
const primaryRelay = relays[0];
|
||||
const normalizedRelayUrl = normalizeRelayURL(primaryRelay);
|
||||
|
||||
// Fetch current kind 10009 event (group list)
|
||||
const currentEvent = await firstValueFrom(
|
||||
eventStore.replaceable(10009, activePubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
// Build new tags array
|
||||
let tags: string[][] = [];
|
||||
|
||||
if (currentEvent) {
|
||||
// Copy existing tags
|
||||
tags = [...currentEvent.tags];
|
||||
|
||||
// Check if communikey is already in the list
|
||||
const existingGroup = tags.find(
|
||||
(t) =>
|
||||
t[0] === "group" &&
|
||||
t[1] === communikeyPubkey &&
|
||||
normalizeRelayURL(t[2] || "") === normalizedRelayUrl,
|
||||
);
|
||||
|
||||
if (existingGroup) {
|
||||
throw new Error("Community is already in your list");
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new group tag (use communikey pubkey as group ID)
|
||||
tags.push(["group", communikeyPubkey, normalizedRelayUrl]);
|
||||
|
||||
// Create and publish the updated event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const draft = await factory.build({
|
||||
kind: 10009,
|
||||
content: "",
|
||||
tags,
|
||||
});
|
||||
const event = await factory.sign(draft);
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a communikey from the user's group list (kind 10009)
|
||||
*/
|
||||
async unbookmarkCommunity(
|
||||
conversation: Conversation,
|
||||
activePubkey: string,
|
||||
): Promise<void> {
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activeSigner) {
|
||||
throw new Error("No active signer");
|
||||
}
|
||||
|
||||
const communikeyPubkey = conversation.metadata?.communikeyPubkey;
|
||||
const relays = conversation.metadata?.communikeyRelays;
|
||||
|
||||
if (!communikeyPubkey || !relays || relays.length === 0) {
|
||||
throw new Error("Community pubkey and relays required");
|
||||
}
|
||||
|
||||
// Use first relay as primary
|
||||
const primaryRelay = relays[0];
|
||||
const normalizedRelayUrl = normalizeRelayURL(primaryRelay);
|
||||
|
||||
// Fetch current kind 10009 event (group list)
|
||||
const currentEvent = await firstValueFrom(
|
||||
eventStore.replaceable(10009, activePubkey, ""),
|
||||
{ defaultValue: undefined },
|
||||
);
|
||||
|
||||
if (!currentEvent) {
|
||||
throw new Error("No group list found");
|
||||
}
|
||||
|
||||
// Find and remove the communikey tag
|
||||
const originalLength = currentEvent.tags.length;
|
||||
const tags = currentEvent.tags.filter(
|
||||
(t) =>
|
||||
!(
|
||||
t[0] === "group" &&
|
||||
t[1] === communikeyPubkey &&
|
||||
normalizeRelayURL(t[2] || "") === normalizedRelayUrl
|
||||
),
|
||||
);
|
||||
|
||||
if (tags.length === originalLength) {
|
||||
throw new Error("Community is not in your list");
|
||||
}
|
||||
|
||||
// Create and publish the updated event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const draft = await factory.build({
|
||||
kind: 10009,
|
||||
content: "",
|
||||
tags,
|
||||
});
|
||||
const event = await factory.sign(draft);
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
// Look for reply q-tags
|
||||
const qTags = getTagValues(event, "q");
|
||||
const replyTo = qTags[0]; // First q-tag is the reply target
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
type: "user",
|
||||
replyTo,
|
||||
protocol: "communikey",
|
||||
metadata: {
|
||||
encrypted: false, // kind 9 messages are always public
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert nutzap event (kind 9321) to Message
|
||||
* NIP-61 nutzaps are P2PK-locked Cashu token transfers
|
||||
*/
|
||||
private nutzapToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
// Sender is the event author
|
||||
const sender = event.pubkey;
|
||||
|
||||
// Recipient is the p-tag value
|
||||
const pTag = event.tags.find((t) => t[0] === "p");
|
||||
const recipient = pTag?.[1] || "";
|
||||
|
||||
// Reply target is the e-tag (the event being nutzapped)
|
||||
const eTag = event.tags.find((t) => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
// Amount is sum of proof amounts from all proof tags
|
||||
let amount = 0;
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "proof" && tag[1]) {
|
||||
try {
|
||||
const proof = JSON.parse(tag[1]);
|
||||
// Proof can be a single object or an array of proofs
|
||||
if (Array.isArray(proof)) {
|
||||
amount += proof.reduce(
|
||||
(sum: number, p: { amount?: number }) => sum + (p.amount || 0),
|
||||
0,
|
||||
);
|
||||
} else if (typeof proof === "object" && proof.amount) {
|
||||
amount += proof.amount;
|
||||
}
|
||||
} catch {
|
||||
// Invalid proof JSON, skip this tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unit defaults to "sat" per NIP-61
|
||||
const unitTag = event.tags.find((t) => t[0] === "unit");
|
||||
const unit = unitTag?.[1] || "sat";
|
||||
|
||||
// Comment is in the content field
|
||||
const comment = event.content || "";
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: sender,
|
||||
content: comment,
|
||||
timestamp: event.created_at,
|
||||
type: "zap", // Render the same as zaps
|
||||
replyTo,
|
||||
protocol: "communikey",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
zapAmount: amount, // In the unit specified (usually sats)
|
||||
zapRecipient: recipient,
|
||||
nutzapUnit: unit, // Store unit for potential future use
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type ChatProtocol =
|
||||
| "nip-17"
|
||||
| "nip-28"
|
||||
| "nip-29"
|
||||
| "communikey"
|
||||
| "nip-53"
|
||||
| "nip-10";
|
||||
|
||||
@@ -71,6 +72,11 @@ export interface ConversationMetadata {
|
||||
description?: string; // Group/thread description
|
||||
icon?: string; // Group icon/picture URL
|
||||
|
||||
// Communikey
|
||||
communikeyPubkey?: string; // Community pubkey (admin)
|
||||
communikeyDefinition?: NostrEvent; // kind 10222 event
|
||||
communikeyRelays?: string[]; // Main + backup relays from r-tags
|
||||
|
||||
// NIP-53 live chat
|
||||
activityAddress?: {
|
||||
kind: number;
|
||||
@@ -234,6 +240,18 @@ export interface ThreadIdentifier {
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Communikey identifier (NIP-29 group fallback)
|
||||
* Used when group ID is a valid pubkey with kind 10222 community definition
|
||||
*/
|
||||
export interface CommunikeyIdentifier {
|
||||
type: "communikey";
|
||||
/** Community pubkey (hex) */
|
||||
value: string;
|
||||
/** Relay URLs from kind 10222 r-tags (main + backups) */
|
||||
relays: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-specific identifier - discriminated union
|
||||
* Returned by adapter parseIdentifier()
|
||||
@@ -245,7 +263,8 @@ export type ProtocolIdentifier =
|
||||
| NIP05Identifier
|
||||
| ChannelIdentifier
|
||||
| GroupListIdentifier
|
||||
| ThreadIdentifier;
|
||||
| ThreadIdentifier
|
||||
| CommunikeyIdentifier;
|
||||
|
||||
/**
|
||||
* Chat command parsing result
|
||||
|
||||
@@ -580,7 +580,7 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
appId: "chat",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[]) => {
|
||||
const result = parseChatCommand(args);
|
||||
const result = await parseChatCommand(args);
|
||||
return {
|
||||
protocol: result.protocol,
|
||||
identifier: result.identifier,
|
||||
|
||||
Reference in New Issue
Block a user