mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
* feat: add configurable zap tagging for chat messages Implements a protocol adapter interface for configuring how zap requests should be tagged for chat messages. This enables proper NIP-53 live activity zapping with appropriate a-tag and goal e-tag support. Changes: - Add ZapConfig interface to base-adapter for protocol-specific zap configuration - Add getZapConfig() method to ChatProtocolAdapter (default: unsupported) - Implement getZapConfig() in NIP-53 adapter with proper tagging: - Always a-tag the live activity (kind 30311) - If zapping host with goal, also e-tag the goal event - Add goal tag parsing to live-activity.ts and types - Update createZapRequest to accept custom tags parameter - Add Zap action to ChatMessageContextMenu (shown when supported) - Update ZapWindow to pass custom tags through to zap request - NIP-29 groups inherit default (unsupported) behavior * feat: add custom tags and relays to zap command Extends the zap command to support custom tags and relay specification, enabling full translation from chat zap config to zap command. Changes: - Add -T/--tag flag to specify custom tags (type, value, optional relay hint) - Add -r/--relay flag to specify where zap receipt should be published - Update ZapWindow to accept and pass through relays prop - Update ChatMessageContextMenu to pass relays from zapConfig - Update man page with new options and examples - Add comprehensive tests for zap parser flag handling Example usage: zap npub... -T a 30311:pk:id wss://relay.example.com zap npub... -r wss://relay1.com -r wss://relay2.com * fix: include event pointer when zapping chat messages Pass the message event as eventPointer when opening ZapWindow from chat context menu. This enables: - Event preview in the zap window - Proper window title showing "Zap [username]" * feat: add zap command reconstruction for Edit feature Add zap case to command-reconstructor.ts so that clicking "Edit" on a zap window title shows a complete command with: - Recipient as npub - Event pointer as nevent/naddr - Custom tags with -T flags - Relays with -r flags This enables users to see and modify the full zap configuration. * fix: separate eventPointer and addressPointer for proper zap tagging - Refactor createZapRequest to use separate eventPointer (for e-tag) and addressPointer (for a-tag) instead of a union type - Remove duplicate p-tag issue (only tag recipient, not event author) - Remove duplicate e-tag issue (only one e-tag with relay hint if available) - Update ZapConfig interface to include addressPointer field - Update NIP-53 adapter to return addressPointer for live activity context - Update ChatMessageContextMenu to pass addressPointer from zapConfig - Update command-reconstructor to properly serialize addressPointer as -T a - Update ZapWindow to pass addressPointer to createZapRequest This ensures proper NIP-53 zap tagging: message author gets p-tag, live activity gets a-tag, and message event gets e-tag (all separate). * refactor: move eventPointer to ZapConfig for NIP-53 adapter - Add eventPointer field to ZapConfig interface for message e-tag - NIP-53 adapter now returns eventPointer from getZapConfig - ChatMessageContextMenu uses eventPointer from zapConfig directly - Remove goal logic from NIP-53 zap config (simplify for now) This gives the adapter full control over zap configuration, including which event to reference in the e-tag. * fix: update zap-parser to return separate eventPointer and addressPointer The ParsedZapCommand interface now properly separates: - eventPointer: for regular events (nevent, note, hex ID) → e-tag - addressPointer: for addressable events (naddr) → a-tag This aligns with ZapWindowProps which expects separate fields, fixing the issue where addressPointer from naddr was being passed as eventPointer and ignored. * feat: improve relay selection for zap requests with e+a tags When both eventPointer and addressPointer are provided: - Collect outbox relays from both semantic authors - Include relay hints from both pointers - Deduplicate and use combined relay set Priority order: 1. Explicit params.relays (respects CLI -r flags) 2. Semantic author outbox relays + pointer relay hints 3. Sender read relays (fallback) 4. Aggregator relays (final fallback) * fix: pass all zap props from WindowRenderer to ZapWindow WindowRenderer was only passing recipientPubkey and eventPointer, dropping addressPointer, customTags, and relays. This caused CLI flags like -T (custom tags) and -r (relays) to be ignored. Now all parsed zap command props flow through to ZapWindow and subsequently to createZapRequest. * refactor: let createZapRequest collect relays from both authors Remove top-level relays from NIP-53 zapConfig so createZapRequest can automatically collect outbox relays from both: - eventPointer.author (message author / zap recipient) - addressPointer.pubkey (stream host) The relay hints in the pointers are still included via the existing logic in createZapRequest. * fix: deduplicate explicit relays in createZapRequest Ensure params.relays is deduplicated before use, not just the automatically collected relays. This handles cases where CLI -r flags might specify duplicate relay URLs. --------- Co-authored-by: Claude <noreply@anthropic.com>
1092 lines
36 KiB
TypeScript
1092 lines
36 KiB
TypeScript
import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react";
|
|
import { use$ } from "applesauce-react/hooks";
|
|
import { from, catchError, of, map } from "rxjs";
|
|
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
|
import {
|
|
Loader2,
|
|
Reply,
|
|
Zap,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
Paperclip,
|
|
Copy,
|
|
CopyCheck,
|
|
} from "lucide-react";
|
|
import { nip19 } from "nostr-tools";
|
|
import { getZapRequest } from "applesauce-common/helpers/zap";
|
|
import { toast } from "sonner";
|
|
import eventStore from "@/services/event-store";
|
|
import type {
|
|
ChatProtocol,
|
|
ProtocolIdentifier,
|
|
Conversation,
|
|
LiveActivityMetadata,
|
|
} from "@/types/chat";
|
|
import { CHAT_KINDS } from "@/types/chat";
|
|
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
|
|
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
|
|
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
|
|
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
|
import type { Message } from "@/types/chat";
|
|
import type { ChatAction } from "@/types/chat-actions";
|
|
import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
|
|
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 { MessageReactions } from "./chat/MessageReactions";
|
|
import { StatusBadge } from "./live/StatusBadge";
|
|
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
|
import { useGrimoire } from "@/core/state";
|
|
import { Button } from "./ui/button";
|
|
import {
|
|
MentionEditor,
|
|
type MentionEditorHandle,
|
|
type EmojiTag,
|
|
type BlobAttachment,
|
|
} from "./editor/MentionEditor";
|
|
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
|
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
|
import { useCopy } from "@/hooks/useCopy";
|
|
import { useAccount } from "@/hooks/useAccount";
|
|
import { Label } from "./ui/label";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "./ui/tooltip";
|
|
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
|
|
|
interface ChatViewerProps {
|
|
protocol: ChatProtocol;
|
|
identifier: ProtocolIdentifier;
|
|
customTitle?: string;
|
|
/** Optional content to render before the title (e.g., sidebar toggle on mobile) */
|
|
headerPrefix?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard for LiveActivityMetadata
|
|
*/
|
|
function isLiveActivityMetadata(value: unknown): value is LiveActivityMetadata {
|
|
if (!value || typeof value !== "object") return false;
|
|
const obj = value as Record<string, unknown>;
|
|
return (
|
|
typeof obj.status === "string" &&
|
|
typeof obj.hostPubkey === "string" &&
|
|
Array.isArray(obj.hashtags) &&
|
|
Array.isArray(obj.relays)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get relay URLs for a conversation based on protocol
|
|
* Used for fetching protocol-specific data like reactions
|
|
*/
|
|
function getConversationRelays(conversation: Conversation): string[] {
|
|
// NIP-53 live chats: Use full relay list from liveActivity metadata
|
|
if (conversation.protocol === "nip-53") {
|
|
const liveActivity = conversation.metadata?.liveActivity;
|
|
if (isLiveActivityMetadata(liveActivity) && liveActivity.relays) {
|
|
return liveActivity.relays;
|
|
}
|
|
}
|
|
|
|
// NIP-29 groups and fallback: Use single relay URL
|
|
const relayUrl = conversation.metadata?.relayUrl;
|
|
return relayUrl ? [relayUrl] : [];
|
|
}
|
|
|
|
/**
|
|
* Get the chat command identifier for a conversation
|
|
* Returns a string that can be passed to the `chat` command to open this conversation
|
|
*
|
|
* For NIP-29 groups: relay'group-id (without wss:// prefix)
|
|
* For NIP-53 live activities: naddr1... encoding
|
|
*/
|
|
function getChatIdentifier(conversation: Conversation): string | null {
|
|
if (conversation.protocol === "nip-29") {
|
|
const groupId = conversation.metadata?.groupId;
|
|
const relayUrl = conversation.metadata?.relayUrl;
|
|
if (!groupId || !relayUrl) return null;
|
|
|
|
// Strip wss:// or ws:// prefix for cleaner identifier
|
|
const cleanRelay = relayUrl.replace(/^wss?:\/\//, "");
|
|
return `${cleanRelay}'${groupId}`;
|
|
}
|
|
|
|
if (conversation.protocol === "nip-53") {
|
|
const activityAddress = conversation.metadata?.activityAddress;
|
|
if (!activityAddress) return null;
|
|
|
|
// Get relay hints from live activity metadata
|
|
const liveActivity = conversation.metadata?.liveActivity;
|
|
const relays = liveActivity?.relays || [];
|
|
|
|
return nip19.naddrEncode({
|
|
kind: activityAddress.kind,
|
|
pubkey: activityAddress.pubkey,
|
|
identifier: activityAddress.identifier,
|
|
relays: relays.slice(0, 3), // Limit relay hints to keep naddr short
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Conversation resolution result - either success with conversation or error
|
|
*/
|
|
type ConversationResult =
|
|
| { status: "loading" }
|
|
| { status: "success"; conversation: Conversation }
|
|
| { status: "error"; error: 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"
|
|
/>
|
|
<div className="flex-1 min-w-0 line-clamp-1 overflow-hidden text-muted-foreground">
|
|
<RichText
|
|
event={replyEvent}
|
|
options={{ showMedia: false, showEventEmbeds: false }}
|
|
/>
|
|
</div>
|
|
<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;
|
|
}) {
|
|
// Get relays for this conversation (memoized to prevent unnecessary re-subscriptions)
|
|
const relays = useMemo(
|
|
() => getConversationRelays(conversation),
|
|
[conversation],
|
|
);
|
|
|
|
// 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>
|
|
);
|
|
}
|
|
|
|
// Zap messages have special styling with gradient border
|
|
if (message.type === "zap") {
|
|
const zapRequest = message.event ? getZapRequest(message.event) : null;
|
|
// For NIP-57 zaps, reply target is in the zap request's e-tag
|
|
// For NIP-61 nutzaps, reply target is already in message.replyTo
|
|
const zapReplyTo =
|
|
message.replyTo ||
|
|
zapRequest?.tags.find((t) => t[0] === "e")?.[1] ||
|
|
undefined;
|
|
|
|
// Check if the replied-to event exists and is a chat kind
|
|
const replyEvent = use$(
|
|
() => (zapReplyTo ? eventStore.event(zapReplyTo) : undefined),
|
|
[zapReplyTo],
|
|
);
|
|
|
|
// Only show reply preview if:
|
|
// 1. The event exists in our store
|
|
// 2. The event is a chat kind (includes messages, nutzaps, live chat, and zap receipts)
|
|
const shouldShowReplyPreview =
|
|
zapReplyTo &&
|
|
replyEvent &&
|
|
(CHAT_KINDS as readonly number[]).includes(replyEvent.kind);
|
|
|
|
return (
|
|
<div className="pl-2 my-1">
|
|
<div
|
|
className="p-[1px] rounded"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to right, rgb(250 204 21), rgb(251 146 60), rgb(168 85 247), rgb(34 211 238))",
|
|
}}
|
|
>
|
|
<div className="bg-background px-1 rounded-sm">
|
|
<div className="flex items-center gap-2">
|
|
<UserName
|
|
pubkey={message.author}
|
|
className="font-semibold text-sm"
|
|
/>
|
|
<Zap className="size-4 fill-yellow-500 text-yellow-500" />
|
|
<span className="text-yellow-500 font-bold">
|
|
{(message.metadata?.zapAmount || 0).toLocaleString("en", {
|
|
notation: "compact",
|
|
})}
|
|
</span>
|
|
{message.metadata?.zapRecipient && (
|
|
<UserName
|
|
pubkey={message.metadata.zapRecipient}
|
|
className="text-sm"
|
|
/>
|
|
)}
|
|
<span className="text-xs text-muted-foreground">
|
|
<Timestamp timestamp={message.timestamp} />
|
|
</span>
|
|
{/* Reactions display - inline after timestamp */}
|
|
<MessageReactions messageId={message.id} relays={relays} />
|
|
</div>
|
|
{shouldShowReplyPreview && (
|
|
<ReplyPreview
|
|
replyToId={zapReplyTo}
|
|
adapter={adapter}
|
|
conversation={conversation}
|
|
onScrollToMessage={onScrollToMessage}
|
|
/>
|
|
)}
|
|
{message.content && (
|
|
<RichText
|
|
event={zapRequest || message.event}
|
|
className="text-sm leading-tight break-words"
|
|
options={{ showMedia: false, showEventEmbeds: false }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Regular user messages - wrap in context menu if event exists
|
|
const messageContent = (
|
|
<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>
|
|
{/* Reactions display - inline after timestamp */}
|
|
<MessageReactions messageId={message.id} relays={relays} />
|
|
{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>
|
|
);
|
|
|
|
// Wrap in context menu if event exists
|
|
if (message.event) {
|
|
return (
|
|
<ChatMessageContextMenu
|
|
event={message.event}
|
|
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
|
|
conversation={conversation}
|
|
adapter={adapter}
|
|
message={message}
|
|
>
|
|
{messageContent}
|
|
</ChatMessageContextMenu>
|
|
);
|
|
}
|
|
|
|
return messageContent;
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
headerPrefix,
|
|
}: ChatViewerProps) {
|
|
const { addWindow } = useGrimoire();
|
|
|
|
// Get active account with signing capability
|
|
const { pubkey, canSign, signer } = useAccount();
|
|
|
|
// Profile search for mentions
|
|
const { searchProfiles } = useProfileSearch();
|
|
|
|
// Emoji search for custom emoji autocomplete
|
|
const { searchEmojis } = useEmojiSearch();
|
|
|
|
// Copy chat identifier to clipboard
|
|
const { copy: copyChatId, copied: chatIdCopied } = useCopy();
|
|
|
|
// Ref to MentionEditor for programmatic submission
|
|
const editorRef = useRef<MentionEditorHandle>(null);
|
|
|
|
// Blossom upload hook for file attachments
|
|
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
|
|
accept: "image/*,video/*,audio/*",
|
|
onSuccess: (results) => {
|
|
if (results.length > 0 && editorRef.current) {
|
|
// Insert the first successful upload as a blob attachment with metadata
|
|
const { blob, server } = results[0];
|
|
editorRef.current.insertBlob({
|
|
url: blob.url,
|
|
sha256: blob.sha256,
|
|
mimeType: blob.type,
|
|
size: blob.size,
|
|
server,
|
|
});
|
|
editorRef.current.focus();
|
|
}
|
|
},
|
|
});
|
|
|
|
// Get the appropriate adapter for this protocol
|
|
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
|
|
|
// State for retry trigger
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
|
|
// Resolve conversation from identifier with error handling
|
|
const conversationResult = use$(
|
|
() =>
|
|
from(adapter.resolveConversation(identifier)).pipe(
|
|
map(
|
|
(conv): ConversationResult => ({
|
|
status: "success",
|
|
conversation: conv,
|
|
}),
|
|
),
|
|
catchError((err) => {
|
|
console.error("[Chat] Failed to resolve conversation:", err);
|
|
const errorMessage =
|
|
err instanceof Error ? err.message : "Failed to load conversation";
|
|
return of<ConversationResult>({
|
|
status: "error",
|
|
error: errorMessage,
|
|
});
|
|
}),
|
|
),
|
|
[adapter, identifier, retryCount],
|
|
);
|
|
|
|
// Extract conversation from result (null while loading or on error)
|
|
const conversation =
|
|
conversationResult?.status === "success"
|
|
? conversationResult.conversation
|
|
: null;
|
|
|
|
// Slash command search for action autocomplete
|
|
// Context-aware: only shows relevant actions based on membership status
|
|
const searchCommands = useCallback(
|
|
async (query: string) => {
|
|
const availableActions = adapter.getActions({
|
|
conversation: conversation || undefined,
|
|
activePubkey: pubkey,
|
|
});
|
|
const lowerQuery = query.toLowerCase();
|
|
return availableActions.filter((action) =>
|
|
action.name.toLowerCase().includes(lowerQuery),
|
|
);
|
|
},
|
|
[adapter, conversation, pubkey],
|
|
);
|
|
|
|
// Cleanup subscriptions when conversation changes or component unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
if (conversation) {
|
|
adapter.cleanup(conversation.id);
|
|
}
|
|
};
|
|
}, [adapter, conversation]);
|
|
|
|
// 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>();
|
|
|
|
// State for loading older messages
|
|
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
// Ref to Virtuoso for programmatic scrolling
|
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
|
|
// State for send in progress (prevents double-sends)
|
|
const [isSending, setIsSending] = useState(false);
|
|
|
|
// State for tooltip open (for mobile tap support)
|
|
const [tooltipOpen, setTooltipOpen] = useState(false);
|
|
|
|
// Handle sending messages with error handling
|
|
const handleSend = async (
|
|
content: string,
|
|
replyToId?: string,
|
|
emojiTags?: EmojiTag[],
|
|
blobAttachments?: BlobAttachment[],
|
|
) => {
|
|
if (!conversation || !canSign || isSending) return;
|
|
|
|
// Check if this is a slash command
|
|
const slashCmd = parseSlashCommand(content);
|
|
if (slashCmd) {
|
|
// Execute action instead of sending message
|
|
setIsSending(true);
|
|
try {
|
|
const result = await adapter.executeAction(slashCmd.command, {
|
|
activePubkey: pubkey!,
|
|
activeSigner: signer!,
|
|
conversation,
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success(result.message || "Action completed");
|
|
} else {
|
|
toast.error(result.message || "Action failed");
|
|
}
|
|
} catch (error) {
|
|
console.error("[Chat] Failed to execute action:", error);
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Action failed";
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Regular message sending
|
|
setIsSending(true);
|
|
try {
|
|
await adapter.sendMessage(conversation, content, {
|
|
replyTo: replyToId,
|
|
emojiTags,
|
|
blobAttachments,
|
|
});
|
|
setReplyTo(undefined); // Clear reply context only on success
|
|
} catch (error) {
|
|
console.error("[Chat] Failed to send message:", error);
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Failed to send message";
|
|
toast.error(errorMessage);
|
|
// Don't clear replyTo so user can retry
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
// Handle command execution from autocomplete
|
|
const handleCommandExecute = useCallback(
|
|
async (action: ChatAction) => {
|
|
if (!conversation || !canSign || isSending) return;
|
|
|
|
setIsSending(true);
|
|
try {
|
|
const result = await adapter.executeAction(action.name, {
|
|
activePubkey: pubkey!,
|
|
activeSigner: signer!,
|
|
conversation,
|
|
});
|
|
|
|
if (result.success) {
|
|
toast.success(result.message || "Action completed");
|
|
} else {
|
|
toast.error(result.message || "Action failed");
|
|
}
|
|
} catch (error) {
|
|
console.error("[Chat] Failed to execute action:", error);
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : "Action failed";
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
},
|
|
[conversation, canSign, isSending, adapter, pubkey, signer],
|
|
);
|
|
|
|
// Handle reply button click
|
|
const handleReply = useCallback((messageId: string) => {
|
|
setReplyTo(messageId);
|
|
// Focus the editor so user can start typing immediately
|
|
editorRef.current?.focus();
|
|
}, []);
|
|
|
|
// Handle scroll to message (when clicking on reply preview)
|
|
// Must search in messagesWithMarkers since that's what Virtuoso renders
|
|
const handleScrollToMessage = useCallback(
|
|
(messageId: string) => {
|
|
if (!messagesWithMarkers) return;
|
|
// Find index in the rendered array (which includes day markers)
|
|
const index = messagesWithMarkers.findIndex(
|
|
(item) => item.type === "message" && item.data.id === messageId,
|
|
);
|
|
if (index !== -1 && virtuosoRef.current) {
|
|
virtuosoRef.current.scrollToIndex({
|
|
index,
|
|
align: "center",
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
},
|
|
[messagesWithMarkers],
|
|
);
|
|
|
|
// Handle loading older messages
|
|
const handleLoadOlder = useCallback(async () => {
|
|
if (!conversation || !messages || messages.length === 0 || isLoadingOlder) {
|
|
return;
|
|
}
|
|
|
|
setIsLoadingOlder(true);
|
|
try {
|
|
// Get the timestamp of the oldest message
|
|
const oldestMessage = messages[0];
|
|
const olderMessages = await adapter.loadMoreMessages(
|
|
conversation,
|
|
oldestMessage.timestamp,
|
|
);
|
|
|
|
// If we got fewer messages than expected, there might be no more
|
|
if (olderMessages.length < 50) {
|
|
setHasMore(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load older messages:", error);
|
|
} finally {
|
|
setIsLoadingOlder(false);
|
|
}
|
|
}, [conversation, messages, adapter, isLoadingOlder]);
|
|
|
|
// Handle NIP badge click
|
|
const handleNipClick = useCallback(() => {
|
|
if (conversation?.protocol === "nip-29") {
|
|
addWindow("nip", { number: 29 });
|
|
} else if (conversation?.protocol === "nip-53") {
|
|
addWindow("nip", { number: 53 });
|
|
}
|
|
}, [conversation?.protocol, addWindow]);
|
|
|
|
// Get live activity metadata if this is a NIP-53 chat (with type guard)
|
|
const liveActivity = isLiveActivityMetadata(
|
|
conversation?.metadata?.liveActivity,
|
|
)
|
|
? conversation?.metadata?.liveActivity
|
|
: undefined;
|
|
|
|
// Derive participants from messages for live activities (unique pubkeys who have chatted)
|
|
const derivedParticipants = useMemo(() => {
|
|
if (conversation?.type !== "live-chat" || !messages) {
|
|
return conversation?.participants || [];
|
|
}
|
|
|
|
const hostPubkey = liveActivity?.hostPubkey;
|
|
const participants: { pubkey: string; role: "host" | "member" }[] = [];
|
|
|
|
// Host always first
|
|
if (hostPubkey) {
|
|
participants.push({ pubkey: hostPubkey, role: "host" });
|
|
}
|
|
|
|
// Add other participants from messages (excluding host)
|
|
const seen = new Set(hostPubkey ? [hostPubkey] : []);
|
|
for (const msg of messages) {
|
|
if (msg.type !== "system" && !seen.has(msg.author)) {
|
|
seen.add(msg.author);
|
|
participants.push({ pubkey: msg.author, role: "member" });
|
|
}
|
|
}
|
|
|
|
return participants;
|
|
}, [
|
|
conversation?.type,
|
|
conversation?.participants,
|
|
messages,
|
|
liveActivity?.hostPubkey,
|
|
]);
|
|
|
|
// Handle loading state
|
|
if (!conversationResult || conversationResult.status === "loading") {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
<Loader2 className="size-6 animate-spin" />
|
|
<span className="text-xs">Loading conversation...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle error state with retry option
|
|
if (conversationResult.status === "error") {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
|
|
<AlertTriangle className="size-8 text-destructive" />
|
|
<span className="text-center text-sm">{conversationResult.error}</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRetryCount((c) => c + 1)}
|
|
className="gap-2"
|
|
>
|
|
<RefreshCw className="size-3" />
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// At this point conversation is guaranteed to exist
|
|
if (!conversation) {
|
|
return null; // Should never happen, but satisfies TypeScript
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* Header with conversation info and controls */}
|
|
<div className="pl-2 pr-0 border-b w-full py-0.5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex flex-1 min-w-0 items-center gap-2">
|
|
{headerPrefix}
|
|
<TooltipProvider>
|
|
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="text-sm font-semibold truncate cursor-help text-left"
|
|
onClick={() => setTooltipOpen(!tooltipOpen)}
|
|
>
|
|
{customTitle || conversation.title}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
align="start"
|
|
className="max-w-md p-3"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Icon + Name */}
|
|
<div className="flex items-center gap-2">
|
|
{conversation.metadata?.icon && (
|
|
<img
|
|
src={conversation.metadata.icon}
|
|
alt=""
|
|
className="size-6 rounded object-cover flex-shrink-0"
|
|
onError={(e) => {
|
|
// Hide image if it fails to load
|
|
e.currentTarget.style.display = "none";
|
|
}}
|
|
/>
|
|
)}
|
|
<span className="font-semibold">
|
|
{conversation.title}
|
|
</span>
|
|
</div>
|
|
{/* Description */}
|
|
{conversation.metadata?.description && (
|
|
<p className="text-xs text-primary-foreground/90">
|
|
{conversation.metadata.description}
|
|
</p>
|
|
)}
|
|
{/* Protocol Type - Clickable */}
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
{(conversation.type === "group" ||
|
|
conversation.type === "live-chat") && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleNipClick();
|
|
}}
|
|
className="rounded bg-primary-foreground/20 px-1.5 py-0.5 font-mono hover:bg-primary-foreground/30 transition-colors cursor-pointer text-primary-foreground"
|
|
>
|
|
{conversation.protocol.toUpperCase()}
|
|
</button>
|
|
)}
|
|
{(conversation.type === "group" ||
|
|
conversation.type === "live-chat") && (
|
|
<span className="text-primary-foreground/60">•</span>
|
|
)}
|
|
<span className="capitalize text-primary-foreground/80">
|
|
{conversation.type}
|
|
</span>
|
|
</div>
|
|
{/* Live Activity Status */}
|
|
{liveActivity?.status && (
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-primary-foreground/80">
|
|
Status:
|
|
</span>
|
|
<StatusBadge status={liveActivity.status} size="xs" />
|
|
</div>
|
|
)}
|
|
{/* Host Info */}
|
|
{liveActivity?.hostPubkey && (
|
|
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
|
<span>Host:</span>
|
|
<UserName
|
|
pubkey={liveActivity.hostPubkey}
|
|
className="text-xs text-primary-foreground"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{/* Copy Chat ID button */}
|
|
{getChatIdentifier(conversation) && (
|
|
<button
|
|
onClick={() => {
|
|
const chatId = getChatIdentifier(conversation);
|
|
if (chatId) copyChatId(chatId);
|
|
}}
|
|
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
|
aria-label="Copy chat ID"
|
|
>
|
|
{chatIdCopied ? (
|
|
<CopyCheck className="size-3.5" />
|
|
) : (
|
|
<Copy className="size-3.5" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
|
<MembersDropdown participants={derivedParticipants} />
|
|
<RelaysDropdown conversation={conversation} />
|
|
{(conversation.type === "group" ||
|
|
conversation.type === "live-chat") && (
|
|
<button
|
|
onClick={handleNipClick}
|
|
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
|
|
>
|
|
{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"
|
|
alignToBottom
|
|
components={{
|
|
Header: () =>
|
|
hasMore && conversationResult.status === "success" ? (
|
|
<div className="flex justify-center py-2">
|
|
<Button
|
|
onClick={handleLoadOlder}
|
|
disabled={isLoadingOlder}
|
|
variant="ghost"
|
|
size="sm"
|
|
>
|
|
{isLoadingOlder ? (
|
|
<>
|
|
<Loader2 className="size-3 animate-spin" />
|
|
<span className="text-xs">Loading...</span>
|
|
</>
|
|
) : (
|
|
"Load older messages"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : null,
|
|
Footer: () => <div className="h-1" />,
|
|
}}
|
|
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={canSign}
|
|
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 can sign */}
|
|
{canSign ? (
|
|
<div className="border-t px-2 py-1 pb-0">
|
|
{replyTo && (
|
|
<ComposerReplyPreview
|
|
replyToId={replyTo}
|
|
onClear={() => setReplyTo(undefined)}
|
|
/>
|
|
)}
|
|
<div className="flex gap-1.5 items-center">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground"
|
|
onClick={openUpload}
|
|
disabled={isSending}
|
|
>
|
|
<Paperclip className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<p>Attach media</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<MentionEditor
|
|
ref={editorRef}
|
|
placeholder="Type a message..."
|
|
searchProfiles={searchProfiles}
|
|
searchEmojis={searchEmojis}
|
|
searchCommands={searchCommands}
|
|
onCommandExecute={handleCommandExecute}
|
|
onSubmit={(content, emojiTags, blobAttachments) => {
|
|
if (content.trim()) {
|
|
handleSend(content, replyTo, emojiTags, blobAttachments);
|
|
}
|
|
}}
|
|
className="flex-1 min-w-0"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
className="flex-shrink-0 h-7 px-2 text-xs"
|
|
disabled={isSending}
|
|
onClick={() => {
|
|
editorRef.current?.submit();
|
|
}}
|
|
>
|
|
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Send"}
|
|
</Button>
|
|
</div>
|
|
{uploadDialog}
|
|
</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 NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
|
|
* Other protocols will be enabled in future phases
|
|
*/
|
|
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
|
|
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":
|
|
return new Nip53Adapter();
|
|
default:
|
|
throw new Error(`Unsupported protocol: ${protocol}`);
|
|
}
|
|
}
|