diff --git a/src/components/ThreadComposer.tsx b/src/components/ThreadComposer.tsx index d91639a..114122c 100644 --- a/src/components/ThreadComposer.tsx +++ b/src/components/ThreadComposer.tsx @@ -1,10 +1,11 @@ import { useState, useRef, useMemo } from "react"; import { use$ } from "applesauce-react/hooks"; import { Button } from "./ui/button"; -import { Loader2, X } from "lucide-react"; +import { Loader2, X, Paperclip } from "lucide-react"; import { MentionEditor, type MentionEditorHandle, + type BlobAttachment, } from "./editor/MentionEditor"; import type { NostrEvent } from "@/types/nostr"; import { publishEventToRelays } from "@/services/hub"; @@ -16,6 +17,8 @@ import type { ProfileSearchResult } from "@/services/profile-search"; import { getDisplayName } from "@/lib/nostr-utils"; import { selectRelaysForThreadReply } from "@/services/relay-selection"; import eventStore from "@/services/event-store"; +import { useBlossomUpload } from "@/hooks/useBlossomUpload"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface ThreadComposerProps { rootEvent: NostrEvent; @@ -42,6 +45,24 @@ export function ThreadComposer({ const editorRef = useRef(null); const activeAccount = use$(accountManager.active$); + // 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, + }); + } + }, + }); + // Search profiles for autocomplete (thread participants only) const searchProfiles = useMemo(() => { return async (query: string): Promise => { @@ -66,7 +87,11 @@ export function ThreadComposer({ }; }, [participants]); - const handleSend = async (content: string) => { + const handleSend = async ( + content: string, + emojiTags: import("./editor/MentionEditor").EmojiTag[] = [], + blobAttachments: BlobAttachment[] = [], + ) => { if (!activeAccount || isSending || !content.trim()) return; setIsSending(true); @@ -92,6 +117,23 @@ export function ThreadComposer({ const allMentionedPubkeys = [rootEvent.pubkey, replyToEvent.pubkey]; const uniquePubkeys = Array.from(new Set(allMentionedPubkeys)); + // Add NIP-92 imeta tags for blob attachments + const imetaTags: string[][] = []; + for (const blob of blobAttachments) { + const imetaParts = [`url ${blob.url}`]; + if (blob.sha256) imetaParts.push(`x ${blob.sha256}`); + if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`); + if (blob.size) imetaParts.push(`size ${blob.size}`); + imetaTags.push(["imeta", ...imetaParts]); + } + + // Add emoji tags for custom emoji autocomplete + const customEmojiTags: string[][] = emojiTags.map((emoji) => [ + "emoji", + emoji.shortcode, + emoji.url, + ]); + // Create event factory and sign event const factory = new EventFactory(); factory.setSigner(activeAccount.signer); @@ -103,6 +145,8 @@ export function ThreadComposer({ ...rootTags, ...parentTags, ...uniquePubkeys.map((pk) => ["p", pk]), + ...imetaTags, + ...customEmojiTags, ], }); @@ -158,13 +202,30 @@ export function ThreadComposer({ ref={editorRef} placeholder="Write a reply..." searchProfiles={searchProfiles} - onSubmit={(content: string) => { + onSubmit={(content: string, emojiTags, blobAttachments) => { if (content.trim()) { - handleSend(content); + handleSend(content, emojiTags, blobAttachments); } }} className="flex-1 min-w-0" /> + + + + + +

Attach media

+
+
+ {uploadDialog} ); } diff --git a/src/components/ThreadConversation.tsx b/src/components/ThreadConversation.tsx index 726a700..1c151db 100644 --- a/src/components/ThreadConversation.tsx +++ b/src/components/ThreadConversation.tsx @@ -13,6 +13,8 @@ export interface ThreadConversationProps { replies: NostrEvent[]; participants: string[]; focusedEventId?: string; // Event to highlight and scroll to (if not root) + replyToId?: string; // ID of event being replied to (managed by parent) + setReplyToId: (id: string | undefined) => void; // Callback to set reply state } interface ThreadNode { @@ -126,6 +128,8 @@ export function ThreadConversation({ replies, participants, focusedEventId, + replyToId, + setReplyToId, }: ThreadConversationProps) { // Build tree structure const initialTree = useMemo( @@ -136,13 +140,11 @@ export function ThreadConversation({ // Track collapse state per event ID const [collapsedIds, setCollapsedIds] = useState>(new Set()); - // Track reply state - const [replyToId, setReplyToId] = useState(); - // Find the event being replied to const replyToEvent = useMemo(() => { if (!replyToId) return undefined; - return replies.find((r) => r.id === replyToId) || rootEvent; + if (replyToId === rootEvent.id) return rootEvent; + return replies.find((r) => r.id === replyToId); }, [replyToId, replies, rootEvent]); // Ref for the focused event element @@ -179,58 +181,67 @@ export function ThreadConversation({ } return ( - <> -
- {initialTree.map((node) => { - const isCollapsed = collapsedIds.has(node.event.id); - const hasChildren = node.children.length > 0; +
+ {initialTree.map((node) => { + const isCollapsed = collapsedIds.has(node.event.id); + const hasChildren = node.children.length > 0; - const isFocused = focusedEventId === node.event.id; + const isFocused = focusedEventId === node.event.id; + const isReplyingToThis = replyToId === node.event.id; - return ( -
- {/* First-level reply */} -
- {/* Collapse toggle button (only if has children) */} - {hasChildren && ( - - )} - -
+ {/* First-level reply */} +
+ {/* Collapse toggle button (only if has children) */} + {hasChildren && ( +
-
+ {isCollapsed ? ( + + ) : ( + + )} + + )} - {/* Second-level replies (nested, indented) */} - {hasChildren && !isCollapsed && ( -
- {node.children.map((child) => { - const isChildFocused = focusedEventId === child.id; - return ( +
+ + + +
+
+ + {/* Inline composer for replying to this first-level comment */} + {isReplyingToThis && replyToEvent && ( + setReplyToId(undefined)} + onSuccess={() => setReplyToId(undefined)} + /> + )} + + {/* Second-level replies (nested, indented) */} + {hasChildren && !isCollapsed && ( +
+ {node.children.map((child) => { + const isChildFocused = focusedEventId === child.id; + const isReplyingToChild = replyToId === child.id; + return ( +
- ); - })} -
- )} -
- ); - })} -
- {/* Reply Composer */} - {replyToId && replyToEvent && ( - setReplyToId(undefined)} - onSuccess={() => setReplyToId(undefined)} - /> - )} - + {/* Inline composer for replying to this second-level comment */} + {isReplyingToChild && replyToEvent && ( + setReplyToId(undefined)} + onSuccess={() => setReplyToId(undefined)} + /> + )} +
+ ); + })} +
+ )} +
+ ); + })} + ); } diff --git a/src/components/ThreadViewer.tsx b/src/components/ThreadViewer.tsx index 87d28d0..90a9e4b 100644 --- a/src/components/ThreadViewer.tsx +++ b/src/components/ThreadViewer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; import { useNostrEvent } from "@/hooks/useNostrEvent"; @@ -9,7 +9,8 @@ import { getNip10References } from "applesauce-common/helpers/threading"; import { getCommentReplyPointer } from "applesauce-common/helpers/comment"; import { getTagValues } from "@/lib/nostr-utils"; import { UserName } from "./nostr/UserName"; -import { Wifi, MessageSquare } from "lucide-react"; +import { Wifi, MessageSquare, Reply } from "lucide-react"; +import { Button } from "./ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -23,6 +24,9 @@ import { TimelineSkeleton } from "@/components/ui/skeleton"; import eventStore from "@/services/event-store"; import type { NostrEvent } from "@/types/nostr"; import { ThreadConversation } from "./ThreadConversation"; +import { ThreadComposer } from "./ThreadComposer"; +import { selectRelaysForThreadReply } from "@/services/relay-selection"; +import accountManager from "@/services/accounts"; export interface ThreadViewerProps { pointer: EventPointer | AddressPointer; @@ -98,12 +102,16 @@ function getThreadRoot( export function ThreadViewer({ pointer }: ThreadViewerProps) { const event = useNostrEvent(pointer); const { relays: relayStates } = useRelayState(); + const activeAccount = use$(accountManager.active$); + + // Track reply state (managed here and passed down to ThreadConversation) + const [replyToId, setReplyToId] = useState(); // Store the original event ID (the one that was clicked) const originalEventId = useMemo(() => { if (!event) return undefined; return event.id; - }, [event?.id]); + }, [event]); // Get thread root const rootPointer = useMemo(() => { @@ -180,6 +188,29 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { ).length; }, [relayStatesForEvent]); + // Find the event being replied to (root or a reply) + const replyToEvent = useMemo(() => { + if (!replyToId || !rootEvent) return undefined; + if (replyToId === rootEvent.id) return rootEvent; + return replies?.find((r) => r.id === replyToId); + }, [replyToId, rootEvent, replies]); + + // Compute relay selection for thread replies + const [replyRelays, setReplyRelays] = useState([]); + useEffect(() => { + if (!activeAccount || !rootEvent || participants.length === 0) { + setReplyRelays([]); + return; + } + + selectRelaysForThreadReply( + eventStore, + activeAccount.pubkey, + participants, + rootEvent, + ).then(setReplyRelays); + }, [activeAccount, rootEvent, participants]); + // Loading state if (!event || !rootEvent) { return ( @@ -234,10 +265,10 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { align="end" className="w-96 max-h-96 overflow-y-auto" > - {/* Relay List */} + {/* Root Event Relays */}
- Relays ({rootRelays.length}) + Root Event Relays ({rootRelays.length})
@@ -338,6 +369,79 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { ); })()} + + {/* Reply Relay Selection */} + {activeAccount && replyRelays.length > 0 && ( + <> +
+
+ Reply Relay Selection ({replyRelays.length}) +
+
+ Relays where your replies will be published +
+
+
+ {replyRelays.map((url) => { + const globalState = relayStates[url]; + const connIcon = getConnectionIcon(globalState); + const authIcon = getAuthIcon(globalState); + + return ( + + +
+ +
+
{authIcon.icon}
+
{connIcon.icon}
+
+
+
+ +
+
+ {url} +
+
+
+
+ Connection +
+
+ + {connIcon.icon} + + {connIcon.label} +
+
+
+
+ Authentication +
+
+ + {authIcon.icon} + + {authIcon.label} +
+
+
+
+
+
+ ); + })} +
+ + )} @@ -352,6 +456,30 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { + {/* Reply to root button */} +
+ +
+ + {/* Composer for replying to root */} + {replyToId === rootEvent.id && replyToEvent && ( + setReplyToId(undefined)} + onSuccess={() => setReplyToId(undefined)} + /> + )} + {/* Replies Section */}
{replies && replies.length > 0 ? ( @@ -363,6 +491,8 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) { focusedEventId={ originalEventId !== rootEvent.id ? originalEventId : undefined } + replyToId={replyToId} + setReplyToId={setReplyToId} /> ) : (