diff --git a/src/components/CommentsSheet.tsx b/src/components/CommentsSheet.tsx index 5a1f5cf8..d5d43798 100644 --- a/src/components/CommentsSheet.tsx +++ b/src/components/CommentsSheet.tsx @@ -3,11 +3,12 @@ * comments/replies on any Nostr event. */ -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { X } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; +import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; import type { NostrEvent } from '@nostrify/nostrify'; import { useAuthor } from '@/hooks/useAuthor'; import { useProfileUrl } from '@/hooks/useProfileUrl'; @@ -121,6 +122,7 @@ interface CommentsModalProps { export function CommentsSheet({ event, open, onClose }: CommentsModalProps) { const { data: rawComments = [], isLoading, dataUpdatedAt } = useEventComments(event); + const [portalContainer, setPortalContainer] = useState(undefined); // Only show comments once the query has actually fetched for this event. // `dataUpdatedAt === 0` means the query has never resolved — show nothing. @@ -130,56 +132,63 @@ export function CommentsSheet({ event, open, onClose }: CommentsModalProps) { return rawComments.filter((e) => seen.has(e.id) ? false : (seen.add(e.id), true)); }, [rawComments, dataUpdatedAt]); + const modalRef = useCallback((node: HTMLDivElement | null) => { + setPortalContainer(node ?? undefined); + }, []); + if (!open) return null; return ( <> {/* Backdrop */}
{ e.stopPropagation(); onClose(); }} /> {/* Modal — centered, rounded */}
e.stopPropagation()} > - {/* Header */} -
-

{event?.kind === 1 ? 'Replies' : 'Comments'}

- -
- - {/* Compose — top */} - {event && ( -
- + + {/* Header */} +
+

{event?.kind === 1 ? 'Replies' : 'Comments'}

+
- )} - {/* Comment list */} -
- {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => )} + {/* Compose — top */} + {event && ( +
+
- ) : comments.length === 0 ? ( -
-

- {event?.kind === 1 ? 'No replies yet. Be the first!' : 'No comments yet. Be the first!'} -

-
- ) : ( - comments.map((reply) => ) )} -
+ + {/* Comment list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : comments.length === 0 ? ( +
+

+ {event?.kind === 1 ? 'No replies yet. Be the first!' : 'No comments yet. Be the first!'} +

+
+ ) : ( + comments.map((reply) => ) + )} +
+
diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 816a8699..f391a010 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -11,7 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; import { EmojiPicker } from '@/components/EmojiPicker'; import { useCustomEmojis } from '@/hooks/useCustomEmojis'; import { useFeedSettings } from '@/hooks/useFeedSettings'; @@ -1178,132 +1178,130 @@ export function ComposeBox({ )} {/* Emoji / GIF picker */} - - - - + + + + + {!pickerOpen && Emoji / GIF} + + + e.preventDefault()} + > + {/* Tab bar */} +
+ + + {customEmojis.length > 0 && ( - - - {!pickerOpen && Emoji / GIF} - - - {/* Tab bar */} -
- - - {customEmojis.length > 0 && ( - - )} -
- {/* Picker content */} - {pickerTab === 'emoji' ? ( - { - if (selection.type === 'native') { - insertEmoji(selection.emoji); - } else { - insertEmoji(`:${selection.shortcode}:`); - } - }} - /> - ) : pickerTab === 'stickers' ? ( -
- {customEmojis.length === 0 ? ( -
- -

No sticker packs yet

-

Add emoji packs to your profile to use stickers

-
- ) : ( - -
- {customEmojis.map((emoji) => ( - - ))} -
-
- )} -
- ) : ( - { - setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url)); - setPickerOpen(false); - expand(); - }} /> - )} -
- + )} +
+ {/* Picker content */} + {pickerTab === 'emoji' ? ( + { + if (selection.type === 'native') { + insertEmoji(selection.emoji); + } else { + insertEmoji(`:${selection.shortcode}:`); + } + }} + /> + ) : pickerTab === 'stickers' ? ( +
+ {customEmojis.length === 0 ? ( +
+ +

No sticker packs yet

+

Add emoji packs to your profile to use stickers

+
+ ) : ( + +
+ {customEmojis.map((emoji) => ( + + ))} +
+
+ )} +
+ ) : ( + { + setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url)); + setPickerOpen(false); + expand(); + }} /> + )} +
+
{/* Content warning (NIP-36) */} diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 7d83418d..0de84458 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { X } from 'lucide-react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; @@ -9,6 +9,7 @@ import { DialogContent, DialogTitle, } from '@/components/ui/dialog'; +import { PortalContainerProvider } from '@/contexts/PortalContainerContext'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { NoteContent } from '@/components/NoteContent'; import { ComposeBox } from '@/components/ComposeBox'; @@ -48,14 +49,20 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu const isQuote = !!quotedEvent; const [previewMode, setPreviewMode] = useState(false); const [hasPreviewableContent, setHasPreviewableContent] = useState(false); + const [portalContainer, setPortalContainer] = useState(undefined); const isProfileRoot = !isUrl && event instanceof Object && 'kind' in event && event.kind === 0; const title = titleOverride ?? (isUrl ? 'New comment' : isProfileRoot ? 'Comment on profile' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post'); const placeholder = placeholderOverride ?? (isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?"); + const dialogContentRef = useCallback((node: HTMLElement | null) => { + setPortalContainer(node ?? undefined); + }, []); + return ( { e.preventDefault(); @@ -64,71 +71,73 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu textarea?.focus(); }} > - {/* Header */} -
- - {title} - + + {/* Header */} +
+ + {title} + -
- {/* Preview toggle */} - {hasPreviewableContent && ( -
- - -
- )} +
+ {/* Preview toggle */} + {hasPreviewableContent && ( +
+ + +
+ )} - + +
-
- {/* Embedded original post (reply only, not for URL roots or quotes) */} - {event && !isUrl && !isQuote && ( -
- + {/* Embedded original post (reply only, not for URL roots or quotes) */} + {event && !isUrl && !isQuote && ( +
+ +
+ )} + + {/* Compose area */} +
+ { onOpenChange(false); onSuccess?.(); }} + placeholder={placeholder} + forceExpanded + hideAvatar + previewMode={previewMode} + onHasPreviewableContentChange={setHasPreviewableContent} + initialContent={initialContent} + />
- )} - - {/* Compose area */} -
- { onOpenChange(false); onSuccess?.(); }} - placeholder={placeholder} - forceExpanded - hideAvatar - previewMode={previewMode} - onHasPreviewableContentChange={setHasPreviewableContent} - initialContent={initialContent} - /> -
+
); diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index bfad13fe..e6a42f35 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( , React.ComponentPropsWithoutRef ->(({ className, onClick, ...props }, ref) => ( +>(({ className, ...props }, ref) => ( { - e.stopPropagation(); - onClick?.(e); - }} {...props} /> )) @@ -34,19 +30,15 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, onClick, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => ( { - e.stopPropagation(); - onClick?.(e); - }} {...props} > {children} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 1421f66c..9e18868d 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -2,6 +2,7 @@ import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" import { cn } from "@/lib/utils" +import { usePortalContainer } from "@/contexts/PortalContainerContext" const Popover = PopoverPrimitive.Root @@ -10,20 +11,24 @@ const PopoverTrigger = PopoverPrimitive.Trigger const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - -)) +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => { + const portalContainer = usePortalContainer(); + + return ( + + + + ); +}) PopoverContent.displayName = PopoverPrimitive.Content.displayName export { Popover, PopoverTrigger, PopoverContent } diff --git a/src/contexts/PortalContainerContext.tsx b/src/contexts/PortalContainerContext.tsx new file mode 100644 index 00000000..a3a0c87e --- /dev/null +++ b/src/contexts/PortalContainerContext.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +/** + * Provides a DOM element for Radix portals (Popover, Tooltip, DropdownMenu, etc.) + * to render into. When set, portaled content renders inside the container element + * instead of document.body. + * + * This is necessary when a Popover is opened inside a Radix Dialog, because the + * Dialog's RemoveScroll blocks wheel/touch scroll events on elements outside the + * Dialog's DOM tree. By portaling into the Dialog's content element, the Popover + * stays within the RemoveScroll boundary and scrolling works correctly. + */ +const PortalContainerContext = createContext(undefined); + +export const PortalContainerProvider = PortalContainerContext.Provider; + +export function usePortalContainer(): HTMLElement | undefined { + return useContext(PortalContainerContext); +}