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' @@ -11,10 +11,13 @@ import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({
children,
onEmojiClick
onEmojiClick,
portalContainer
}: {
children: React.ReactNode
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 [open, setOpen] = useState(false)
@ -23,7 +26,7 @@ export default function EmojiPickerDialog({ @@ -23,7 +26,7 @@ export default function EmojiPickerDialog({
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerContent portalContainer={portalContainer}>
<DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader>
@ -42,7 +45,7 @@ export default function EmojiPickerDialog({ @@ -42,7 +45,7 @@ export default function EmojiPickerDialog({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit">
<DropdownMenuContent side="top" className="p-0 w-fit" portalContainer={portalContainer}>
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()

23
src/components/GifPicker/index.tsx

@ -12,7 +12,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -12,7 +12,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.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 { useTranslation } from 'react-i18next'
@ -20,10 +20,13 @@ const GIFBUDDY_URL = 'https://www.gifbuddy.lol/' @@ -20,10 +20,13 @@ const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
export default function GifPicker({
children,
onSelect
onSelect,
portalContainer
}: {
children: React.ReactNode
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 { isSmallScreen } = useScreenSize()
@ -125,13 +128,23 @@ export default function GifPicker({ @@ -125,13 +128,23 @@ export default function GifPicker({
const content = (
<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
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
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>
{error && (
<p className="text-sm text-muted-foreground px-1">{error}</p>
@ -207,7 +220,7 @@ export default function GifPicker({ @@ -207,7 +220,7 @@ export default function GifPicker({
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerContent portalContainer={portalContainer}>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader>
@ -220,7 +233,7 @@ export default function GifPicker({ @@ -220,7 +233,7 @@ export default function GifPicker({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0">
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content}
</DropdownMenuContent>
</DropdownMenu>

4
src/components/ui/dialog.tsx

@ -58,7 +58,7 @@ const DialogOverlay = React.forwardRef< @@ -58,7 +58,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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
)}
{...props}
@ -77,7 +77,7 @@ const DialogContent = React.forwardRef< @@ -77,7 +77,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}

13
src/components/ui/drawer.tsx

@ -61,7 +61,7 @@ const DrawerOverlay = React.forwardRef< @@ -61,7 +61,7 @@ const DrawerOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
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}
/>
))
@ -69,14 +69,17 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName @@ -69,14 +69,17 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & { hideOverlay?: boolean }
>(({ className, children, hideOverlay = false, ...props }, ref) => (
<DrawerPortal>
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
hideOverlay?: boolean
portalContainer?: HTMLElement | null
}
>(({ className, children, hideOverlay = false, portalContainer, ...props }, ref) => (
<DrawerPortal container={portalContainer}>
{!hideOverlay && <DrawerOverlay />}
<DrawerPrimitive.Content
ref={ref}
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
)}
style={{

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

@ -85,7 +85,7 @@ const DropdownMenuSubContent = React.forwardRef< @@ -85,7 +85,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={contentRef}
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={() => {
if (showScrollButtons) {
@ -138,8 +138,9 @@ const DropdownMenuContent = React.forwardRef< @@ -138,8 +138,9 @@ const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
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 [canScrollDown, setCanScrollDown] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null)
@ -178,12 +179,12 @@ const DropdownMenuContent = React.forwardRef< @@ -178,12 +179,12 @@ const DropdownMenuContent = React.forwardRef<
}
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Portal container={portalContainer}>
<DropdownMenuPrimitive.Content
ref={contentRef}
sideOffset={sideOffset}
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={() => {
if (showScrollButtons) {

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

@ -126,6 +126,7 @@ export default function CreateThreadDialog({ @@ -126,6 +126,7 @@ export default function CreateThreadDialog({
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [isLoadingRelays, setIsLoadingRelays] = useState(true)
const [isTopicSelectorOpen, setIsTopicSelectorOpen] = useState(false)
const [pickerPortalContainer, setPickerPortalContainer] = useState<HTMLElement | null>(null)
// Readings options state
const [isReadingGroup, setIsReadingGroup] = useState(false)
@ -541,6 +542,12 @@ export default function CreateThreadDialog({ @@ -541,6 +542,12 @@ export default function CreateThreadDialog({
return (
<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">
<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>
@ -710,13 +717,14 @@ export default function CreateThreadDialog({ @@ -710,13 +717,14 @@ export default function CreateThreadDialog({
{t('Upload Image')}
</Button>
</Uploader>
<GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)}>
<GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)} portalContainer={pickerPortalContainer}>
<Button type="button" variant="outline" size="sm">
<Film className="h-4 w-4 mr-1" />
{t('Insert GIF')}
</Button>
</GifPicker>
<EmojiPickerDialog
portalContainer={pickerPortalContainer}
onEmojiClick={(emoji) => {
if (emoji == null) return
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 { @@ -27,21 +27,29 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
let fallbackUrl: 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')
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++) {
const field = imetaTag[i]
if (field?.startsWith('url ')) {
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
const mimeField = imetaTag.find((f) => f?.startsWith('m '))
if (mimeField) mimeType = mimeField.substring(2).trim()
const xField = imetaTag.find((f) => f?.startsWith('x '))
const yField = imetaTag.find((f) => f?.startsWith('y '))
if (xField) width = parseInt(xField.substring(2).trim(), 10)
if (yField) height = parseInt(yField.substring(2).trim(), 10)
if (mimeField) mimeType = imetaMime
const dimField = imetaTag.find((f) => f?.startsWith('dim '))
if (dimField) {
const dims = dimField.substring(4).trim().split('x')
if (dims.length >= 2) {
width = parseInt(dims[0], 10)
height = parseInt(dims[1], 10)
}
}
break
}
}
@ -80,11 +88,15 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { @@ -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) {
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]
if (!mimeType) {
const mTag = event.tags.find((t) => t[0] === 'm' && t[1])
mimeType = mTag?.[1]
}
}
}

Loading…
Cancel
Save