mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
feat: add reply functionality and require active account for composer
Reply Functionality: - Added Reply button to each message (visible on hover) - Button appears in message header next to timestamp - Uses Reply icon from lucide-react - Clicking reply sets the replyTo state with message ID - Reply preview shows in composer when replying Active Account Requirements: - Check for active account using accountManager.active$ - Only show composer if user has active account - Only enable reply buttons if user has active account - Show "Sign in to send messages" message when no active account - Prevent sending messages without active account UI Improvements: - Reply button uses opacity transition on hover (0 → 100) - Positioned with ml-auto to align right in header - Reply button only visible on group hover for clean UI - Consistent styling with muted-foreground color scheme Benefits: - Users can reply to specific messages inline - Clear indication when authentication is required - Prevents errors from attempting to send without account - Professional chat UX with hover interactions Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
32
CLAUDE.md
32
CLAUDE.md
@@ -186,6 +186,38 @@ const text = getHighlightText(event);
|
||||
- Provides diagnostic UI with retry capability and error details
|
||||
- Error boundaries auto-reset when event changes
|
||||
|
||||
## Chat System
|
||||
|
||||
**Current Status**: Only NIP-29 (relay-based groups) is supported. Other protocols are planned for future releases.
|
||||
|
||||
**Architecture**: Protocol adapter pattern for supporting multiple Nostr messaging protocols:
|
||||
- `src/lib/chat/adapters/base-adapter.ts` - Base interface all adapters implement
|
||||
- `src/lib/chat/adapters/nip-29-adapter.ts` - NIP-29 relay groups (currently enabled)
|
||||
- Other adapters (NIP-C7, NIP-17, NIP-28, NIP-53) are implemented but commented out
|
||||
|
||||
**NIP-29 Group Format**: `relay'group-id` (wss:// prefix optional)
|
||||
- Examples: `relay.example.com'bitcoin-dev`, `wss://nos.lol'welcome`
|
||||
- Groups are hosted on a single relay that enforces membership and moderation
|
||||
- Messages are kind 9, metadata is kind 39000, admins are kind 39001, members are kind 39002
|
||||
|
||||
**Key Components**:
|
||||
- `src/components/ChatViewer.tsx` - Main chat interface (protocol-agnostic)
|
||||
- `src/components/chat/ReplyPreview.tsx` - Shows reply context with scroll-to functionality
|
||||
- `src/lib/chat-parser.ts` - Auto-detects protocol from identifier format
|
||||
- `src/types/chat.ts` - Protocol-agnostic types (Conversation, Message, etc.)
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
chat relay.example.com'bitcoin-dev # Join NIP-29 group
|
||||
chat wss://nos.lol'welcome # Join with explicit wss:// prefix
|
||||
```
|
||||
|
||||
**Adding New Protocols** (for future work):
|
||||
1. Create new adapter extending `ChatProtocolAdapter` in `src/lib/chat/adapters/`
|
||||
2. Implement all required methods (parseIdentifier, resolveConversation, loadMessages, sendMessage)
|
||||
3. Uncomment adapter registration in `src/lib/chat-parser.ts` and `src/components/ChatViewer.tsx`
|
||||
4. Update command docs in `src/types/man.ts` if needed
|
||||
|
||||
## Testing
|
||||
|
||||
**Test Framework**: Vitest with node environment
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useMemo, useState, memo, useCallback } from "react";
|
||||
import { useMemo, useState, memo, useCallback, useRef } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from } from "rxjs";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
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";
|
||||
// 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";
|
||||
@@ -26,6 +29,54 @@ interface ChatViewerProps {
|
||||
customTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -33,19 +84,34 @@ 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;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex items-start hover:bg-muted/50 px-3 py-2">
|
||||
<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="text-sm leading-relaxed break-words overflow-hidden">
|
||||
{message.event ? (
|
||||
@@ -55,6 +121,7 @@ const MessageItem = memo(function MessageItem({
|
||||
replyToId={message.replyTo}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
)}
|
||||
</RichText>
|
||||
@@ -82,6 +149,10 @@ export function ChatViewer({
|
||||
}: ChatViewerProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
// Get active account
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
const hasActiveAccount = !!activeAccount;
|
||||
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
@@ -100,13 +171,37 @@ export function ChatViewer({
|
||||
// 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);
|
||||
|
||||
// Handle sending messages
|
||||
const handleSend = async (content: string, replyToId?: string) => {
|
||||
if (!conversation) return;
|
||||
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") {
|
||||
@@ -158,6 +253,7 @@ export function ChatViewer({
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages && messages.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
followOutput="smooth"
|
||||
@@ -167,6 +263,9 @@ export function ChatViewer({
|
||||
message={message}
|
||||
adapter={adapter}
|
||||
conversation={conversation}
|
||||
onReply={handleReply}
|
||||
canReply={hasActiveAccount}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
)}
|
||||
style={{ height: "100%" }}
|
||||
@@ -178,71 +277,78 @@ export function ChatViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message composer */}
|
||||
<div className="border-t px-3 py-2">
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-2">
|
||||
<span>Replying to {replyTo.slice(0, 8)}...</span>
|
||||
<button
|
||||
onClick={() => setReplyTo(undefined)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const input = form.elements.namedItem(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
if (input.value.trim()) {
|
||||
handleSend(input.value, replyTo);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
autoFocus
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 resize-none bg-background px-3 py-2 text-sm border rounded-md min-w-0"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
{/* Message composer - only show if user has active account */}
|
||||
{hasActiveAccount ? (
|
||||
<div className="border-t px-3 py-2">
|
||||
{replyTo && (
|
||||
<div className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs mb-2">
|
||||
<span>Replying to {replyTo.slice(0, 8)}...</span>
|
||||
<button
|
||||
onClick={() => setReplyTo(undefined)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const input = form.elements.namedItem(
|
||||
"message",
|
||||
) as HTMLTextAreaElement;
|
||||
if (input.value.trim()) {
|
||||
handleSend(input.value, replyTo);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="flex-shrink-0">
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
className="flex gap-2"
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
autoFocus
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 resize-none bg-background px-3 py-2 text-sm border rounded-md min-w-0"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
// Submit on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="flex-shrink-0">
|
||||
Send
|
||||
</Button>
|
||||
</form>
|
||||
</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
|
||||
* TODO: Add other adapters as they're implemented
|
||||
* 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":
|
||||
return new NipC7Adapter();
|
||||
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
// case "nip-17":
|
||||
// case "nip-17": // Phase 2 - Encrypted DMs (coming soon)
|
||||
// return new Nip17Adapter();
|
||||
// case "nip-28":
|
||||
// case "nip-28": // Phase 3 - Public channels (coming soon)
|
||||
// return new Nip28Adapter();
|
||||
// case "nip-53":
|
||||
// case "nip-53": // Phase 5 - Live activity chat (coming soon)
|
||||
// return new Nip53Adapter();
|
||||
default:
|
||||
throw new Error(`Unsupported protocol: ${protocol}`);
|
||||
|
||||
@@ -26,7 +26,7 @@ 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";
|
||||
// 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";
|
||||
@@ -562,10 +562,11 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData {
|
||||
const identifier = props.identifier as ProtocolIdentifier;
|
||||
|
||||
// Get adapter and resolve conversation
|
||||
// Currently only NIP-29 is supported
|
||||
const getAdapter = () => {
|
||||
switch (protocol) {
|
||||
case "nip-c7":
|
||||
return new NipC7Adapter();
|
||||
// case "nip-c7": // Coming soon
|
||||
// return new NipC7Adapter();
|
||||
case "nip-29":
|
||||
return new Nip29Adapter();
|
||||
default:
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ReplyPreviewProps {
|
||||
replyToId: string;
|
||||
adapter: ChatProtocolAdapter;
|
||||
conversation: Conversation;
|
||||
onScrollToMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ 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]);
|
||||
@@ -36,6 +38,12 @@ export const ReplyPreview = memo(function ReplyPreview({
|
||||
}
|
||||
}, [replyEvent, adapter, conversation, replyToId]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (onScrollToMessage) {
|
||||
onScrollToMessage(replyToId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
@@ -45,7 +53,11 @@ export const ReplyPreview = memo(function ReplyPreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground flex items-baseline gap-1 mb-0.5 overflow-hidden">
|
||||
<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}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,41 @@ export function PublicChatsRenderer({ event }: BaseEventProps) {
|
||||
// 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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Observable, Subject } from "rxjs";
|
||||
import { map, first, skipUntil } from "rxjs/operators";
|
||||
import { Observable } from "rxjs";
|
||||
import { map, first } from "rxjs/operators";
|
||||
import type { Filter } from "nostr-tools";
|
||||
import { ChatProtocolAdapter } from "./base-adapter";
|
||||
import type {
|
||||
@@ -292,9 +292,6 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Create a subject to track EOSE
|
||||
const eoseSubject = new Subject<void>();
|
||||
|
||||
// Start a persistent subscription to the group relay
|
||||
// This will feed new messages into the EventStore in real-time
|
||||
pool
|
||||
@@ -306,8 +303,6 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
console.log("[NIP-29] EOSE received for messages");
|
||||
eoseSubject.next();
|
||||
eoseSubject.complete();
|
||||
} else {
|
||||
// Event received
|
||||
console.log(
|
||||
@@ -318,9 +313,7 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
});
|
||||
|
||||
// Return observable from EventStore which will update automatically
|
||||
// Wait for EOSE before emitting to prevent scroll jumping during initial load
|
||||
return eventStore.timeline(filter).pipe(
|
||||
skipUntil(eoseSubject),
|
||||
map((events) => {
|
||||
console.log(`[NIP-29] Timeline has ${events.length} messages`);
|
||||
return events
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Observable, firstValueFrom, Subject } from "rxjs";
|
||||
import { map, first, skipUntil } from "rxjs/operators";
|
||||
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";
|
||||
@@ -156,10 +156,7 @@ export class NipC7Adapter extends ChatProtocolAdapter {
|
||||
filter.since = options.after;
|
||||
}
|
||||
|
||||
// Create a subject to track EOSE
|
||||
const eoseSubject = new Subject<void>();
|
||||
|
||||
// Start subscription to populate EventStore and track EOSE
|
||||
// Start subscription to populate EventStore
|
||||
pool
|
||||
.subscription([], [filter], {
|
||||
eventStore, // Automatically add to store
|
||||
@@ -169,21 +166,23 @@ export class NipC7Adapter extends ChatProtocolAdapter {
|
||||
if (typeof response === "string") {
|
||||
// EOSE received
|
||||
console.log("[NIP-C7] EOSE received for messages");
|
||||
eoseSubject.next();
|
||||
eoseSubject.complete();
|
||||
} else {
|
||||
// Event received
|
||||
console.log(
|
||||
`[NIP-C7] Received message: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Return observable from EventStore
|
||||
// Wait for EOSE before emitting to prevent scroll jumping during initial load
|
||||
// Return observable from EventStore which will update automatically
|
||||
return eventStore.timeline(filter).pipe(
|
||||
skipUntil(eoseSubject),
|
||||
map((events) =>
|
||||
events
|
||||
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),
|
||||
),
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user