Browse Source

fix GIF picker

imwald
Silberengel 2 weeks ago
parent
commit
fdfd196619
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 100
      src/components/GifPicker/index.tsx
  4. 38
      src/components/Image/index.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.18.0",
"version": "23.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.18.0",
"version": "23.18.1",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "imwald",
"version": "23.18.0",
"version": "23.18.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",

100
src/components/GifPicker/index.tsx

@ -41,6 +41,12 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = [] @@ -41,6 +41,12 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL
/** Lock drawer height at open so mobile keyboard / dvh changes do not resize the sheet. */
function mobileDrawerHeightPx(): number {
const vh = window.visualViewport?.height ?? window.innerHeight
return Math.min(Math.round(vh * 0.88), Math.round(vh - 80))
}
export default function GifPicker({
children,
onSelect,
@ -78,6 +84,10 @@ export default function GifPicker({ @@ -78,6 +84,10 @@ export default function GifPicker({
const [publishDescription, setPublishDescription] = useState('')
const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null)
const pickerRootRef = useRef<HTMLDivElement>(null)
const [mobileDrawerHeight, setMobileDrawerHeight] = useState<number | undefined>()
/** Keep drawer content mounted until Vaul's close animation finishes (avoids empty-sheet flicker). */
const [drawerContentMounted, setDrawerContentMounted] = useState(false)
const userReadRelays = useUserReadInboxUrls()
@ -167,13 +177,46 @@ export default function GifPicker({ @@ -167,13 +177,46 @@ export default function GifPicker({
void loadGifs()
}, [open, loadGifs])
useEffect(() => {
if (!open || !isSmallScreen) return
setMobileDrawerHeight(mobileDrawerHeightPx())
}, [open, isSmallScreen])
useEffect(() => {
if (open) setDrawerContentMounted(true)
}, [open])
const preparePickerClose = useCallback(() => {
loadGenerationRef.current += 1
setLoading(false)
const el = document.activeElement
if (el instanceof HTMLElement && pickerRootRef.current?.contains(el)) {
el.blur()
}
}, [])
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) preparePickerClose()
setOpen(next)
},
[preparePickerClose]
)
const handleDrawerAnimationEnd = useCallback((isOpen: boolean) => {
if (!isOpen) {
setDrawerContentMounted(false)
setMobileDrawerHeight(undefined)
}
}, [])
const handleSelect = useCallback(
(gif: GifMetadata) => {
const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url) return
const desc = publishDescription.trim()
onSelect?.(url)
setOpen(false)
handleOpenChange(false)
if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(buildKind1063GifPublishDraft(url, desc), {
@ -181,7 +224,7 @@ export default function GifPicker({ @@ -181,7 +224,7 @@ export default function GifPicker({
}).catch(() => {})
if (desc) setPublishDescription('')
},
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription, handleOpenChange]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -241,7 +284,7 @@ export default function GifPicker({ @@ -241,7 +284,7 @@ export default function GifPicker({
window.removeEventListener('message', handler)
gifbuddyPopupRef.current = null
onSelect?.(urlToInsert)
setOpen(false)
handleOpenChange(false)
}
}
window.addEventListener('message', handler)
@ -250,7 +293,7 @@ export default function GifPicker({ @@ -250,7 +293,7 @@ export default function GifPicker({
gifbuddyPopupRef.current = null
}, 10 * 60 * 1000)
if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) })
}, [searchInput, onSelect])
}, [searchInput, onSelect, handleOpenChange])
const descriptionForPublish = publishDescription.trim()
@ -260,7 +303,7 @@ export default function GifPicker({ @@ -260,7 +303,7 @@ export default function GifPicker({
if (!url || !/^https?:\/\//i.test(url)) return
onSelect?.(url)
setPasteUrl('')
setOpen(false)
handleOpenChange(false)
if (pubkey) {
setPublishingPaste(true)
try {
@ -274,7 +317,7 @@ export default function GifPicker({ @@ -274,7 +317,7 @@ export default function GifPicker({
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish])
}, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish, handleOpenChange])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback(
@ -287,7 +330,7 @@ export default function GifPicker({ @@ -287,7 +330,7 @@ export default function GifPicker({
const desc = publishDescription.trim()
setArchivingEventId(gif.eventId)
onSelect?.(url)
setOpen(false)
handleOpenChange(false)
void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gif1063PublishRelayUrls
@ -298,7 +341,7 @@ export default function GifPicker({ @@ -298,7 +341,7 @@ export default function GifPicker({
if (desc) setPublishDescription('')
})
},
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription, handleOpenChange]
)
const gifSourceKindTitle = useCallback(
@ -344,11 +387,14 @@ export default function GifPicker({ @@ -344,11 +387,14 @@ export default function GifPicker({
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
<div className="grid grid-cols-2 gap-1 p-2 min-h-[200px] content-start">
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<div
key={gif.eventId}
className="relative aspect-square min-h-0 w-full rounded overflow-hidden [contain:layout]"
>
<button
type="button"
className={cn(
@ -405,6 +451,8 @@ export default function GifPicker({ @@ -405,6 +451,8 @@ export default function GifPicker({
const content = (
<div
ref={pickerRootRef}
data-gif-picker-root
className={cn(
'flex min-w-0 w-full flex-col gap-2 p-2',
isDrawer ? 'min-h-0 flex-1 overflow-hidden' : 'min-w-[280px] max-w-[360px]'
@ -422,7 +470,10 @@ export default function GifPicker({ @@ -422,7 +470,10 @@ export default function GifPicker({
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
onClick={(e) => {
e.stopPropagation()
handleOpenChange(false)
}}
aria-label={t('Close')}
>
<X className="size-4" />
@ -524,18 +575,35 @@ export default function GifPicker({ @@ -524,18 +575,35 @@ export default function GifPicker({
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<Drawer
open={open}
onOpenChange={handleOpenChange}
onAnimationEnd={handleDrawerAnimationEnd}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
dragHandle="vaul"
portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2 pb-2"
className="px-2 pb-2"
style={
mobileDrawerHeight != null
? { height: mobileDrawerHeight, maxHeight: mobileDrawerHeight }
: { maxHeight: 'min(88dvh, calc(100dvh - 5rem))' }
}
onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return
e.preventDefault()
}}
>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader>
<div className="flex min-h-0 w-full min-w-0 max-w-[100vw] flex-1 flex-col overflow-hidden">
{content}
<div className="flex h-full min-h-0 w-full min-w-0 max-w-[100vw] flex-col overflow-hidden">
{drawerContentMounted ? content : null}
</div>
</DrawerContent>
</Drawer>
@ -543,7 +611,7 @@ export default function GifPicker({ @@ -543,7 +611,7 @@ export default function GifPicker({
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content}

38
src/components/Image/index.tsx

@ -130,6 +130,8 @@ export default function Image({ @@ -130,6 +130,8 @@ export default function Image({
const imgRef = useRef<HTMLImageElement | null>(null)
/** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */
const loadSettledRef = useRef(false)
/** When imeta has no `dim`, reserve space from decoded natural size to avoid mobile layout shift. */
const [intrinsicDim, setIntrinsicDim] = useState<{ width: number; height: number } | undefined>()
const finalAlt = imetaAlt || alt
const imgTitle =
@ -151,6 +153,21 @@ export default function Image({ @@ -151,6 +153,21 @@ export default function Image({
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim())
const showErrorState = hasError || badSrc
const effectiveDim =
dim && dim.width > 0 && dim.height > 0 ? dim : intrinsicDim
const captureIntrinsicDim = useCallback(
(el: HTMLImageElement) => {
if (dim && dim.width > 0 && dim.height > 0) return
const w = el.naturalWidth
const h = el.naturalHeight
if (w <= 0 || h <= 0) return
setIntrinsicDim((prev) =>
prev?.width === w && prev?.height === h ? prev : { width: w, height: h }
)
},
[dim]
)
/** NIP-94 blurhash when present; otherwise a stable URL-derived placeholder (many events omit blurhash). */
const effectiveBlurHash = useMemo(() => {
@ -171,6 +188,7 @@ export default function Image({ @@ -171,6 +188,7 @@ export default function Image({
useEffect(() => {
setImageUrl(resolvePrimalBlossomPlayableUrl(url ?? ''))
loadSettledRef.current = false
setIntrinsicDim(undefined)
wasInitiallyHeldRef.current = effectiveHoldUntilClick
const shouldHold = effectiveHoldUntilClick
const sessionRevealed = Boolean(url?.trim() && wasMediaUrlRevealed(url))
@ -195,12 +213,14 @@ export default function Image({ @@ -195,12 +213,14 @@ export default function Image({
if (loadSettledRef.current) return
loadSettledRef.current = true
clearLoadWatch()
const el = imgRef.current
if (el) captureIntrinsicDim(el)
setIsLoading(false)
setHasError(false)
// Unmount blurhash/skeleton immediately — keeping z-10 overlay (even at opacity-0) leaves bg-muted/40
// and canvas layers visible as odd tinted bands until delayed teardown.
setDisplaySkeleton(false)
}, [])
}, [captureIntrinsicDim])
// Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once).
useLayoutEffect(() => {
@ -208,19 +228,23 @@ export default function Image({ @@ -208,19 +228,23 @@ export default function Image({
const el = imgRef.current
if (!el) return
if (el.complete && el.naturalWidth > 0) {
captureIntrinsicDim(el)
notifyLoaded()
return
}
if (typeof el.decode === 'function') {
let cancelled = false
el.decode().then(() => {
if (!cancelled && el.naturalWidth > 0) notifyLoaded()
if (!cancelled && el.naturalWidth > 0) {
captureIntrinsicDim(el)
notifyLoaded()
}
}).catch(() => {})
return () => {
cancelled = true
}
}
}, [revealed, badSrc, imageUrl, notifyLoaded])
}, [revealed, badSrc, imageUrl, notifyLoaded, captureIntrinsicDim])
useEffect(() => {
clearLoadWatch()
@ -285,9 +309,9 @@ export default function Image({ @@ -285,9 +309,9 @@ export default function Image({
}
const reserveStyle = wrapperReserveStyle(
dim,
effectiveDim,
showErrorState,
displaySkeleton && !showErrorState
displaySkeleton && !showErrorState && !effectiveDim
)
const mergedWrapperStyle: CSSProperties | undefined =
reserveStyle || wrapperStyleProp
@ -369,8 +393,8 @@ export default function Image({ @@ -369,8 +393,8 @@ export default function Image({
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
width={dim?.width}
height={dim?.height}
width={effectiveDim?.width ?? dim?.width}
height={effectiveDim?.height ?? dim?.height}
/>
)}
{showErrorState && (

Loading…
Cancel
Save