diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index bb85aaa..9f9d187 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -11,12 +11,14 @@ import { Paperclip, Copy, CopyCheck, + Lock, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { getZapRequest } from "applesauce-common/helpers/zap"; import { toast } from "sonner"; import accountManager from "@/services/accounts"; import eventStore from "@/services/event-store"; +import type { NostrEvent } from "@/types/nostr"; import type { ChatProtocol, ProtocolIdentifier, @@ -35,6 +37,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser"; import { UserName } from "./nostr/UserName"; import { RichText } from "./nostr/RichText"; import Timestamp from "./Timestamp"; +import { Avatar, AvatarImage, AvatarFallback } from "./ui/avatar"; import { ReplyPreview } from "./chat/ReplyPreview"; import { MembersDropdown } from "./chat/MembersDropdown"; import { RelaysDropdown } from "./chat/RelaysDropdown"; @@ -50,7 +53,9 @@ import { } from "./editor/MentionEditor"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { useProfile } from "@/hooks/useProfile"; import { useCopy } from "@/hooks/useCopy"; +import { getDisplayName } from "@/lib/nostr-utils"; import { Label } from "./ui/label"; import { Tooltip, @@ -121,8 +126,59 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean { ); } +/** + * ParticipantAvatar - Renders a single participant avatar + */ +const ParticipantAvatar = memo(function ParticipantAvatar({ + pubkey, + className, +}: { + pubkey: string; + className?: string; +}) { + const profile = useProfile(pubkey); + const displayName = getDisplayName(pubkey, profile); + + return ( + + + + {displayName.slice(0, 2)} + + + ); +}); + +/** + * ParticipantName - Renders a single participant name without accent color + * (for use in DM titles where we want regular text color) + */ +const ParticipantName = memo(function ParticipantName({ + pubkey, +}: { + pubkey: string; +}) { + const { addWindow } = useGrimoire(); + const profile = useProfile(pubkey); + const displayName = getDisplayName(pubkey, profile); + + return ( + { + e.stopPropagation(); + addWindow("profile", { pubkey }); + }} + > + {displayName} + + ); +}); + /** * DmTitle - Renders profile names for NIP-17 DM conversations + * Uses regular text color (not accent) without lock icon (lock shown in header controls) */ const DmTitle = memo(function DmTitle({ participants, @@ -141,15 +197,17 @@ const DmTitle = memo(function DmTitle({ // 1-on-1 or group return ( - + {others.slice(0, 3).map((p, i) => ( - - {i > 0 && , } - + + {i > 0 && } + ))} {others.length > 3 && ( - +{others.length - 3} + +  +{others.length - 3} + )} ); @@ -173,10 +231,50 @@ function isLiveActivityMetadata(value: unknown): value is LiveActivityMetadata { * 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-17 DMs: nprofile1.../npub1... (chat partner's identifier) * For NIP-29 groups: relay'group-id (without wss:// prefix) * For NIP-53 live activities: naddr1... encoding */ -function getChatIdentifier(conversation: Conversation): string | null { +function getChatIdentifier( + conversation: Conversation, + activePubkey?: string, +): string | null { + if (conversation.protocol === "nip-17") { + // For DMs, get the other participant(s) + const others = conversation.participants + .map((p) => p.pubkey) + .filter((p) => p !== activePubkey); + + // Self-chat or no participants + if (others.length === 0) { + // Return own npub + if (activePubkey) { + return nip19.npubEncode(activePubkey); + } + return null; + } + + // 1-on-1 chat: return nprofile with inbox relays if available + if (others.length === 1) { + const pubkey = others[0]; + const participantInboxRelays = + conversation.metadata?.participantInboxRelays; + const relays = participantInboxRelays?.[pubkey]; + + if (relays && relays.length > 0) { + return nip19.nprofileEncode({ + pubkey, + relays: relays.slice(0, 3), // Limit to 3 relays + }); + } else { + return nip19.npubEncode(pubkey); + } + } + + // Group chat: return comma-separated npubs + return others.map((p) => nip19.npubEncode(p)).join(","); + } + if (conversation.protocol === "nip-29") { const groupId = conversation.metadata?.groupId; const relayUrl = conversation.metadata?.relayUrl; @@ -220,11 +318,41 @@ type ConversationResult = const ComposerReplyPreview = memo(function ComposerReplyPreview({ replyToId, onClear, + adapter, + conversation, }: { replyToId: string; onClear: () => void; + adapter: ChatProtocolAdapter; + conversation: Conversation; }) { - const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]); + // State for manually loaded events (NIP-17 synthetic events) + const [manualEvent, setManualEvent] = useState(null); + + // Load the event being replied to (reactive - updates when event arrives) + const storeEvent = use$(() => eventStore.event(replyToId), [replyToId]); + + // Use store event if available, otherwise fall back to manually loaded event + const replyEvent = storeEvent ?? manualEvent; + + // Fetch event from adapter if not in store (for NIP-17 synthetic events) + useEffect(() => { + if (!replyEvent) { + adapter + .loadReplyMessage(conversation, replyToId) + .then((event) => { + if (event) { + setManualEvent(event); + } + }) + .catch((err) => { + console.error( + `[ComposerReplyPreview] Failed to load reply ${replyToId.slice(0, 8)}:`, + err, + ); + }); + } + }, [replyEvent, adapter, conversation, replyToId]); if (!replyEvent) { return ( @@ -735,7 +863,9 @@ export function ChatViewer({ // Handle NIP badge click const handleNipClick = useCallback(() => { - if (conversation?.protocol === "nip-29") { + if (conversation?.protocol === "nip-17") { + addWindow("nip", { number: 17 }); + } else if (conversation?.protocol === "nip-29") { addWindow("nip", { number: 29 }); } else if (conversation?.protocol === "nip-53") { addWindow("nip", { number: 53 }); @@ -846,29 +976,34 @@ export function ChatViewer({ className="max-w-md p-3" >
- {/* Icon + Name */} + {/* Icon/Avatar + Name */}
- {conversation.metadata?.icon && ( - { - // Hide image if it fails to load - e.currentTarget.style.display = "none"; - }} - /> - )} - - {conversation.protocol === "nip-17" ? ( + {/* For NIP-17 DMs, just show title without big avatars */} + {conversation.protocol === "nip-17" ? ( + - ) : ( - conversation.title - )} - + + ) : ( + <> + {conversation.metadata?.icon && ( + { + // Hide image if it fails to load + e.currentTarget.style.display = "none"; + }} + /> + )} + + {conversation.title} + + + )}
{/* Description */} {conversation.metadata?.description && ( @@ -878,26 +1013,78 @@ export function ChatViewer({ )} {/* Protocol Type - Clickable */}
- {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} - {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} + + {conversation.type}
+ {/* Participants (NIP-17) */} + {conversation.protocol === "nip-17" && + (() => { + const others = conversation.participants.filter( + (p) => p.pubkey !== activeAccount?.pubkey, + ); + + // Self-chat + if (others.length === 0) { + return ( +
+ Saved Messages +
+ ); + } + + // 1-on-1 chat + if (others.length === 1) { + return ( +
+ + +
+ ); + } + + // Group chat (2+ others) + return ( +
+
+ Participants: +
+
+ {others.map((p) => ( +
+ + +
+ ))} +
+
+ ); + })()} {/* Live Activity Status */} {liveActivity?.status && (
@@ -922,10 +1109,13 @@ export function ChatViewer({ {/* Copy Chat ID button */} - {getChatIdentifier(conversation) && ( + {getChatIdentifier(conversation, activeAccount?.pubkey) && (
+ {/* Lock icon for encrypted conversations */} + {conversation.protocol === "nip-17" && ( + + )} - {(conversation.type === "group" || - conversation.type === "live-chat") && ( - - )} + {/* NIP badge - clickable for all protocols */} +
@@ -1037,6 +1229,8 @@ export function ChatViewer({ setReplyTo(undefined)} + adapter={adapter} + conversation={conversation} /> )}
diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 4fb550f..6d7e1a6 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -428,22 +428,22 @@ function ConversationRow({ >
-
+
{isSelfConversation ? ( Saved Messages ) : ( <> {otherParticipants.slice(0, 3).map((pubkey, i) => ( - + {i > 0 && ( - , + )} ))} {otherParticipants.length > 3 && ( - - +{otherParticipants.length - 3} + +  +{otherParticipants.length - 3} )} diff --git a/src/lib/chat/adapters/nip-17-adapter.ts b/src/lib/chat/adapters/nip-17-adapter.ts index 41192fc..f91d804 100644 --- a/src/lib/chat/adapters/nip-17-adapter.ts +++ b/src/lib/chat/adapters/nip-17-adapter.ts @@ -58,14 +58,19 @@ async function fetchInboxRelays(pubkey: string): Promise { eventStore.replaceable(DM_RELAY_LIST_KIND, pubkey).pipe( filter((e): e is NostrEvent => e !== undefined), take(1), - timeout(50), + timeout(100), ), ); if (existing) { - return parseRelayTags(existing); + const relays = parseRelayTags(existing); + console.log( + `[NIP-17] Found cached inbox relays for ${pubkey.slice(0, 8)}:`, + relays.length, + ); + return relays; } } catch { - // Not in store + // Not in store, continue to network fetch } // 2. Build relay list to query: participant's outbox + aggregators @@ -74,20 +79,29 @@ async function fetchInboxRelays(pubkey: string): Promise { try { const cached = await relayListCache.get(pubkey); if (cached?.write) { - relaysToQuery.push(...cached.write.slice(0, 2)); + relaysToQuery.push(...cached.write.slice(0, 3)); } } catch { // Cache miss } // Add aggregator relays - relaysToQuery.push(...AGGREGATOR_RELAYS.slice(0, 2)); + relaysToQuery.push(...AGGREGATOR_RELAYS.slice(0, 3)); // Dedupe const uniqueRelays = [...new Set(relaysToQuery)]; - if (uniqueRelays.length === 0) return []; + if (uniqueRelays.length === 0) { + console.warn( + `[NIP-17] No relays to query for inbox relays of ${pubkey.slice(0, 8)}`, + ); + return []; + } - // 3. Fetch from relays + console.log( + `[NIP-17] Fetching inbox relays for ${pubkey.slice(0, 8)} from ${uniqueRelays.length} relays`, + ); + + // 3. Fetch from relays with longer timeout try { const events = await firstValueFrom( pool @@ -98,7 +112,7 @@ async function fetchInboxRelays(pubkey: string): Promise { ) .pipe( toArray(), - timeout(3000), + timeout(5000), // Increased from 3s to 5s catchError(() => of([] as NostrEvent[])), ), ); @@ -107,7 +121,16 @@ async function fetchInboxRelays(pubkey: string): Promise { const latest = events.reduce((a, b) => a.created_at > b.created_at ? a : b, ); - return parseRelayTags(latest); + const relays = parseRelayTags(latest); + console.log( + `[NIP-17] Fetched inbox relays for ${pubkey.slice(0, 8)}:`, + relays.length, + ); + return relays; + } else { + console.log( + `[NIP-17] No inbox relay list found for ${pubkey.slice(0, 8)}`, + ); } } catch (err) { console.warn(