mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user