From 54c4d7e47cc810ed3c1800f62de20ce63a70162a Mon Sep 17 00:00:00 2001 From: Lemon Date: Fri, 6 Mar 2026 13:00:33 -0800 Subject: [PATCH 1/3] Fix GIF picker scroll inside dialogs by portaling into modal container Radix Dialog's RemoveScroll blocks wheel events on elements outside the Dialog's DOM tree. The GIF picker Popover was portaling to document.body, placing it outside the RemoveScroll boundary. Trackpad scrolling was blocked while scrollbar dragging still worked. Added PortalContainerContext so PopoverContent can portal into the modal element instead of document.body when rendered inside a dialog/modal. --- src/components/CommentsSheet.tsx | 71 +++++++------ src/components/ReplyComposeModal.tsx | 131 +++++++++++++----------- src/components/ui/popover.tsx | 33 +++--- src/contexts/PortalContainerContext.tsx | 19 ++++ 4 files changed, 148 insertions(+), 106 deletions(-) create mode 100644 src/contexts/PortalContainerContext.tsx diff --git a/src/components/CommentsSheet.tsx b/src/components/CommentsSheet.tsx index 5a1f5cf8..bf1b666b 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,6 +132,10 @@ 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 ( @@ -143,43 +149,46 @@ export function CommentsSheet({ event, open, onClose }: CommentsModalProps) { {/* 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/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/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); +} From f0e04e2569e26906623daedd0eadee173aeb0ddf Mon Sep 17 00:00:00 2001 From: Lemon Date: Fri, 6 Mar 2026 13:07:25 -0800 Subject: [PATCH 2/3] Convert emoji/GIF/stickers picker from popover to centered dialog The picker was positioned relative to the trigger button via a Popover, which felt awkward especially on smaller screens or inside other modals. Now renders as a centered Dialog with a backdrop for a more traditional modal experience. --- src/components/ComposeBox.tsx | 240 +++++++++++++++++----------------- 1 file changed, 119 insertions(+), 121 deletions(-) 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) */} From 5a0bc346b7faa36c376103001688f4e9b2b5f1ab Mon Sep 17 00:00:00 2001 From: Lemon Date: Fri, 6 Mar 2026 22:58:09 -0800 Subject: [PATCH 3/3] fix: make emoji/GIF picker dialog dismiss on iOS tap-outside - Raised dialog z-index from z-[150] to z-[250] so the overlay sits above CommentsSheet's backdrop (z-[200]) and receives touch events - Removed stopPropagation from DialogOverlay/DialogContent onClick handlers which was preventing Radix DismissableLayer's deferred click listener from reaching document on iOS Safari On iOS touch, Radix registers a one-time click listener on document after pointerdown. The stopPropagation was intercepting that click before it could reach document, so the dismiss callback never fired. Z-index stacking now handles isolation between the dialog and the CommentsSheet backdrop. --- src/components/CommentsSheet.tsx | 2 +- src/components/ui/alert-dialog.tsx | 4 ++-- src/components/ui/dialog.tsx | 16 ++++------------ 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/components/CommentsSheet.tsx b/src/components/CommentsSheet.tsx index bf1b666b..d5d43798 100644 --- a/src/components/CommentsSheet.tsx +++ b/src/components/CommentsSheet.tsx @@ -142,7 +142,7 @@ export function CommentsSheet({ event, open, onClose }: CommentsModalProps) { <> {/* Backdrop */}
{ e.stopPropagation(); onClose(); }} /> 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}