Browse Source

make gif picker easier to use

imwald
Silberengel 1 week ago
parent
commit
ee6ba45e5b
  1. 2
      package.json
  2. 195
      src/components/GifPicker/index.tsx
  3. 7
      src/i18n/locales/en.ts
  4. 1
      src/services/Untitled

2
package.json

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

195
src/components/GifPicker/index.tsx

@ -8,6 +8,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls' import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
@ -41,10 +42,12 @@ const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
const GIFBUDDY_SEARCH_URL = (q: string) => const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL 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. */ type GifPickerTab = 'find' | 'import'
/** Shorter sheet on mobile — tall drawers fight the post composer and keyboard. */
function mobileDrawerHeightPx(): number { function mobileDrawerHeightPx(): number {
const vh = window.visualViewport?.height ?? window.innerHeight const vh = window.visualViewport?.height ?? window.innerHeight
return Math.min(Math.round(vh * 0.88), Math.round(vh - 80)) return Math.min(Math.round(vh * 0.6), Math.round(vh - 120))
} }
export default function GifPicker({ export default function GifPicker({
@ -86,6 +89,7 @@ export default function GifPicker({
const gifbuddyPopupRef = useRef<Window | null>(null) const gifbuddyPopupRef = useRef<Window | null>(null)
const pickerRootRef = useRef<HTMLDivElement>(null) const pickerRootRef = useRef<HTMLDivElement>(null)
const [mobileDrawerHeight, setMobileDrawerHeight] = useState<number | undefined>() const [mobileDrawerHeight, setMobileDrawerHeight] = useState<number | undefined>()
const [activeTab, setActiveTab] = useState<GifPickerTab>('find')
/** Keep drawer content mounted until Vaul's close animation finishes (avoids empty-sheet flicker). */ /** Keep drawer content mounted until Vaul's close animation finishes (avoids empty-sheet flicker). */
const [drawerContentMounted, setDrawerContentMounted] = useState(false) const [drawerContentMounted, setDrawerContentMounted] = useState(false)
@ -186,6 +190,13 @@ export default function GifPicker({
if (open) setDrawerContentMounted(true) if (open) setDrawerContentMounted(true)
}, [open]) }, [open])
useEffect(() => {
if (!open) return
setActiveTab('find')
setSearchInput('')
applyLocalFilter('')
}, [open, applyLocalFilter])
const preparePickerClose = useCallback(() => { const preparePickerClose = useCallback(() => {
loadGenerationRef.current += 1 loadGenerationRef.current += 1
setLoading(false) setLoading(false)
@ -270,7 +281,7 @@ export default function GifPicker({
/** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */ /** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */
const openGifBuddySearch = useCallback(() => { const openGifBuddySearch = useCallback(() => {
const url = GIFBUDDY_SEARCH_URL(searchInput) const url = GIFBUDDY_SEARCH_URL(pasteUrl || searchInput)
const w = window.open(url, '_blank', 'noopener,noreferrer') const w = window.open(url, '_blank', 'noopener,noreferrer')
gifbuddyPopupRef.current = w ?? null gifbuddyPopupRef.current = w ?? null
const handler = (event: MessageEvent) => { const handler = (event: MessageEvent) => {
@ -293,7 +304,7 @@ export default function GifPicker({
gifbuddyPopupRef.current = null gifbuddyPopupRef.current = null
}, 10 * 60 * 1000) }, 10 * 60 * 1000)
if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) }) if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) })
}, [searchInput, onSelect, handleOpenChange]) }, [pasteUrl, searchInput, onSelect, handleOpenChange])
const descriptionForPublish = publishDescription.trim() const descriptionForPublish = publishDescription.trim()
@ -375,21 +386,22 @@ export default function GifPicker({
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen const isDrawer = isSmallScreen
const gifGrid = loading ? ( const renderGifGrid = (items: GifMetadata[], showArchiveActions: boolean) =>
loading ? (
<div <div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]" className="grid grid-cols-2 gap-1 p-2 min-h-[120px]"
role="status" role="status"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
> >
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" /> <Skeleton key={i} className="aspect-square w-full rounded" />
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-1 p-2 min-h-[200px] content-start"> <div className="grid grid-cols-2 gap-1 p-2 min-h-[120px] content-start">
{gifs.map((gif) => { {items.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn const showArchive = showArchiveActions && gifShouldOfferNip94Archive(gif) && isLoggedIn
return ( return (
<div <div
key={gif.eventId} key={gif.eventId}
@ -398,7 +410,7 @@ export default function GifPicker({
<button <button
type="button" type="button"
className={cn( className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 'absolute inset-0 z-0 touch-manipulation rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-80'
)} )}
onClick={() => handleSelect(gif)} onClick={() => handleSelect(gif)}
> >
@ -407,6 +419,7 @@ export default function GifPicker({
alt="" alt=""
className="w-full h-full object-cover pointer-events-none" className="w-full h-full object-cover pointer-events-none"
loading="lazy" loading="lazy"
decoding="async"
onError={(e) => { onError={(e) => {
const el = e.target as HTMLImageElement const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim() const fallback = gif.fallbackUrl?.trim()
@ -430,7 +443,7 @@ export default function GifPicker({
type="button" type="button"
variant="secondary" variant="secondary"
size="icon" size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md" className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md touch-manipulation"
disabled={archivingEventId === gif.eventId} disabled={archivingEventId === gif.eventId}
title={t( title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post' 'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
@ -449,70 +462,38 @@ export default function GifPicker({
</div> </div>
) )
const content = ( const scrollableGifGrid = (items: GifMetadata[], showArchiveActions: boolean) =>
<div isDrawer ? (
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]'
)}
>
<div className="flex items-center gap-1 shrink-0">
<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={(e) => {
e.stopPropagation()
handleOpenChange(false)
}}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
{error && (
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<div <div
className={cn(isDrawer && 'flex min-h-0 flex-1 flex-col')} className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border"
{...(isDrawer && { 'data-vaul-no-drag': true })} data-vaul-no-drag
> >
{isDrawer ? ( {renderGifGrid(items, showArchiveActions)}
<div className="page-scroll-y min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain touch-pan-y rounded-md border">
{gifGrid}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[520px] w-full rounded-md border">{gifGrid}</ScrollArea> <ScrollArea className="h-[520px] w-full rounded-md border">
)} {renderGifGrid(items, showArchiveActions)}
</div> </ScrollArea>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0"> )
<div className="flex flex-col gap-1.5">
const importPanel = (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">{t('Paste a GIF URL, upload your own file, or search GifBuddy.')}</p>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" className="w-full touch-manipulation"
onClick={openGifBuddySearch} onClick={openGifBuddySearch}
> >
<ExternalLink className="size-3.5 mr-1.5" /> <ExternalLink className="size-3.5 mr-1.5" />
{t('Search on GifBuddy')} {t('Search on GifBuddy')}
</Button> </Button>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.')} {t('Opens GifBuddy in a new tab. Copy a GIF URL there, then paste it below.')}
</p> </p>
<div className="grid gap-1"> <div className="grid gap-1">
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">{t('Paste URL of a GIF')}</Label>
{t('Paste URL of a GIF')}
</Label>
<div className="flex gap-1"> <div className="flex gap-1">
<Input <Input
placeholder="https://..." placeholder="https://..."
@ -523,6 +504,7 @@ export default function GifPicker({
<Button <Button
type="button" type="button"
size="sm" size="sm"
className="shrink-0 touch-manipulation"
disabled={!pasteUrl.trim() || publishingPaste} disabled={!pasteUrl.trim() || publishingPaste}
onClick={handlePasteUrlInsert} onClick={handlePasteUrlInsert}
title={t('Insert URL into your post and publish to Nostr GIF library (NIP-94).')} title={t('Insert URL into your post and publish to Nostr GIF library (NIP-94).')}
@ -531,7 +513,6 @@ export default function GifPicker({
</Button> </Button>
</div> </div>
</div> </div>
</div>
{isLoggedIn && ( {isLoggedIn && (
<div className="grid gap-1"> <div className="grid gap-1">
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
@ -558,21 +539,103 @@ export default function GifPicker({
type="button" type="button"
variant="secondary" variant="secondary"
size="sm" size="sm"
className="w-full" className="w-full touch-manipulation"
disabled={uploading} disabled={uploading}
onClick={triggerFileUpload} onClick={triggerFileUpload}
> >
{uploading ? t('Uploading...') : t('Add your own GIFs')} {uploading ? t('Uploading...') : t('Add your own GIFs')}
</Button> </Button>
{uploadError && ( {uploadError && <p className="text-xs text-destructive text-center">{uploadError}</p>}
<p className="text-xs text-destructive text-center">{uploadError}</p>
)}
</> </>
)} )}
</div> </div>
)
const findPanel = (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="shrink-0 text-xs text-muted-foreground">
{t('Search your library and tap a GIF to insert.')}
</p>
<Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="shrink-0"
/>
{error && <p className="shrink-0 px-1 text-sm text-muted-foreground">{error}</p>}
{scrollableGifGrid(gifs, !isDrawer)}
</div>
)
const tabbedContent = (
<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]'
)}
>
<div className="flex shrink-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium">{t('Choose a GIF')}</p>
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-8 shrink-0', isDrawer && 'touch-manipulation')}
onClick={(e) => {
e.stopPropagation()
handleOpenChange(false)
}}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as GifPickerTab)}
className={cn('flex flex-col', isDrawer && 'min-h-0 flex-1')}
>
<TabsList className="grid h-auto w-full shrink-0 grid-cols-2 gap-0.5 p-1">
<TabsTrigger
value="find"
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')}
>
{t('Find GIF')}
</TabsTrigger>
<TabsTrigger
value="import"
className={cn('px-1.5 py-1.5 text-xs', isDrawer && 'touch-manipulation')}
>
{t('Import GIF')}
</TabsTrigger>
</TabsList>
<TabsContent
value="find"
className={cn(
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isDrawer ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'
)}
>
{findPanel}
</TabsContent>
<TabsContent
value="import"
className={cn(
'mt-2 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isDrawer ? 'min-h-0 flex-1 overflow-y-auto overscroll-y-contain' : ''
)}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
{importPanel}
</TabsContent>
</Tabs>
</div> </div>
) )
const content = tabbedContent
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Drawer <Drawer
@ -591,7 +654,7 @@ export default function GifPicker({
style={ style={
mobileDrawerHeight != null mobileDrawerHeight != null
? { height: mobileDrawerHeight, maxHeight: mobileDrawerHeight } ? { height: mobileDrawerHeight, maxHeight: mobileDrawerHeight }
: { maxHeight: 'min(88dvh, calc(100dvh - 5rem))' } : { maxHeight: 'min(60dvh, calc(100dvh - 8rem))' }
} }
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null

7
src/i18n/locales/en.ts

@ -518,6 +518,13 @@ export default {
'Search GIFs': 'Search GIFs', 'Search GIFs': 'Search GIFs',
'Search memes': 'Search memes', 'Search memes': 'Search memes',
'Choose a GIF': 'Choose a GIF', 'Choose a GIF': 'Choose a GIF',
'Find GIF': 'Find GIF',
'Import GIF': 'Import GIF',
'Search your library and tap a GIF to insert.': 'Search your library and tap a GIF to insert.',
'Paste a GIF URL, upload your own file, or search GifBuddy.':
'Paste a GIF URL, upload your own file, or search GifBuddy.',
'Opens GifBuddy in a new tab. Copy a GIF URL there, then paste it below.':
'Opens GifBuddy in a new tab. Copy a GIF URL there, then paste it below.',
'Choose a meme': 'Choose a meme', 'Choose a meme': 'Choose a meme',
'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs', 'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs',
'Add your own GIFs': 'Add your own GIFs', 'Add your own GIFs': 'Add your own GIFs',

1
src/services/Untitled

@ -0,0 +1 @@
I
Loading…
Cancel
Save