feat: Add media uploads and inline reply positioning to threads

Enhance thread composer with full media support and improved UX:
- Add Blossom upload button with NIP-92 imeta tag support
- Add emoji autocomplete support with emoji tags
- Position composer inline directly below replied-to comment
- Add "Reply to post" button for replying to root event
- Display relay selection info in thread heading dropdown
- Lift reply state to ThreadViewer for sharing between components

All replies now support images, videos, audio, and custom emoji
with intelligent relay targeting shown to the user.
This commit is contained in:
Claude
2026-01-17 20:40:40 +00:00
parent 60bf3ead82
commit c94eb3c80c
3 changed files with 282 additions and 79 deletions

View File

@@ -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<MentionEditorHandle>(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<ProfileSearchResult[]> => {
@@ -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"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="flex-shrink-0 h-7 w-7 p-0"
onClick={openUpload}
disabled={isSending}
>
<Paperclip className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Attach media</p>
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="secondary"
@@ -178,6 +239,7 @@ export function ThreadComposer({
{isSending ? <Loader2 className="size-3 animate-spin" /> : "Reply"}
</Button>
</div>
{uploadDialog}
</div>
);
}

View File

@@ -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<Set<string>>(new Set());
// Track reply state
const [replyToId, setReplyToId] = useState<string | undefined>();
// 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 (
<>
<div className="space-y-0">
{initialTree.map((node) => {
const isCollapsed = collapsedIds.has(node.event.id);
const hasChildren = node.children.length > 0;
<div className="space-y-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 (
<div key={node.event.id}>
{/* First-level reply */}
<div
ref={isFocused ? focusedRef : undefined}
className="relative"
>
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
onClick={() => toggleCollapse(node.event.id)}
className="absolute -left-6 top-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
isCollapsed ? "Expand replies" : "Collapse replies"
}
>
{isCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
<div
className={isFocused ? "ring-2 ring-primary/50 rounded" : ""}
return (
<div key={node.event.id}>
{/* First-level reply */}
<div ref={isFocused ? focusedRef : undefined} className="relative">
{/* Collapse toggle button (only if has children) */}
{hasChildren && (
<button
onClick={() => toggleCollapse(node.event.id)}
className="absolute -left-6 top-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
isCollapsed ? "Expand replies" : "Collapse replies"
}
>
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer
event={node.event}
onReply={setReplyToId}
/>
</EventErrorBoundary>
</div>
</div>
{isCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</button>
)}
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-6 mt-1 space-y-0 border-l-2 border-border pl-3">
{node.children.map((child) => {
const isChildFocused = focusedEventId === child.id;
return (
<div
className={isFocused ? "ring-2 ring-primary/50 rounded" : ""}
>
<EventErrorBoundary event={node.event}>
<ThreadCommentRenderer
event={node.event}
onReply={setReplyToId}
/>
</EventErrorBoundary>
</div>
</div>
{/* Inline composer for replying to this first-level comment */}
{isReplyingToThis && replyToEvent && (
<ThreadComposer
rootEvent={rootEvent}
replyToEvent={replyToEvent}
participants={participants}
onCancel={() => setReplyToId(undefined)}
onSuccess={() => setReplyToId(undefined)}
/>
)}
{/* Second-level replies (nested, indented) */}
{hasChildren && !isCollapsed && (
<div className="ml-6 mt-1 space-y-0 border-l-2 border-border pl-3">
{node.children.map((child) => {
const isChildFocused = focusedEventId === child.id;
const isReplyingToChild = replyToId === child.id;
return (
<div key={child.id}>
<div
key={child.id}
ref={isChildFocused ? focusedRef : undefined}
className={
isChildFocused ? "ring-2 ring-primary/50 rounded" : ""
@@ -243,25 +254,25 @@ export function ThreadConversation({
/>
</EventErrorBoundary>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Reply Composer */}
{replyToId && replyToEvent && (
<ThreadComposer
rootEvent={rootEvent}
replyToEvent={replyToEvent}
participants={participants}
onCancel={() => setReplyToId(undefined)}
onSuccess={() => setReplyToId(undefined)}
/>
)}
</>
{/* Inline composer for replying to this second-level comment */}
{isReplyingToChild && replyToEvent && (
<ThreadComposer
rootEvent={rootEvent}
replyToEvent={replyToEvent}
participants={participants}
onCancel={() => setReplyToId(undefined)}
onSuccess={() => setReplyToId(undefined)}
/>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -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<string | undefined>();
// 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<string[]>([]);
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 */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
Relays ({rootRelays.length})
Root Event Relays ({rootRelays.length})
</div>
</div>
@@ -338,6 +369,79 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) {
</>
);
})()}
{/* Reply Relay Selection */}
{activeAccount && replyRelays.length > 0 && (
<>
<div className="px-3 py-2 border-t-2 border-border">
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
Reply Relay Selection ({replyRelays.length})
</div>
<div className="text-[10px] text-muted-foreground mt-1">
Relays where your replies will be published
</div>
</div>
<div className="py-2">
{replyRelays.map((url) => {
const globalState = relayStates[url];
const connIcon = getConnectionIcon(globalState);
const authIcon = getAuthIcon(globalState);
return (
<Tooltip key={url}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-xs py-1 px-3 hover:bg-accent/5 cursor-default">
<RelayLink
url={url}
showInboxOutbox={false}
className="flex-1 min-w-0 truncate font-mono text-foreground/80"
/>
<div className="flex items-center gap-1.5 flex-shrink-0">
<div>{authIcon.icon}</div>
<div>{connIcon.icon}</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="left"
className="max-w-xs bg-popover text-popover-foreground border border-border shadow-md"
>
<div className="space-y-2 text-xs p-1">
<div className="font-mono font-bold border-b border-border pb-2 break-all text-primary">
{url}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Connection
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">
{connIcon.icon}
</span>
<span>{connIcon.label}</span>
</div>
</div>
<div className="space-y-0.5">
<div className="text-[10px] uppercase font-bold text-muted-foreground tracking-tight">
Authentication
</div>
<div className="flex items-center gap-1.5 font-medium">
<span className="shrink-0">
{authIcon.icon}
</span>
<span>{authIcon.label}</span>
</div>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -352,6 +456,30 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) {
</EventErrorBoundary>
</div>
{/* Reply to root button */}
<div className="px-3 py-2 border-b border-border/50">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setReplyToId(rootEvent.id)}
>
<Reply className="size-3" />
Reply to post
</Button>
</div>
{/* Composer for replying to root */}
{replyToId === rootEvent.id && replyToEvent && (
<ThreadComposer
rootEvent={rootEvent}
replyToEvent={replyToEvent}
participants={participants}
onCancel={() => setReplyToId(undefined)}
onSuccess={() => setReplyToId(undefined)}
/>
)}
{/* Replies Section */}
<div className="px-3 py-2">
{replies && replies.length > 0 ? (
@@ -363,6 +491,8 @@ export function ThreadViewer({ pointer }: ThreadViewerProps) {
focusedEventId={
originalEventId !== rootEvent.id ? originalEventId : undefined
}
replyToId={replyToId}
setReplyToId={setReplyToId}
/>
) : (
<div className="text-sm text-muted-foreground italic p-2">