Browse Source

fix gif picker modal

imwald
Silberengel 3 days ago
parent
commit
aa38e7c600
  1. 9
      src/components/EmojiPickerDialog/index.tsx
  2. 23
      src/components/GifPicker/index.tsx
  3. 4
      src/components/ui/dialog.tsx
  4. 13
      src/components/ui/drawer.tsx
  5. 9
      src/components/ui/dropdown-menu.tsx
  6. 10
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  7. 32
      src/services/gif.service.ts

9
src/components/EmojiPickerDialog/index.tsx

@ -11,10 +11,13 @@ import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({ export default function EmojiPickerDialog({
children, children,
onEmojiClick onEmojiClick,
portalContainer
}: { }: {
children: React.ReactNode children: React.ReactNode
onEmojiClick?: (emoji: string | TEmoji | undefined) => void onEmojiClick?: (emoji: string | TEmoji | undefined) => void
/** When set (e.g. inside a modal), picker content portals here so it stays on top of the modal */
portalContainer?: HTMLElement | null
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -23,7 +26,7 @@ export default function EmojiPickerDialog({
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent> <DrawerContent portalContainer={portalContainer}>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle> <DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader> </DrawerHeader>
@ -42,7 +45,7 @@ export default function EmojiPickerDialog({
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit"> <DropdownMenuContent side="top" className="p-0 w-fit" portalContainer={portalContainer}>
<EmojiPicker <EmojiPicker
onEmojiClick={(emoji, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()

23
src/components/GifPicker/index.tsx

@ -12,7 +12,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service' import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { Loader2 } from 'lucide-react' import { Loader2, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -20,10 +20,13 @@ const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
export default function GifPicker({ export default function GifPicker({
children, children,
onSelect onSelect,
portalContainer
}: { }: {
children: React.ReactNode children: React.ReactNode
onSelect?: (gifUrl: string) => void onSelect?: (gifUrl: string) => void
/** When set (e.g. inside a modal), picker content portals here so it stays on top of the modal */
portalContainer?: HTMLElement | null
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -125,13 +128,23 @@ export default function GifPicker({
const content = ( const content = (
<div className="flex flex-col gap-2 p-2 min-w-[280px] max-w-[360px]"> <div className="flex flex-col gap-2 p-2 min-w-[280px] max-w-[360px]">
<div className="flex gap-1"> <div className="flex items-center gap-1">
<Input <Input
placeholder={t('Search GIFs')} placeholder={t('Search GIFs')}
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
className="flex-1" className="flex-1"
/> />
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div> </div>
{error && ( {error && (
<p className="text-sm text-muted-foreground px-1">{error}</p> <p className="text-sm text-muted-foreground px-1">{error}</p>
@ -207,7 +220,7 @@ export default function GifPicker({
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent> <DrawerContent portalContainer={portalContainer}>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle> <DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
@ -220,7 +233,7 @@ export default function GifPicker({
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0"> <DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content} {content}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

4
src/components/ui/dialog.tsx

@ -58,7 +58,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-40 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
@ -77,7 +77,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 sm:border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'fixed left-[50%] top-[50%] z-40 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 sm:border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className className
)} )}
{...props} {...props}

13
src/components/ui/drawer.tsx

@ -61,7 +61,7 @@ const DrawerOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay <DrawerPrimitive.Overlay
ref={ref} ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)} className={cn('fixed inset-0 z-[100] bg-black/80', className)}
{...props} {...props}
/> />
)) ))
@ -69,14 +69,17 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef< const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>, React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & { hideOverlay?: boolean } React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
>(({ className, children, hideOverlay = false, ...props }, ref) => ( hideOverlay?: boolean
<DrawerPortal> portalContainer?: HTMLElement | null
}
>(({ className, children, hideOverlay = false, portalContainer, ...props }, ref) => (
<DrawerPortal container={portalContainer}>
{!hideOverlay && <DrawerOverlay />} {!hideOverlay && <DrawerOverlay />}
<DrawerPrimitive.Content <DrawerPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] sm:border bg-background', 'fixed inset-x-0 bottom-0 z-[100] mt-24 flex h-auto flex-col rounded-t-[10px] sm:border bg-background',
className className
)} )}
style={{ style={{

9
src/components/ui/dropdown-menu.tsx

@ -85,7 +85,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={contentRef} ref={contentRef}
className={cn( className={cn(
'relative z-50 min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2' 'relative z-[100] min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
)} )}
onAnimationEnd={() => { onAnimationEnd={() => {
if (showScrollButtons) { if (showScrollButtons) {
@ -138,8 +138,9 @@ const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
showScrollButtons?: boolean showScrollButtons?: boolean
portalContainer?: HTMLElement | null
} }
>(({ className, sideOffset = 4, showScrollButtons = false, ...props }, ref) => { >(({ className, sideOffset = 4, showScrollButtons = false, portalContainer, ...props }, ref) => {
const [canScrollUp, setCanScrollUp] = React.useState(false) const [canScrollUp, setCanScrollUp] = React.useState(false)
const [canScrollDown, setCanScrollDown] = React.useState(false) const [canScrollDown, setCanScrollDown] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null) const contentRef = React.useRef<HTMLDivElement>(null)
@ -178,12 +179,12 @@ const DropdownMenuContent = React.forwardRef<
} }
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal container={portalContainer}>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={contentRef} ref={contentRef}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'relative z-50 min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2' 'relative z-[100] min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
)} )}
onAnimationEnd={() => { onAnimationEnd={() => {
if (showScrollButtons) { if (showScrollButtons) {

10
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -126,6 +126,7 @@ export default function CreateThreadDialog({
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [isLoadingRelays, setIsLoadingRelays] = useState(true) const [isLoadingRelays, setIsLoadingRelays] = useState(true)
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false) const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false)
const [pickerPortalContainer, setPickerPortalContainer] = useState<HTMLElement | null>(null)
// Readings options state // Readings options state
const [isReadingGroup, setIsReadingGroup] = useState(false) const [isReadingGroup, setIsReadingGroup] = useState(false)
@ -541,6 +542,12 @@ export default function CreateThreadDialog({
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
{/* Portal target for GIF/emoji pickers so they render as children of this modal */}
<div
ref={setPickerPortalContainer}
className="absolute inset-0 pointer-events-none"
aria-hidden
/>
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background"> <Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle> <CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle>
@ -710,13 +717,14 @@ export default function CreateThreadDialog({
{t('Upload Image')} {t('Upload Image')}
</Button> </Button>
</Uploader> </Uploader>
<GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)}> <GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)} portalContainer={pickerPortalContainer}>
<Button type="button" variant="outline" size="sm"> <Button type="button" variant="outline" size="sm">
<Film className="h-4 w-4 mr-1" /> <Film className="h-4 w-4 mr-1" />
{t('Insert GIF')} {t('Insert GIF')}
</Button> </Button>
</GifPicker> </GifPicker>
<EmojiPickerDialog <EmojiPickerDialog
portalContainer={pickerPortalContainer}
onEmojiClick={(emoji) => { onEmojiClick={(emoji) => {
if (emoji == null) return if (emoji == null) return
const char = typeof emoji === 'string' ? emoji : (emoji as { native?: string }).native ?? String(emoji) const char = typeof emoji === 'string' ? emoji : (emoji as { native?: string }).native ?? String(emoji)

32
src/services/gif.service.ts

@ -27,21 +27,29 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
let fallbackUrl: string | undefined let fallbackUrl: string | undefined
let sha256: string | undefined let sha256: string | undefined
// imeta tags (NIP-92) // imeta tags (NIP-92): accept url when it contains .gif or when m is image/gif
const imetaTags = event.tags.filter((t) => t[0] === 'imeta') const imetaTags = event.tags.filter((t) => t[0] === 'imeta')
for (const imetaTag of imetaTags) { for (const imetaTag of imetaTags) {
const mimeField = imetaTag.find((f) => f?.startsWith('m '))
const imetaMime = mimeField?.substring(2).trim()
const isGifMime = imetaMime === 'image/gif'
for (let i = 1; i < imetaTag.length; i++) { for (let i = 1; i < imetaTag.length; i++) {
const field = imetaTag[i] const field = imetaTag[i]
if (field?.startsWith('url ')) { if (field?.startsWith('url ')) {
const candidateUrl = field.substring(4).trim() const candidateUrl = field.substring(4).trim()
if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) { if (!candidateUrl) continue
const urlHasGif = candidateUrl.toLowerCase().includes('.gif')
if (urlHasGif || isGifMime) {
url = candidateUrl url = candidateUrl
const mimeField = imetaTag.find((f) => f?.startsWith('m ')) if (mimeField) mimeType = imetaMime
if (mimeField) mimeType = mimeField.substring(2).trim() const dimField = imetaTag.find((f) => f?.startsWith('dim '))
const xField = imetaTag.find((f) => f?.startsWith('x ')) if (dimField) {
const yField = imetaTag.find((f) => f?.startsWith('y ')) const dims = dimField.substring(4).trim().split('x')
if (xField) width = parseInt(xField.substring(2).trim(), 10) if (dims.length >= 2) {
if (yField) height = parseInt(yField.substring(2).trim(), 10) width = parseInt(dims[0], 10)
height = parseInt(dims[1], 10)
}
}
break break
} }
} }
@ -80,11 +88,15 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
} }
} }
// url tag // url tag (accept any URL; isGif check below uses mime from 'm' tag if URL has no .gif)
if (!url) { if (!url) {
const urlTag = event.tags.find((t) => t[0] === 'url' && t[1]) const urlTag = event.tags.find((t) => t[0] === 'url' && t[1])
if (urlTag?.[1] && urlTag[1].toLowerCase().includes('.gif')) { if (urlTag?.[1]) {
url = urlTag[1] url = urlTag[1]
if (!mimeType) {
const mTag = event.tags.find((t) => t[0] === 'm' && t[1])
mimeType = mTag?.[1]
}
} }
} }

Loading…
Cancel
Save