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:
Alejandro Gómez
2026-01-11 22:17:13 +01:00
parent cfd897f96c
commit fc2e680afd
8 changed files with 268 additions and 83 deletions

View File

@@ -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

View File

@@ -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}`);

View File

@@ -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:

View File

@@ -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}

View File

@@ -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

View File

@@ -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: {

View File

@@ -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

View File

@@ -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);
}),
);
}