mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
Merge branch 'chat'
This commit is contained in:
470
src/components/ChatViewer.tsx
Normal file
470
src/components/ChatViewer.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useMemo, useState, memo, useCallback, useRef } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from } from "rxjs";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { Reply } from "lucide-react";
|
||||
import accountManager from "@/services/accounts";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type {
|
||||
ChatProtocol,
|
||||
ProtocolIdentifier,
|
||||
Conversation,
|
||||
} from "@/types/chat";
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Message } from "@/types/chat";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
} from "./editor/MentionEditor";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
interface ChatViewerProps {
|
||||
protocol: ChatProtocol;
|
||||
identifier: ProtocolIdentifier;
|
||||
customTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format timestamp as a readable day marker
|
||||
*/
|
||||
function formatDayMarker(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Reset time parts for comparison
|
||||
const dateOnly = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
);
|
||||
const todayOnly = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const yesterdayOnly = new Date(
|
||||
yesterday.getFullYear(),
|
||||
yesterday.getMonth(),
|
||||
yesterday.getDate(),
|
||||
);
|
||||
|
||||
if (dateOnly.getTime() === todayOnly.getTime()) {
|
||||
return "Today";
|
||||
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
|
||||
return "Yesterday";
|
||||
} else {
|
||||
// Format as "Jan 15" (short month, no year, respects locale)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if two timestamps are on different days
|
||||
*/
|
||||
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
const date1 = new Date(timestamp1 * 1000);
|
||||
const date2 = new Date(timestamp2 * 1000);
|
||||
|
||||
return (
|
||||
date1.getFullYear() !== date2.getFullYear() ||
|
||||
date1.getMonth() !== date2.getMonth() ||
|
||||
date1.getDate() !== date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ComposerReplyPreview - Shows who is being replied to in the composer
|
||||
*/
|
||||
const ComposerReplyPreview = memo(function ComposerReplyPreview({
|
||||
replyToId,
|
||||
onClear,
|
||||
}: {
|
||||
replyToId: string;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-1.5 overflow-hidden">
|
||||
<span className="flex-1 min-w-0 truncate">
|
||||
Replying to {replyToId.slice(0, 8)}...
|
||||
</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-1.5 overflow-hidden">
|
||||
<span className="flex-shrink-0">↳</span>
|
||||
<UserName
|
||||
pubkey={replyEvent.pubkey}
|
||||
className="font-medium flex-shrink-0"
|
||||
/>
|
||||
<span className="flex-1 min-w-0 truncate text-muted-foreground">
|
||||
{replyEvent.content}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* MessageItem - Memoized message component for performance
|
||||
*/
|
||||
const MessageItem = memo(function MessageItem({
|
||||
message,
|
||||
adapter,
|
||||
conversation,
|
||||
onReply,
|
||||
canReply,
|
||||
onScrollToMessage,
|
||||
}: {
|
||||
message: Message;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
onReply?: (messageId: string) => void;
|
||||
canReply: boolean;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
}) {
|
||||
// System messages (join/leave) have special styling
|
||||
if (message.type === "system") {
|
||||
return (
|
||||
<div className="flex items-center px-3 py-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
* <UserName pubkey={message.author} className="text-xs" />{" "}
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user messages
|
||||
return (
|
||||
<div className="group flex items-start hover:bg-muted/50 px-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserName pubkey={message.author} className="font-semibold text-sm" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Timestamp timestamp={message.timestamp} />
|
||||
</span>
|
||||
{canReply && onReply && (
|
||||
<button
|
||||
onClick={() => onReply(message.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground ml-auto"
|
||||
title="Reply to this message"
|
||||
>
|
||||
<Reply className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="break-words overflow-hidden">
|
||||
{message.event ? (
|
||||
<RichText className="text-sm leading-tight" event={message.event}>
|
||||
{message.replyTo && (
|
||||
<ReplyPreview
|
||||
replyToId={message.replyTo}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
)}
|
||||
</RichText>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ChatViewer - Main chat interface component
|
||||
*
|
||||
* Provides protocol-agnostic chat UI that works across all Nostr messaging protocols.
|
||||
* Uses adapter pattern to handle protocol-specific logic while providing consistent UX.
|
||||
*/
|
||||
export function ChatViewer({
|
||||
protocol,
|
||||
identifier,
|
||||
customTitle,
|
||||
}: ChatViewerProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get active account
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const hasActiveAccount = !!activeAccount;
|
||||
|
||||
// Profile search for mentions
|
||||
const { searchProfiles } = useProfileSearch();
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
// Resolve conversation from identifier (async operation)
|
||||
const conversation = use$(
|
||||
() => from(adapter.resolveConversation(identifier)),
|
||||
[adapter, identifier],
|
||||
);
|
||||
|
||||
// Load messages for this conversation (reactive)
|
||||
const messages = use$(
|
||||
() => (conversation ? adapter.loadMessages(conversation) : undefined),
|
||||
[adapter, conversation],
|
||||
);
|
||||
|
||||
// Process messages to include day markers
|
||||
const messagesWithMarkers = useMemo(() => {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const items: Array<
|
||||
| { type: "message"; data: Message }
|
||||
| { type: "day-marker"; data: string; timestamp: number }
|
||||
> = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
// Add day marker if this is the first message or if day changed
|
||||
if (index === 0) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (isDifferentDay(prevMessage.timestamp, message.timestamp)) {
|
||||
items.push({
|
||||
type: "day-marker",
|
||||
data: formatDayMarker(message.timestamp),
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the message itself
|
||||
items.push({ type: "message", data: message });
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [messages]);
|
||||
|
||||
// Track reply context (which message is being replied to)
|
||||
const [replyTo, setReplyTo] = useState<string | undefined>();
|
||||
|
||||
// Ref to Virtuoso for programmatic scrolling
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Handle sending messages
|
||||
const handleSend = async (content: string, replyToId?: string) => {
|
||||
if (!conversation || !hasActiveAccount) return;
|
||||
await adapter.sendMessage(conversation, content, replyToId);
|
||||
setReplyTo(undefined); // Clear reply context after sending
|
||||
};
|
||||
|
||||
// Handle reply button click
|
||||
const handleReply = useCallback((messageId: string) => {
|
||||
setReplyTo(messageId);
|
||||
}, []);
|
||||
|
||||
// Handle scroll to message (when clicking on reply preview)
|
||||
const handleScrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
if (!messages) return;
|
||||
const index = messages.findIndex((m) => m.id === messageId);
|
||||
if (index !== -1 && virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// Handle NIP badge click
|
||||
const handleNipClick = useCallback(() => {
|
||||
if (conversation?.protocol === "nip-29") {
|
||||
addWindow("nip", { number: 29 });
|
||||
}
|
||||
}, [conversation?.protocol, addWindow]);
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Loading conversation...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with conversation info and controls */}
|
||||
<div className="px-4 border-b w-full py-0.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
<div className="flex-1 flex flex-row gap-2 items-baseline min-w-0">
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{customTitle || conversation.title}
|
||||
</h2>
|
||||
{conversation.metadata?.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{conversation.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
<MembersDropdown participants={conversation.participants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{conversation.type === "group" && (
|
||||
<button
|
||||
onClick={handleNipClick}
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
{conversation.protocol.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message timeline with virtualization */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messagesWithMarkers}
|
||||
initialTopMostItemIndex={messagesWithMarkers.length - 1}
|
||||
followOutput="smooth"
|
||||
itemContent={(_index, item) => {
|
||||
if (item.type === "day-marker") {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center py-2"
|
||||
key={`marker-${item.timestamp}`}
|
||||
>
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{item.data}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageItem
|
||||
key={item.data.id}
|
||||
message={item.data}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No messages yet. Start the conversation!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message composer - only show if user has active account */}
|
||||
{hasActiveAccount ? (
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{replyTo && (
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
<MentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
searchProfiles={searchProfiles}
|
||||
onSubmit={(content) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 h-[2.5rem]"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
Sign in to send messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate adapter for a protocol
|
||||
* Currently only NIP-29 (relay-based groups) is supported
|
||||
* Other protocols will be enabled in future phases
|
||||
*/
|
||||
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
||||
switch (protocol) {
|
||||
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
|
||||
// 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();
|
||||
default:
|
||||
throw new Error(`Unsupported protocol: ${protocol}`);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { getLiveHost } from "@/lib/live-activity";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getZapSender } from "applesauce-common/helpers/zap";
|
||||
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
||||
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
||||
import type { ChatProtocol, ProtocolIdentifier } from "@/types/chat";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface WindowTitleData {
|
||||
title: string | ReactElement;
|
||||
@@ -546,6 +550,47 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
return `Relay Pool (${connectedCount}/${relayList.length})`;
|
||||
}, [appId, relays]);
|
||||
|
||||
// Chat viewer title - resolve conversation to get partner name
|
||||
const [chatTitle, setChatTitle] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (appId !== "chat") {
|
||||
setChatTitle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = props.protocol as ChatProtocol;
|
||||
const identifier = props.identifier as ProtocolIdentifier;
|
||||
|
||||
// Get adapter and resolve conversation
|
||||
// Currently only NIP-29 is supported
|
||||
const getAdapter = () => {
|
||||
switch (protocol) {
|
||||
// case "nip-c7": // Coming soon
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = getAdapter();
|
||||
if (!adapter) {
|
||||
setChatTitle("Chat");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve conversation asynchronously
|
||||
adapter
|
||||
.resolveConversation(identifier)
|
||||
.then((conversation) => {
|
||||
setChatTitle(conversation.title);
|
||||
})
|
||||
.catch(() => {
|
||||
setChatTitle("Chat");
|
||||
});
|
||||
}, [appId, props]);
|
||||
|
||||
// Generate final title data with icon and tooltip
|
||||
return useMemo(() => {
|
||||
let title: ReactElement | string;
|
||||
@@ -619,6 +664,10 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
title = connTitle;
|
||||
icon = getCommandIcon("conn");
|
||||
tooltip = rawCommand;
|
||||
} else if (chatTitle && appId === "chat") {
|
||||
title = chatTitle;
|
||||
icon = getCommandIcon("chat");
|
||||
tooltip = rawCommand;
|
||||
} else {
|
||||
title = staticTitle || appId.toUpperCase();
|
||||
tooltip = rawCommand;
|
||||
@@ -642,6 +691,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
kindsTitle,
|
||||
debugTitle,
|
||||
connTitle,
|
||||
chatTitle,
|
||||
staticTitle,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
|
||||
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
||||
);
|
||||
const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const ChatViewer = lazy(() =>
|
||||
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
|
||||
);
|
||||
const SpellsViewer = lazy(() =>
|
||||
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
|
||||
);
|
||||
@@ -169,6 +172,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "conn":
|
||||
content = <ConnViewer />;
|
||||
break;
|
||||
case "chat":
|
||||
content = (
|
||||
<ChatViewer
|
||||
protocol={window.props.protocol}
|
||||
identifier={window.props.identifier}
|
||||
customTitle={window.customTitle}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
|
||||
58
src/components/chat/MembersDropdown.tsx
Normal file
58
src/components/chat/MembersDropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Users2 } from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Participant } from "@/types/chat";
|
||||
|
||||
interface MembersDropdownProps {
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MembersDropdown - Shows member count and list with roles
|
||||
* Similar to relay indicators in ReqViewer
|
||||
*/
|
||||
export function MembersDropdown({ participants }: MembersDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Users2 className="size-3" />
|
||||
<span>{participants.length}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Members ({participants.length})
|
||||
</div>
|
||||
<div style={{ height: "300px" }}>
|
||||
<Virtuoso
|
||||
data={participants}
|
||||
itemContent={(_index, participant) => (
|
||||
<div
|
||||
key={participant.pubkey}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<UserName
|
||||
pubkey={participant.pubkey}
|
||||
className="text-sm truncate flex-1 min-w-0"
|
||||
/>
|
||||
{participant.role && participant.role !== "member" && (
|
||||
<Label size="sm" className="flex-shrink-0">
|
||||
{participant.role}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
92
src/components/chat/RelaysDropdown.tsx
Normal file
92
src/components/chat/RelaysDropdown.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Wifi } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
interface RelaysDropdownProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* RelaysDropdown - Shows relay count and list with connection status
|
||||
* Similar to relay indicators in ReqViewer
|
||||
*/
|
||||
export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Get relays for this conversation
|
||||
const relays: string[] = [];
|
||||
|
||||
// NIP-29: Single group relay
|
||||
if (conversation.metadata?.relayUrl) {
|
||||
relays.push(conversation.metadata.relayUrl);
|
||||
}
|
||||
|
||||
// Normalize URLs for state lookup
|
||||
const normalizedRelays = relays.map((url) => {
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
});
|
||||
|
||||
// Count connected relays
|
||||
const connectedCount = normalizedRelays.filter((url) => {
|
||||
const state = relayStates[url];
|
||||
return state?.connectionState === "connected";
|
||||
}).length;
|
||||
|
||||
if (relays.length === 0) {
|
||||
return null; // Don't show if no relays
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Wifi className="size-3" />
|
||||
<span>
|
||||
{connectedCount}/{relays.length}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Relays ({relays.length})
|
||||
</div>
|
||||
<div className="space-y-1 p-1">
|
||||
{relays.map((url) => {
|
||||
const normalizedUrl = normalizedRelays[relays.indexOf(url)];
|
||||
const state = relayStates[normalizedUrl];
|
||||
const connIcon = getConnectionIcon(state);
|
||||
const authIcon = getAuthIcon(state);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{connIcon.icon}
|
||||
{authIcon.icon}
|
||||
</div>
|
||||
<RelayLink
|
||||
url={url}
|
||||
className="text-sm truncate flex-1 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
71
src/components/chat/ReplyPreview.tsx
Normal file
71
src/components/chat/ReplyPreview.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
import { RichText } from "../nostr/RichText";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
|
||||
interface ReplyPreviewProps {
|
||||
replyToId: string;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReplyPreview - Shows who is being replied to with truncated message content
|
||||
* Automatically fetches missing events from protocol-specific relays
|
||||
*/
|
||||
export const ReplyPreview = memo(function ReplyPreview({
|
||||
replyToId,
|
||||
adapter,
|
||||
conversation,
|
||||
onScrollToMessage,
|
||||
}: ReplyPreviewProps) {
|
||||
// Load the event being replied to (reactive - updates when event arrives)
|
||||
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
|
||||
// Fetch event from relays if not in store
|
||||
useEffect(() => {
|
||||
if (!replyEvent) {
|
||||
adapter.loadReplyMessage(conversation, replyToId).catch((err) => {
|
||||
console.error(
|
||||
`[ReplyPreview] Failed to load reply ${replyToId.slice(0, 8)}:`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [replyEvent, adapter, conversation, replyToId]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (onScrollToMessage) {
|
||||
onScrollToMessage(replyToId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
↳ Replying to {replyToId.slice(0, 8)}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-xs text-muted-foreground flex items-baseline gap-1 mb-0.5 overflow-hidden cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={handleClick}
|
||||
title="Click to scroll to message"
|
||||
>
|
||||
<span className="flex-shrink-0">↳</span>
|
||||
<UserName
|
||||
pubkey={replyEvent.pubkey}
|
||||
className="font-medium flex-shrink-0"
|
||||
/>
|
||||
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
|
||||
<RichText event={replyEvent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
275
src/components/editor/MentionEditor.tsx
Normal file
275
src/components/editor/MentionEditor.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import {
|
||||
ProfileSuggestionList,
|
||||
type ProfileSuggestionListHandle,
|
||||
} from "./ProfileSuggestionList";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
export interface MentionEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (content: string) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface MentionEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getContent: () => string;
|
||||
getContentWithMentions: () => string;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const MentionEditor = forwardRef<
|
||||
MentionEditorHandle,
|
||||
MentionEditorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
placeholder = "Type a message...",
|
||||
onSubmit,
|
||||
searchProfiles,
|
||||
autoFocus = true,
|
||||
className = "",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Create mention suggestion configuration
|
||||
const suggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions
|
||||
const serializeContent = useCallback((editorInstance: any) => {
|
||||
let text = "";
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
// Fallback to display name if encoding fails
|
||||
text += `@${child.attrs?.label || "unknown"}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
}, []);
|
||||
|
||||
// Helper function to handle submission
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (!editorInstance || !onSubmit) return;
|
||||
|
||||
const content = serializeContent(editorInstance);
|
||||
if (content) {
|
||||
onSubmit(content);
|
||||
editorInstance.commands.clearContent();
|
||||
}
|
||||
},
|
||||
[onSubmit, serializeContent],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
// Disable Enter to submit via Mod-Enter instead
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: {
|
||||
...suggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the ProfileSearchResult
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: props.pubkey,
|
||||
label: props.displayName,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[2rem] px-3 py-1.5",
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
// Get editor from view state
|
||||
const editorInstance = (view as any).editor;
|
||||
handleSubmit(editorInstance);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
});
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getContentWithMentions: () => {
|
||||
if (!editor) return "";
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => editor?.isEmpty ?? true,
|
||||
submit: () => {
|
||||
if (editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, serializeContent, handleSubmit],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-background transition-colors focus-within:border-primary h-[2.5rem] flex items-center ${className}`}
|
||||
>
|
||||
<EditorContent editor={editor} className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MentionEditor.displayName = "MentionEditor";
|
||||
113
src/components/editor/ProfileSuggestionList.tsx
Normal file
113
src/components/editor/ProfileSuggestionList.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import { UserName } from "../nostr/UserName";
|
||||
|
||||
export interface ProfileSuggestionListProps {
|
||||
items: ProfileSearchResult[];
|
||||
command: (item: ProfileSearchResult) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ProfileSuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
export const ProfileSuggestionList = forwardRef<
|
||||
ProfileSuggestionListHandle,
|
||||
ProfileSuggestionListProps
|
||||
>(({ items, command, onClose }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard navigation
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (items[selectedIndex]) {
|
||||
command(items[selectedIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedElement = listRef.current?.children[selectedIndex];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset selected index when items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No profiles found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.pubkey}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<UserName pubkey={item.pubkey} />
|
||||
</div>
|
||||
{item.nip05 && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.nip05}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ProfileSuggestionList.displayName = "ProfileSuggestionList";
|
||||
98
src/components/nostr/GroupLink.tsx
Normal file
98
src/components/nostr/GroupLink.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Format relay URL for display
|
||||
* Removes protocol and trailing slash
|
||||
*/
|
||||
function formatRelayForDisplay(url: string): string {
|
||||
return url.replace(/^wss?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export interface GroupLinkProps {
|
||||
groupId: string;
|
||||
relayUrl: string;
|
||||
metadata?: NostrEvent; // Optional pre-loaded metadata
|
||||
className?: string;
|
||||
iconClassname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GroupLink - Clickable NIP-29 group component
|
||||
* Displays group name (from kind 39000 metadata) or group ID
|
||||
* Opens chat window on click
|
||||
*
|
||||
* Special case: "_" group ID represents the unmanaged relay top-level group
|
||||
*/
|
||||
export function GroupLink({
|
||||
groupId,
|
||||
relayUrl,
|
||||
metadata,
|
||||
className,
|
||||
iconClassname,
|
||||
}: GroupLinkProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Handle special case: "_" is the unmanaged relay top-level group
|
||||
const isUnmanagedGroup = groupId === "_";
|
||||
|
||||
// Extract group name from metadata if available
|
||||
let groupName: string;
|
||||
if (isUnmanagedGroup) {
|
||||
// For "_" groups, show the relay name
|
||||
groupName = formatRelayForDisplay(relayUrl);
|
||||
} else if (metadata && metadata.kind === 39000) {
|
||||
groupName = getTagValue(metadata, "name") || groupId;
|
||||
} else {
|
||||
groupName = groupId;
|
||||
}
|
||||
|
||||
// Extract group icon if available (not applicable for "_" groups)
|
||||
const groupIcon =
|
||||
!isUnmanagedGroup && metadata && metadata.kind === 39000
|
||||
? getTagValue(metadata, "picture")
|
||||
: undefined;
|
||||
|
||||
const handleClick = () => {
|
||||
// Open chat with properly structured ProtocolIdentifier
|
||||
addWindow("chat", {
|
||||
protocol: "nip-29",
|
||||
identifier: {
|
||||
type: "group",
|
||||
value: groupId,
|
||||
relays: [relayUrl],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-crosshair hover:bg-muted/50 rounded px-1 py-0.5 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{groupIcon ? (
|
||||
<img
|
||||
src={groupIcon}
|
||||
alt=""
|
||||
className={cn("size-4 flex-shrink-0 rounded-sm", iconClassname)}
|
||||
/>
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"size-4 flex-shrink-0 text-muted-foreground",
|
||||
iconClassname,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs truncate">{groupName}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/nostr/kinds/PublicChatsRenderer.tsx
Normal file
124
src/components/nostr/kinds/PublicChatsRenderer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { map } from "rxjs/operators";
|
||||
import { useEffect } from "react";
|
||||
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
|
||||
import { GroupLink } from "../GroupLink";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Extract group references from a kind 10009 event
|
||||
* Groups are stored in "group" tags: ["group", "<group-id>", "<relay-url>", ...]
|
||||
*/
|
||||
function extractGroups(event: { tags: string[][] }): Array<{
|
||||
groupId: string;
|
||||
relayUrl: string;
|
||||
}> {
|
||||
const groups: Array<{ groupId: string; relayUrl: string }> = [];
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "group" && tag[1] && tag[2]) {
|
||||
groups.push({
|
||||
groupId: tag[1],
|
||||
relayUrl: tag[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Chats Renderer (Kind 10009)
|
||||
* NIP-51 list of NIP-29 groups
|
||||
* Displays each group as a clickable link with icon and name
|
||||
* Batch-loads metadata for all groups to show their names
|
||||
*/
|
||||
export function PublicChatsRenderer({ event }: BaseEventProps) {
|
||||
const groups = extractGroups(event);
|
||||
|
||||
// Batch-load metadata for all groups at once
|
||||
// Filter out "_" which is the unmanaged relay group (doesn't have metadata)
|
||||
const groupIds = groups.map((g) => g.groupId).filter((id) => id !== "_");
|
||||
|
||||
// Subscribe to relays to fetch group metadata
|
||||
// Extract unique relay URLs from groups
|
||||
const relayUrls = Array.from(new Set(groups.map((g) => g.relayUrl)));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupIds.length === 0) return;
|
||||
|
||||
console.log(
|
||||
`[PublicChatsRenderer] Fetching metadata for ${groupIds.length} groups from ${relayUrls.length} relays`,
|
||||
);
|
||||
|
||||
// Subscribe to fetch metadata events (kind 39000) from the group relays
|
||||
const subscription = pool
|
||||
.subscription(
|
||||
relayUrls,
|
||||
[{ kinds: [39000], "#d": groupIds }],
|
||||
{ eventStore }, // Automatically add to store
|
||||
)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[PublicChatsRenderer] EOSE received for metadata");
|
||||
} else {
|
||||
console.log(
|
||||
`[PublicChatsRenderer] Received metadata: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [groupIds.join(","), relayUrls.join(",")]);
|
||||
|
||||
const groupMetadataMap = use$(
|
||||
() =>
|
||||
groupIds.length > 0
|
||||
? eventStore.timeline([{ kinds: [39000], "#d": groupIds }]).pipe(
|
||||
map((events) => {
|
||||
const metadataMap = new Map<string, NostrEvent>();
|
||||
for (const evt of events) {
|
||||
// Extract group ID from #d tag
|
||||
const dTag = evt.tags.find((t) => t[0] === "d");
|
||||
if (dTag && dTag[1]) {
|
||||
metadataMap.set(dTag[1], evt);
|
||||
}
|
||||
}
|
||||
return metadataMap;
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
[groupIds.join(",")],
|
||||
);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No public chats configured
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groups.map((group) => (
|
||||
<GroupLink
|
||||
key={`${group.relayUrl}'${group.groupId}`}
|
||||
groupId={group.groupId}
|
||||
relayUrl={group.relayUrl}
|
||||
metadata={groupMetadataMap?.get(group.groupId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { RepositoryStateRenderer } from "./RepositoryStateRenderer";
|
||||
import { RepositoryStateDetailRenderer } from "./RepositoryStateDetailRenderer";
|
||||
import { Kind39701Renderer } from "./BookmarkRenderer";
|
||||
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
|
||||
import { PublicChatsRenderer } from "./PublicChatsRenderer";
|
||||
import { LiveActivityRenderer } from "./LiveActivityRenderer";
|
||||
import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer";
|
||||
import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer";
|
||||
@@ -95,6 +96,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10317: Kind10317Renderer, // User Grasp List (NIP-34)
|
||||
10006: GenericRelayListRenderer, // Blocked Relays (NIP-51)
|
||||
10007: GenericRelayListRenderer, // Search Relays (NIP-51)
|
||||
10009: PublicChatsRenderer, // Public Chats List (NIP-51)
|
||||
10012: GenericRelayListRenderer, // Favorite Relays (NIP-51)
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex bg-transparent p-2 text-base transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Layout,
|
||||
Bug,
|
||||
Wifi,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -70,6 +71,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
|
||||
icon: Rss,
|
||||
description: "View event feed",
|
||||
},
|
||||
chat: {
|
||||
icon: MessageSquare,
|
||||
description: "Join and participate in NIP-29 relay-based group chats",
|
||||
},
|
||||
|
||||
// Utility commands
|
||||
encode: {
|
||||
|
||||
54
src/hooks/useProfileSearch.ts
Normal file
54
src/hooks/useProfileSearch.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
ProfileSearchService,
|
||||
type ProfileSearchResult,
|
||||
} from "@/services/profile-search";
|
||||
import eventStore from "@/services/event-store";
|
||||
|
||||
/**
|
||||
* Hook to provide profile search functionality with automatic indexing
|
||||
* of profiles from the event store
|
||||
*/
|
||||
export function useProfileSearch() {
|
||||
const serviceRef = useRef<ProfileSearchService | null>(null);
|
||||
|
||||
// Create service instance (singleton per component mount)
|
||||
if (!serviceRef.current) {
|
||||
serviceRef.current = new ProfileSearchService();
|
||||
}
|
||||
|
||||
const service = serviceRef.current;
|
||||
|
||||
// Subscribe to profile events from the event store
|
||||
useEffect(() => {
|
||||
const subscription = eventStore
|
||||
.timeline([{ kinds: [0], limit: 1000 }])
|
||||
.subscribe({
|
||||
next: (events) => {
|
||||
service.addProfiles(events);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Failed to load profiles for search:", error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
service.clear(); // Clean up indexed profiles
|
||||
};
|
||||
}, [service]);
|
||||
|
||||
// Memoize search function
|
||||
const searchProfiles = useMemo(
|
||||
() =>
|
||||
async (query: string): Promise<ProfileSearchResult[]> => {
|
||||
return await service.search(query, { limit: 20 });
|
||||
},
|
||||
[service],
|
||||
);
|
||||
|
||||
return {
|
||||
searchProfiles,
|
||||
service,
|
||||
};
|
||||
}
|
||||
@@ -278,3 +278,39 @@ body.animating-layout
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TipTap Editor Styles */
|
||||
.ProseMirror {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Mention styles */
|
||||
.ProseMirror .mention {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ProseMirror .mention:hover {
|
||||
background-color: hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
109
src/lib/chat-parser.test.ts
Normal file
109
src/lib/chat-parser.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseChatCommand } from "./chat-parser";
|
||||
|
||||
describe("parseChatCommand", () => {
|
||||
describe("NIP-29 relay groups", () => {
|
||||
it("should parse NIP-29 group ID without protocol (single arg)", () => {
|
||||
const result = parseChatCommand(["groups.0xchat.com'chachi"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
expect(result.adapter.protocol).toBe("nip-29");
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group ID when split by shell-quote", () => {
|
||||
// shell-quote splits on ' so "groups.0xchat.com'chachi" becomes ["groups.0xchat.com", "chachi"]
|
||||
const result = parseChatCommand(["groups.0xchat.com", "chachi"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
expect(result.adapter.protocol).toBe("nip-29");
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group ID with wss:// protocol (single arg)", () => {
|
||||
const result = parseChatCommand(["wss://groups.0xchat.com'chachi"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group ID with wss:// when split by shell-quote", () => {
|
||||
const result = parseChatCommand(["wss://groups.0xchat.com", "chachi"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group with different relay and group-id (single arg)", () => {
|
||||
const result = parseChatCommand(["relay.example.com'bitcoin-dev"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier.value).toBe("bitcoin-dev");
|
||||
expect(result.identifier.relays).toEqual(["wss://relay.example.com"]);
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group with different relay when split", () => {
|
||||
const result = parseChatCommand(["relay.example.com", "bitcoin-dev"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier.value).toBe("bitcoin-dev");
|
||||
expect(result.identifier.relays).toEqual(["wss://relay.example.com"]);
|
||||
});
|
||||
|
||||
it("should parse NIP-29 group from nos.lol", () => {
|
||||
const result = parseChatCommand(["nos.lol'welcome"]);
|
||||
|
||||
expect(result.protocol).toBe("nip-29");
|
||||
expect(result.identifier.value).toBe("welcome");
|
||||
expect(result.identifier.relays).toEqual(["wss://nos.lol"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw error when no identifier provided", () => {
|
||||
expect(() => parseChatCommand([])).toThrow(
|
||||
"Chat identifier required. Usage: chat <identifier>",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for unsupported identifier format", () => {
|
||||
expect(() => parseChatCommand(["unsupported-format"])).toThrow(
|
||||
/Unable to determine chat protocol/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for npub (NIP-C7 disabled)", () => {
|
||||
expect(() => parseChatCommand(["npub1xyz"])).toThrow(
|
||||
/Unable to determine chat protocol/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for note/nevent (NIP-28 not implemented)", () => {
|
||||
expect(() => parseChatCommand(["note1xyz"])).toThrow(
|
||||
/Unable to determine chat protocol/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for naddr (NIP-53 not implemented)", () => {
|
||||
expect(() => parseChatCommand(["naddr1xyz"])).toThrow(
|
||||
/Unable to determine chat protocol/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
74
src/lib/chat-parser.ts
Normal file
74
src/lib/chat-parser.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ChatCommandResult } from "@/types/chat";
|
||||
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
|
||||
import { Nip29Adapter } from "./chat/adapters/nip-29-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
|
||||
*
|
||||
* Tries each adapter's parseIdentifier() in priority order:
|
||||
* 1. NIP-17 (encrypted DMs) - prioritized for privacy
|
||||
* 2. NIP-28 (channels) - specific event format (kind 40)
|
||||
* 3. NIP-29 (groups) - specific group ID format
|
||||
* 4. NIP-53 (live chat) - specific addressable format (kind 30311)
|
||||
* 5. NIP-C7 (simple chat) - fallback for generic pubkeys
|
||||
*
|
||||
* @param args - Command arguments (first arg is the identifier)
|
||||
* @returns Parsed result with protocol and identifier
|
||||
* @throws Error if no adapter can parse the identifier
|
||||
*/
|
||||
export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
if (args.length === 0) {
|
||||
throw new Error("Chat identifier required. Usage: chat <identifier>");
|
||||
}
|
||||
|
||||
// Handle NIP-29 format that may be split by shell-quote
|
||||
// If we have 2 args and they look like relay + group-id, join them with '
|
||||
let identifier = args[0];
|
||||
if (args.length === 2 && args[0].includes(".") && !args[0].includes("'")) {
|
||||
// Looks like "relay.com" "group-id" split by shell-quote
|
||||
// Rejoin with apostrophe for NIP-29 format
|
||||
identifier = `${args[0]}'${args[1]}`;
|
||||
}
|
||||
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
// new Nip17Adapter(), // Phase 2
|
||||
// new Nip28Adapter(), // Phase 3
|
||||
new Nip29Adapter(), // Phase 4 - Relay groups (currently only enabled)
|
||||
// new Nip53Adapter(), // Phase 5
|
||||
// new NipC7Adapter(), // Phase 1 - Simple chat (disabled for now)
|
||||
];
|
||||
|
||||
for (const adapter of adapters) {
|
||||
const parsed = adapter.parseIdentifier(identifier);
|
||||
if (parsed) {
|
||||
return {
|
||||
protocol: adapter.protocol,
|
||||
identifier: parsed,
|
||||
adapter,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to determine chat protocol from identifier: ${identifier}
|
||||
|
||||
Currently supported formats:
|
||||
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
|
||||
Examples:
|
||||
chat relay.example.com'bitcoin-dev
|
||||
chat wss://relay.example.com'nostr-dev
|
||||
- naddr1... (NIP-29 group metadata, kind 39000)
|
||||
Example:
|
||||
chat naddr1qqxnzdesxqmnxvpexqmny...
|
||||
|
||||
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)`,
|
||||
);
|
||||
}
|
||||
107
src/lib/chat/adapters/base-adapter.ts
Normal file
107
src/lib/chat/adapters/base-adapter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Observable } from "rxjs";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
ChatProtocol,
|
||||
ConversationType,
|
||||
LoadMessagesOptions,
|
||||
CreateConversationParams,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Abstract base class for all chat protocol adapters
|
||||
*
|
||||
* Each adapter implements protocol-specific logic for:
|
||||
* - Identifier parsing and resolution
|
||||
* - Message loading and sending
|
||||
* - Conversation management
|
||||
* - Protocol capabilities
|
||||
*/
|
||||
export abstract class ChatProtocolAdapter {
|
||||
abstract readonly protocol: ChatProtocol;
|
||||
abstract readonly type: ConversationType;
|
||||
|
||||
/**
|
||||
* Parse an identifier string to determine if this adapter can handle it
|
||||
* Returns null if the identifier doesn't match this protocol
|
||||
*/
|
||||
abstract parseIdentifier(input: string): ProtocolIdentifier | null;
|
||||
|
||||
/**
|
||||
* Resolve a protocol identifier into a full Conversation object
|
||||
* May involve fetching metadata from relays
|
||||
*/
|
||||
abstract resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation>;
|
||||
|
||||
/**
|
||||
* Load messages for a conversation
|
||||
* Returns an Observable that emits message arrays as they arrive
|
||||
*/
|
||||
abstract loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]>;
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
abstract loadMoreMessages(
|
||||
conversation: Conversation,
|
||||
before: number,
|
||||
): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Send a message to a conversation
|
||||
* Returns when the message has been published
|
||||
*/
|
||||
abstract sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
replyTo?: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the capabilities of this protocol
|
||||
* Used to determine which UI features to show
|
||||
*/
|
||||
abstract getCapabilities(): ChatCapabilities;
|
||||
|
||||
/**
|
||||
* Load a replied-to message by ID
|
||||
* First checks EventStore, then fetches from protocol-specific relays if needed
|
||||
* Returns null if event cannot be loaded
|
||||
*/
|
||||
abstract loadReplyMessage(
|
||||
conversation: Conversation,
|
||||
eventId: string,
|
||||
): Promise<NostrEvent | null>;
|
||||
|
||||
/**
|
||||
* Load list of all conversations for this protocol
|
||||
* Optional - not all protocols support conversation lists
|
||||
*/
|
||||
loadConversationList?(): Observable<Conversation[]>;
|
||||
|
||||
/**
|
||||
* Create a new conversation
|
||||
* Optional - not all protocols support creation
|
||||
*/
|
||||
createConversation?(params: CreateConversationParams): Promise<Conversation>;
|
||||
|
||||
/**
|
||||
* Join an existing conversation
|
||||
* Optional - only for protocols with join semantics (groups)
|
||||
*/
|
||||
joinConversation?(conversation: Conversation): Promise<void>;
|
||||
|
||||
/**
|
||||
* Leave a conversation
|
||||
* Optional - only for protocols with leave semantics (groups)
|
||||
*/
|
||||
leaveConversation?(conversation: Conversation): Promise<void>;
|
||||
}
|
||||
183
src/lib/chat/adapters/nip-29-adapter.test.ts
Normal file
183
src/lib/chat/adapters/nip-29-adapter.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Nip29Adapter } from "./nip-29-adapter";
|
||||
|
||||
describe("Nip29Adapter", () => {
|
||||
const adapter = new Nip29Adapter();
|
||||
|
||||
describe("parseIdentifier", () => {
|
||||
it("should parse group ID with relay domain (no protocol)", () => {
|
||||
const result = adapter.parseIdentifier("groups.0xchat.com'chachi");
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse group ID with wss:// protocol", () => {
|
||||
const result = adapter.parseIdentifier("wss://groups.0xchat.com'chachi");
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "chachi",
|
||||
relays: ["wss://groups.0xchat.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse group ID with ws:// protocol", () => {
|
||||
const result = adapter.parseIdentifier("ws://relay.example.com'test");
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "test",
|
||||
relays: ["ws://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse various group-id formats", () => {
|
||||
const result1 = adapter.parseIdentifier("relay.example.com'bitcoin-dev");
|
||||
expect(result1?.value).toBe("bitcoin-dev");
|
||||
expect(result1?.relays).toEqual(["wss://relay.example.com"]);
|
||||
|
||||
const result2 = adapter.parseIdentifier("nos.lol'welcome");
|
||||
expect(result2?.value).toBe("welcome");
|
||||
expect(result2?.relays).toEqual(["wss://nos.lol"]);
|
||||
|
||||
const result3 = adapter.parseIdentifier("relay.test.com'my_group_123");
|
||||
expect(result3?.value).toBe("my_group_123");
|
||||
expect(result3?.relays).toEqual(["wss://relay.test.com"]);
|
||||
});
|
||||
|
||||
it("should handle relay URLs with ports", () => {
|
||||
const result = adapter.parseIdentifier(
|
||||
"relay.example.com:7777'testgroup",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "testgroup",
|
||||
relays: ["wss://relay.example.com:7777"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for invalid formats", () => {
|
||||
expect(adapter.parseIdentifier("")).toBeNull();
|
||||
expect(adapter.parseIdentifier("just-a-string")).toBeNull();
|
||||
expect(adapter.parseIdentifier("no-apostrophe")).toBeNull();
|
||||
expect(adapter.parseIdentifier("'missing-relay")).toBeNull();
|
||||
expect(adapter.parseIdentifier("missing-groupid'")).toBeNull();
|
||||
expect(adapter.parseIdentifier("multiple'apostrophes'here")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-NIP-29 identifiers", () => {
|
||||
// These should not match NIP-29 format
|
||||
expect(adapter.parseIdentifier("npub1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("note1...")).toBeNull();
|
||||
expect(adapter.parseIdentifier("alice@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("should parse kind 39000 naddr (group metadata)", () => {
|
||||
// Create a valid kind 39000 naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "bitcoin-dev",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "bitcoin-dev",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle naddr with multiple relays (uses first)", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: [
|
||||
"wss://relay1.example.com",
|
||||
"wss://relay2.example.com",
|
||||
"wss://relay3.example.com",
|
||||
],
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "test-group",
|
||||
relays: ["wss://relay1.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should add wss:// prefix to naddr relay if missing", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: ["relay.example.com"], // No protocol prefix
|
||||
});
|
||||
|
||||
const result = adapter.parseIdentifier(naddr);
|
||||
expect(result).toEqual({
|
||||
type: "group",
|
||||
value: "test-group",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for non-39000 kind naddr", () => {
|
||||
// kind 30311 (live activity) should not work for NIP-29
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30311,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "some-event",
|
||||
relays: ["wss://relay.example.com"],
|
||||
});
|
||||
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for naddr without relays", () => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 39000,
|
||||
pubkey:
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
identifier: "test-group",
|
||||
relays: [], // No relays
|
||||
});
|
||||
|
||||
expect(adapter.parseIdentifier(naddr)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for malformed naddr", () => {
|
||||
expect(adapter.parseIdentifier("naddr1invaliddata")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol properties", () => {
|
||||
it("should have correct protocol and type", () => {
|
||||
expect(adapter.protocol).toBe("nip-29");
|
||||
expect(adapter.type).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
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(true);
|
||||
expect(capabilities.supportsRoles).toBe(true);
|
||||
expect(capabilities.supportsGroupManagement).toBe(true);
|
||||
expect(capabilities.canCreateConversations).toBe(false);
|
||||
expect(capabilities.requiresRelay).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
624
src/lib/chat/adapters/nip-29-adapter.ts
Normal file
624
src/lib/chat/adapters/nip-29-adapter.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ChatProtocolAdapter } 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 { getTagValues } from "@/lib/nostr-utils";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
|
||||
/**
|
||||
* NIP-29 Adapter - Relay-Based Groups
|
||||
*
|
||||
* Features:
|
||||
* - Relay-enforced group membership and moderation
|
||||
* - Admin, moderator, and member roles
|
||||
* - Single relay enforces all group rules
|
||||
* - Group chat messages (kind 9)
|
||||
*
|
||||
* Group ID format: wss://relay.url'group-id
|
||||
* Events use "h" tag with group-id
|
||||
*/
|
||||
export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-29" as const;
|
||||
readonly type = "group" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts group ID format or naddr
|
||||
* Examples:
|
||||
* - wss://relay.example.com'bitcoin-dev
|
||||
* - relay.example.com'bitcoin-dev (wss:// prefix is optional)
|
||||
* - naddr1... (kind 39000 group metadata address)
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try naddr format first (kind 39000 group metadata)
|
||||
if (input.startsWith("naddr1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "naddr" && decoded.data.kind === 39000) {
|
||||
const { identifier, relays } = decoded.data;
|
||||
const relayUrl = relays?.[0];
|
||||
|
||||
if (!identifier || !relayUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure relay URL has wss:// prefix
|
||||
let normalizedRelay = relayUrl;
|
||||
if (
|
||||
!normalizedRelay.startsWith("ws://") &&
|
||||
!normalizedRelay.startsWith("wss://")
|
||||
) {
|
||||
normalizedRelay = `wss://${normalizedRelay}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "group",
|
||||
value: identifier,
|
||||
relays: [normalizedRelay],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not a valid naddr, fall through to try other formats
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-29 format: [wss://]relay'group-id
|
||||
const match = input.match(/^((?:wss?:\/\/)?[^']+)'([^']+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
let [, relayUrl] = match;
|
||||
const groupId = match[2];
|
||||
|
||||
// Add wss:// prefix if not present
|
||||
if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
|
||||
relayUrl = `wss://${relayUrl}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "group",
|
||||
value: groupId,
|
||||
relays: [relayUrl],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from group identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
const groupId = identifier.value;
|
||||
const relayUrl = identifier.relays?.[0];
|
||||
|
||||
if (!relayUrl) {
|
||||
throw new Error("NIP-29 groups require a relay URL");
|
||||
}
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-29] Fetching group metadata for ${groupId} from ${relayUrl}`,
|
||||
);
|
||||
|
||||
// Fetch group metadata from the specific relay (kind 39000)
|
||||
const metadataFilter: Filter = {
|
||||
kinds: [39000],
|
||||
"#d": [groupId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
// Use pool.subscription to fetch from the relay
|
||||
const metadataEvents: NostrEvent[] = [];
|
||||
const metadataObs = pool.subscription([relayUrl], [metadataFilter], {
|
||||
eventStore, // Automatically add to store
|
||||
});
|
||||
|
||||
// Subscribe and wait for EOSE
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("[NIP-29] Metadata fetch timeout");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = metadataObs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timeout);
|
||||
console.log(
|
||||
`[NIP-29] Got ${metadataEvents.length} metadata events`,
|
||||
);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
// Event received
|
||||
metadataEvents.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("[NIP-29] Metadata fetch error:", err);
|
||||
sub.unsubscribe();
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const metadataEvent = metadataEvents[0];
|
||||
|
||||
// Debug: Log metadata event tags
|
||||
if (metadataEvent) {
|
||||
console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags);
|
||||
}
|
||||
|
||||
// Extract group info from metadata event
|
||||
const title = metadataEvent
|
||||
? getTagValues(metadataEvent, "name")[0] || groupId
|
||||
: groupId;
|
||||
const description = metadataEvent
|
||||
? getTagValues(metadataEvent, "about")[0]
|
||||
: undefined;
|
||||
const icon = metadataEvent
|
||||
? getTagValues(metadataEvent, "picture")[0]
|
||||
: undefined;
|
||||
|
||||
console.log(`[NIP-29] Group title: ${title}`);
|
||||
|
||||
// Fetch admins (kind 39001) and members (kind 39002)
|
||||
// Both use d tag (addressable events signed by relay)
|
||||
const participantsFilter: Filter = {
|
||||
kinds: [39001, 39002],
|
||||
"#d": [groupId],
|
||||
limit: 10, // Should be 1 of each kind, but allow for duplicates
|
||||
};
|
||||
|
||||
const participantEvents: NostrEvent[] = [];
|
||||
const participantsObs = pool.subscription(
|
||||
[relayUrl],
|
||||
[participantsFilter],
|
||||
{
|
||||
eventStore,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log("[NIP-29] Participants fetch timeout");
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
const sub = participantsObs.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
clearTimeout(timeout);
|
||||
console.log(
|
||||
`[NIP-29] Got ${participantEvents.length} participant events`,
|
||||
);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
} else {
|
||||
// Event received
|
||||
participantEvents.push(response);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error("[NIP-29] Participants fetch error:", err);
|
||||
sub.unsubscribe();
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to validate and normalize role names
|
||||
const normalizeRole = (role: string | undefined): ParticipantRole => {
|
||||
if (!role) return "member";
|
||||
const lower = role.toLowerCase();
|
||||
if (lower === "admin") return "admin";
|
||||
if (lower === "moderator") return "moderator";
|
||||
if (lower === "host") return "host";
|
||||
// Default to member for unknown roles
|
||||
return "member";
|
||||
};
|
||||
|
||||
// Extract participants from both admins and members events
|
||||
const participantsMap = new Map<string, Participant>();
|
||||
|
||||
// Process kind:39001 (admins with roles)
|
||||
const adminEvents = participantEvents.filter((e) => e.kind === 39001);
|
||||
for (const event of adminEvents) {
|
||||
// Each p tag: ["p", "<pubkey>", "<role1>", "<role2>", ...]
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
const pubkey = tag[1];
|
||||
const roles = tag.slice(2).filter((r) => r); // Get all roles after pubkey
|
||||
const primaryRole = normalizeRole(roles[0]); // Use first role as primary
|
||||
participantsMap.set(pubkey, { pubkey, role: primaryRole });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process kind:39002 (members without roles)
|
||||
const memberEvents = participantEvents.filter((e) => e.kind === 39002);
|
||||
for (const event of memberEvents) {
|
||||
// Each p tag: ["p", "<pubkey>"]
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "p" && tag[1]) {
|
||||
const pubkey = tag[1];
|
||||
// Only add if not already in map (admins take precedence)
|
||||
if (!participantsMap.has(pubkey)) {
|
||||
participantsMap.set(pubkey, { pubkey, role: "member" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const participants = Array.from(participantsMap.values());
|
||||
|
||||
console.log(
|
||||
`[NIP-29] Found ${participants.length} participants (${adminEvents.length} admin events, ${memberEvents.length} member events)`,
|
||||
);
|
||||
console.log(
|
||||
`[NIP-29] Metadata - title: ${title}, icon: ${icon}, description: ${description}`,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `nip-29:${relayUrl}'${groupId}`,
|
||||
type: "group",
|
||||
protocol: "nip-29",
|
||||
title,
|
||||
participants,
|
||||
metadata: {
|
||||
groupId,
|
||||
relayUrl,
|
||||
...(description && { description }),
|
||||
...(icon && { icon }),
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a group
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
|
||||
if (!groupId || !relayUrl) {
|
||||
throw new Error("Group ID and relay URL required");
|
||||
}
|
||||
|
||||
console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`);
|
||||
|
||||
// Subscribe to group messages (kind 9) and admin events (9000-9022)
|
||||
// kind 9: chat messages
|
||||
// kind 9000: put-user (admin adds user)
|
||||
// kind 9001: remove-user (admin removes user)
|
||||
const filter: Filter = {
|
||||
kinds: [9, 9000, 9001],
|
||||
"#h": [groupId],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
|
||||
if (options?.before) {
|
||||
filter.until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Start a persistent subscription to the group relay
|
||||
// This will feed new messages into the EventStore in real-time
|
||||
pool
|
||||
.subscription([relayUrl], [filter], {
|
||||
eventStore, // Automatically add to store
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
console.log("[NIP-29] EOSE received for messages");
|
||||
} else {
|
||||
// Event received
|
||||
console.log(
|
||||
`[NIP-29] 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-29] Timeline has ${events.length} messages`);
|
||||
return events
|
||||
.map((event) => this.eventToMessage(event, conversation.id))
|
||||
.sort((a, b) => a.timestamp - b.timestamp); // Oldest first for flex-col-reverse
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more historical messages (pagination)
|
||||
*/
|
||||
async loadMoreMessages(
|
||||
_conversation: Conversation,
|
||||
_before: number,
|
||||
): Promise<Message[]> {
|
||||
// For now, return empty - pagination to be implemented in Phase 6
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the group
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
replyTo?: 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 groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
|
||||
if (!groupId || !relayUrl) {
|
||||
throw new Error("Group ID and relay URL required");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [["h", groupId]];
|
||||
|
||||
if (replyTo) {
|
||||
// NIP-29 uses q-tag for replies (same as NIP-C7)
|
||||
tags.push(["q", replyTo]);
|
||||
}
|
||||
|
||||
// Use kind 9 for group chat messages
|
||||
const draft = await factory.build({ kind: 9, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish only to the group relay
|
||||
await publishEventToRelays(event, [relayUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false, // kind 9 messages are public
|
||||
supportsThreading: true, // q-tag replies (NIP-C7 style)
|
||||
supportsModeration: true, // kind 9005/9006 for delete/ban
|
||||
supportsRoles: true, // admin, moderator, member
|
||||
supportsGroupManagement: true, // join/leave via kind 9021
|
||||
canCreateConversations: false, // Groups created by admins (kind 9007)
|
||||
requiresRelay: true, // Single relay enforces rules
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a replied-to message
|
||||
* First checks EventStore, then fetches from group relay 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 group relay
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
if (!relayUrl) {
|
||||
console.warn("[NIP-29] No relay URL for loading reply message");
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[NIP-29] Fetching reply message ${eventId.slice(0, 8)}... from ${relayUrl}`,
|
||||
);
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription([relayUrl], [filter], { eventStore });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(
|
||||
`[NIP-29] 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(`[NIP-29] Reply message fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing group
|
||||
*/
|
||||
async joinConversation(conversation: Conversation): 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 groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
|
||||
if (!groupId || !relayUrl) {
|
||||
throw new Error("Group ID and relay URL required");
|
||||
}
|
||||
|
||||
// Create join request (kind 9021)
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["h", groupId],
|
||||
["relay", relayUrl],
|
||||
];
|
||||
|
||||
const draft = await factory.build({
|
||||
kind: 9021,
|
||||
content: "",
|
||||
tags,
|
||||
});
|
||||
const event = await factory.sign(draft);
|
||||
await publishEventToRelays(event, [relayUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a group
|
||||
*/
|
||||
async leaveConversation(conversation: Conversation): 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 groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
|
||||
if (!groupId || !relayUrl) {
|
||||
throw new Error("Group ID and relay URL required");
|
||||
}
|
||||
|
||||
// Create leave request (kind 9022)
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["h", groupId],
|
||||
["relay", relayUrl],
|
||||
];
|
||||
|
||||
const draft = await factory.build({
|
||||
kind: 9022,
|
||||
content: "",
|
||||
tags,
|
||||
});
|
||||
const event = await factory.sign(draft);
|
||||
await publishEventToRelays(event, [relayUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
// Handle admin events (join/leave) as system messages
|
||||
if (event.kind === 9000 || event.kind === 9001) {
|
||||
// Extract the affected user's pubkey from p-tag
|
||||
const pTags = event.tags.filter((t) => t[0] === "p");
|
||||
const affectedPubkey = pTags[0]?.[1] || event.pubkey; // Fall back to event author
|
||||
|
||||
let content = "";
|
||||
if (event.kind === 9000) {
|
||||
// put-user: admin adds someone (show as joined)
|
||||
content = "joined";
|
||||
} else if (event.kind === 9001) {
|
||||
// remove-user: admin removes someone
|
||||
content = affectedPubkey === event.pubkey ? "left" : "was removed";
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: affectedPubkey, // Show the user who joined/left
|
||||
content,
|
||||
timestamp: event.created_at,
|
||||
type: "system",
|
||||
protocol: "nip-29",
|
||||
metadata: {
|
||||
encrypted: false,
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular chat message (kind 9)
|
||||
// Look for reply q-tags (NIP-29 uses q-tags like NIP-C7)
|
||||
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: "nip-29",
|
||||
metadata: {
|
||||
encrypted: false, // kind 9 messages are always public
|
||||
},
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
337
src/lib/chat/adapters/nip-c7-adapter.ts
Normal file
337
src/lib/chat/adapters/nip-c7-adapter.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Observable, firstValueFrom } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter } from "./base-adapter";
|
||||
import type {
|
||||
Conversation,
|
||||
Message,
|
||||
ProtocolIdentifier,
|
||||
ChatCapabilities,
|
||||
LoadMessagesOptions,
|
||||
} from "@/types/chat";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { publishEvent } from "@/services/hub";
|
||||
import accountManager from "@/services/accounts";
|
||||
import { isNip05, resolveNip05 } from "@/lib/nip05";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { isValidHexPubkey } from "@/lib/nostr-validation";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
|
||||
/**
|
||||
* NIP-C7 Adapter - Simple Chat (Kind 9)
|
||||
*
|
||||
* Features:
|
||||
* - Direct messaging between users
|
||||
* - Quote-based threading (q-tag)
|
||||
* - No encryption
|
||||
* - Uses outbox relays
|
||||
*/
|
||||
export class NipC7Adapter extends ChatProtocolAdapter {
|
||||
readonly protocol = "nip-c7" as const;
|
||||
readonly type = "dm" as const;
|
||||
|
||||
/**
|
||||
* Parse identifier - accepts npub, nprofile, hex pubkey, or NIP-05
|
||||
*/
|
||||
parseIdentifier(input: string): ProtocolIdentifier | null {
|
||||
// Try bech32 decoding (npub/nprofile)
|
||||
try {
|
||||
const decoded = nip19.decode(input);
|
||||
if (decoded.type === "npub") {
|
||||
return {
|
||||
type: "chat-partner",
|
||||
value: decoded.data,
|
||||
};
|
||||
}
|
||||
if (decoded.type === "nprofile") {
|
||||
return {
|
||||
type: "chat-partner",
|
||||
value: decoded.data.pubkey,
|
||||
relays: decoded.data.relays,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not bech32, try other formats
|
||||
}
|
||||
|
||||
// Try hex pubkey
|
||||
if (isValidHexPubkey(input)) {
|
||||
return {
|
||||
type: "chat-partner",
|
||||
value: input,
|
||||
};
|
||||
}
|
||||
|
||||
// Try NIP-05
|
||||
if (isNip05(input)) {
|
||||
return {
|
||||
type: "chat-partner-nip05",
|
||||
value: input,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conversation from identifier
|
||||
*/
|
||||
async resolveConversation(
|
||||
identifier: ProtocolIdentifier,
|
||||
): Promise<Conversation> {
|
||||
let pubkey: string;
|
||||
|
||||
// Resolve NIP-05 if needed
|
||||
if (identifier.type === "chat-partner-nip05") {
|
||||
const resolved = await resolveNip05(identifier.value);
|
||||
if (!resolved) {
|
||||
throw new Error(`Failed to resolve NIP-05: ${identifier.value}`);
|
||||
}
|
||||
pubkey = resolved;
|
||||
} else {
|
||||
pubkey = identifier.value;
|
||||
}
|
||||
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
// Get display name for partner
|
||||
const metadataEvent = await this.getMetadata(pubkey);
|
||||
const metadata = metadataEvent
|
||||
? getProfileContent(metadataEvent)
|
||||
: undefined;
|
||||
const title = getDisplayName(pubkey, metadata);
|
||||
|
||||
return {
|
||||
id: `nip-c7:${pubkey}`,
|
||||
type: "dm",
|
||||
protocol: "nip-c7",
|
||||
title,
|
||||
participants: [
|
||||
{ pubkey: activePubkey, role: "member" },
|
||||
{ pubkey, role: "member" },
|
||||
],
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages between active user and conversation partner
|
||||
*/
|
||||
loadMessages(
|
||||
conversation: Conversation,
|
||||
options?: LoadMessagesOptions,
|
||||
): Observable<Message[]> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (!activePubkey) {
|
||||
throw new Error("No active account");
|
||||
}
|
||||
|
||||
const partner = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new Error("No conversation partner found");
|
||||
}
|
||||
|
||||
// Subscribe to kind 9 messages between users
|
||||
const filter: Filter = {
|
||||
kinds: [9],
|
||||
authors: [activePubkey, partner.pubkey],
|
||||
"#p": [activePubkey, partner.pubkey],
|
||||
limit: options?.limit || 50,
|
||||
};
|
||||
|
||||
if (options?.before) {
|
||||
filter.until = options.before;
|
||||
}
|
||||
if (options?.after) {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Start subscription to populate EventStore
|
||||
pool
|
||||
.subscription([], [filter], {
|
||||
eventStore, // Automatically add to store
|
||||
})
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
console.log("[NIP-C7] EOSE received for messages");
|
||||
} else {
|
||||
// Event received
|
||||
console.log(
|
||||
`[NIP-C7] 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-C7] 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[]> {
|
||||
// For now, return empty - pagination to be implemented in Phase 6
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
async sendMessage(
|
||||
conversation: Conversation,
|
||||
content: string,
|
||||
replyTo?: 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 partner = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new Error("No conversation partner found");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [["p", partner.pubkey]];
|
||||
if (replyTo) {
|
||||
tags.push(["q", replyTo]); // NIP-C7 quote tag for threading
|
||||
}
|
||||
|
||||
const draft = await factory.build({ kind: 9, content, tags });
|
||||
const event = await factory.sign(draft);
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
getCapabilities(): ChatCapabilities {
|
||||
return {
|
||||
supportsEncryption: false,
|
||||
supportsThreading: true, // q-tag quotes
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: true,
|
||||
requiresRelay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 - might already be loaded
|
||||
const cachedEvent = await eventStore
|
||||
.event(eventId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (cachedEvent) {
|
||||
return cachedEvent;
|
||||
}
|
||||
|
||||
// Not in store, fetch from relay pool
|
||||
console.log(`[NIP-C7] Fetching reply message ${eventId.slice(0, 8)}...`);
|
||||
|
||||
const filter: Filter = {
|
||||
ids: [eventId],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
const obs = pool.subscription([], [filter], { eventStore }); // Empty relay list = use global pool
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.log(
|
||||
`[NIP-C7] 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(`[NIP-C7] Reply message fetch error:`, err);
|
||||
sub.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return events[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Nostr event to Message
|
||||
*/
|
||||
private eventToMessage(event: NostrEvent, conversationId: string): Message {
|
||||
const quotedEventIds = getTagValues(event, "q");
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
conversationId,
|
||||
author: event.pubkey,
|
||||
content: event.content,
|
||||
timestamp: event.created_at,
|
||||
replyTo: quotedEventIds[0], // First q tag
|
||||
protocol: "nip-c7",
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get user metadata
|
||||
*/
|
||||
private async getMetadata(pubkey: string): Promise<NostrEvent | undefined> {
|
||||
return firstValueFrom(eventStore.replaceable(0, pubkey), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,21 @@ export const hub = new ActionRunner(eventStore, factory, publishEvent);
|
||||
accountManager.active$.subscribe((account) => {
|
||||
factory.setSigner(account?.signer || undefined);
|
||||
});
|
||||
|
||||
export async function publishEventToRelays(
|
||||
event: NostrEvent,
|
||||
relays: string[],
|
||||
): Promise<void> {
|
||||
// If no relays, throw error
|
||||
if (relays.length === 0) {
|
||||
throw new Error(
|
||||
"No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints.",
|
||||
);
|
||||
}
|
||||
|
||||
// Publish to relay pool
|
||||
await pool.publish(relays, event);
|
||||
|
||||
// Add to EventStore for immediate local availability
|
||||
eventStore.add(event);
|
||||
}
|
||||
|
||||
136
src/services/profile-search.ts
Normal file
136
src/services/profile-search.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Index } from "flexsearch";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { getProfileContent } from "applesauce-core/helpers";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
|
||||
export interface ProfileSearchResult {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
nip05?: string;
|
||||
picture?: string;
|
||||
event?: NostrEvent;
|
||||
}
|
||||
|
||||
export class ProfileSearchService {
|
||||
private index: Index;
|
||||
private profiles: Map<string, ProfileSearchResult>;
|
||||
|
||||
constructor() {
|
||||
this.profiles = new Map();
|
||||
this.index = new Index({
|
||||
tokenize: "forward",
|
||||
cache: true,
|
||||
resolution: 9,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a profile to the search index
|
||||
*/
|
||||
async addProfile(event: NostrEvent): Promise<void> {
|
||||
if (event.kind !== 0) return;
|
||||
|
||||
const pubkey = event.pubkey;
|
||||
const metadata = getProfileContent(event);
|
||||
|
||||
const profile: ProfileSearchResult = {
|
||||
pubkey,
|
||||
displayName: getDisplayName(pubkey, metadata),
|
||||
username: metadata?.name,
|
||||
nip05: metadata?.nip05,
|
||||
picture: metadata?.picture,
|
||||
event,
|
||||
};
|
||||
|
||||
this.profiles.set(pubkey, profile);
|
||||
|
||||
// Create searchable text from multiple fields (lowercase for case-insensitive search)
|
||||
const searchText = [
|
||||
profile.displayName,
|
||||
profile.username,
|
||||
profile.nip05,
|
||||
pubkey,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
await this.index.addAsync(pubkey, searchText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple profiles in batch
|
||||
*/
|
||||
async addProfiles(events: NostrEvent[]): Promise<void> {
|
||||
for (const event of events) {
|
||||
await this.addProfile(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a profile from the search index
|
||||
*/
|
||||
async removeProfile(pubkey: string): Promise<void> {
|
||||
this.profiles.delete(pubkey);
|
||||
await this.index.removeAsync(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search profiles by query string
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): Promise<ProfileSearchResult[]> {
|
||||
const { limit = 10, offset = 0 } = options;
|
||||
|
||||
if (!query.trim()) {
|
||||
// Return recent profiles when no query
|
||||
const items = Array.from(this.profiles.values()).slice(
|
||||
offset,
|
||||
offset + limit,
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
// Search index (lowercase for case-insensitive search)
|
||||
const ids = (await this.index.searchAsync(query.toLowerCase(), {
|
||||
limit: limit + offset,
|
||||
})) as string[];
|
||||
|
||||
// Map IDs to profiles
|
||||
const items = ids
|
||||
.slice(offset, offset + limit)
|
||||
.map((id) => this.profiles.get(id))
|
||||
.filter(Boolean) as ProfileSearchResult[];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile by pubkey
|
||||
*/
|
||||
getByPubkey(pubkey: string): ProfileSearchResult | undefined {
|
||||
return this.profiles.get(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all profiles
|
||||
*/
|
||||
clear(): void {
|
||||
this.profiles.clear();
|
||||
this.index = new Index({
|
||||
tokenize: "forward",
|
||||
cache: true,
|
||||
resolution: 9,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of indexed profiles
|
||||
*/
|
||||
get size(): number {
|
||||
return this.profiles.size;
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,21 @@ import "fake-indexeddb/auto";
|
||||
// Polyfill WebSocket - required by nostr-tools relay code
|
||||
import { WebSocket } from "ws";
|
||||
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;
|
||||
|
||||
// Polyfill localStorage - required by state management and accounts
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
globalThis.localStorage = localStorageMock as Storage;
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AppId =
|
||||
| "relay"
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "win";
|
||||
|
||||
149
src/types/chat.ts
Normal file
149
src/types/chat.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { NostrEvent } from "./nostr";
|
||||
|
||||
/**
|
||||
* Chat protocol identifier
|
||||
*/
|
||||
export type ChatProtocol = "nip-c7" | "nip-17" | "nip-28" | "nip-29" | "nip-53";
|
||||
|
||||
/**
|
||||
* Conversation type
|
||||
*/
|
||||
export type ConversationType = "dm" | "channel" | "group" | "live-chat";
|
||||
|
||||
/**
|
||||
* Participant role in a conversation
|
||||
*/
|
||||
export type ParticipantRole = "admin" | "moderator" | "member" | "host";
|
||||
|
||||
/**
|
||||
* Participant in a conversation
|
||||
*/
|
||||
export interface Participant {
|
||||
pubkey: string;
|
||||
role?: ParticipantRole;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-specific conversation metadata
|
||||
*/
|
||||
export interface ConversationMetadata {
|
||||
// NIP-28 channel
|
||||
channelEvent?: NostrEvent; // kind 40 creation event
|
||||
|
||||
// NIP-29 group
|
||||
groupId?: string; // host'group-id format
|
||||
relayUrl?: string; // Relay enforcing group rules
|
||||
description?: string; // Group description
|
||||
icon?: string; // Group icon/picture URL
|
||||
|
||||
// NIP-53 live chat
|
||||
activityAddress?: {
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
};
|
||||
|
||||
// NIP-17 DM
|
||||
encrypted?: boolean;
|
||||
giftWrapped?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic conversation abstraction
|
||||
* Works across all messaging protocols
|
||||
*/
|
||||
export interface Conversation {
|
||||
id: string; // Protocol-specific identifier
|
||||
type: ConversationType;
|
||||
protocol: ChatProtocol;
|
||||
title: string;
|
||||
participants: Participant[];
|
||||
metadata?: ConversationMetadata;
|
||||
lastMessage?: Message;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message metadata (reactions, zaps, encryption status, etc.)
|
||||
*/
|
||||
export interface MessageMetadata {
|
||||
encrypted?: boolean;
|
||||
reactions?: NostrEvent[];
|
||||
zaps?: NostrEvent[];
|
||||
deleted?: boolean;
|
||||
hidden?: boolean; // NIP-28 channel hide
|
||||
}
|
||||
|
||||
/**
|
||||
* Message type - system messages for events like join/leave, user messages for chat
|
||||
*/
|
||||
export type MessageType = "user" | "system";
|
||||
|
||||
/**
|
||||
* Generic message abstraction
|
||||
* Works across all messaging protocols
|
||||
*/
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
author: string; // pubkey
|
||||
content: string;
|
||||
timestamp: number;
|
||||
type?: MessageType; // Defaults to "user" if not specified
|
||||
replyTo?: string; // Parent message ID
|
||||
metadata?: MessageMetadata;
|
||||
protocol: ChatProtocol;
|
||||
event: NostrEvent; // Original Nostr event for verification
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-specific identifier
|
||||
* Returned by adapter parseIdentifier()
|
||||
*/
|
||||
export interface ProtocolIdentifier {
|
||||
type: string; // e.g., 'dm-recipient', 'channel-event', 'group-id'
|
||||
value: any; // Protocol-specific value
|
||||
relays?: string[]; // Relay hints from bech32 encoding
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat command parsing result
|
||||
*/
|
||||
export interface ChatCommandResult {
|
||||
protocol: ChatProtocol;
|
||||
identifier: ProtocolIdentifier;
|
||||
adapter: any; // Will be ChatProtocolAdapter but avoiding circular dependency
|
||||
}
|
||||
|
||||
/**
|
||||
* Message loading options
|
||||
*/
|
||||
export interface LoadMessagesOptions {
|
||||
limit?: number;
|
||||
before?: number; // Unix timestamp
|
||||
after?: number; // Unix timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation creation parameters
|
||||
*/
|
||||
export interface CreateConversationParams {
|
||||
type: ConversationType;
|
||||
title?: string;
|
||||
participants: string[]; // pubkeys
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat capabilities - what features a protocol supports
|
||||
*/
|
||||
export interface ChatCapabilities {
|
||||
supportsEncryption: boolean;
|
||||
supportsThreading: boolean;
|
||||
supportsModeration: boolean;
|
||||
supportsRoles: boolean;
|
||||
supportsGroupManagement: boolean;
|
||||
canCreateConversations: boolean;
|
||||
requiresRelay: boolean;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parseOpenCommand } from "@/lib/open-parser";
|
||||
import { parseProfileCommand } from "@/lib/profile-parser";
|
||||
import { parseRelayCommand } from "@/lib/relay-parser";
|
||||
import { resolveNip05Batch } from "@/lib/nip05";
|
||||
import { parseChatCommand } from "@/lib/chat-parser";
|
||||
|
||||
export interface ManPageEntry {
|
||||
name: string;
|
||||
@@ -344,6 +345,35 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
name: "chat",
|
||||
section: "1",
|
||||
synopsis: "chat <group-identifier>",
|
||||
description:
|
||||
"Join and participate in NIP-29 relay-based group chats. Groups are hosted on a single relay that enforces membership and moderation rules. Use the format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional) and group-id is the group identifier.",
|
||||
options: [
|
||||
{
|
||||
flag: "<group-identifier>",
|
||||
description:
|
||||
"NIP-29 group identifier in format: relay'group-id (wss:// prefix optional)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"chat relay.example.com'bitcoin-dev Join relay group (wss:// prefix optional)",
|
||||
"chat wss://relay.example.com'nostr-dev Join relay group with explicit protocol",
|
||||
"chat nos.lol'welcome Join welcome group on nos.lol",
|
||||
],
|
||||
seeAlso: ["profile", "open", "req"],
|
||||
appId: "chat",
|
||||
category: "Nostr",
|
||||
argParser: async (args: string[]) => {
|
||||
const result = parseChatCommand(args);
|
||||
return {
|
||||
protocol: result.protocol,
|
||||
identifier: result.identifier,
|
||||
};
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
name: "profile",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user