diff --git a/src/apps/images/index.tsx b/src/apps/images/index.tsx new file mode 100644 index 0000000..6d82e14 --- /dev/null +++ b/src/apps/images/index.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { ImagePlus, Loader2, Search, X } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; +import { AuthorLine } from '@/components/nostr/AuthorLine'; +import { Composer } from '@/apps/feed/Composer'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { absoluteTime } from '@/lib/nostrUtils'; +import { isPicturePost, pictureTags, pictureUrl } from '@/lib/picturePosts'; +import { cn } from '@/lib/utils'; +import type { AppProps } from '@/os/types'; + +const PAGE_SIZE = 80; + +function usePictures(tag: string) { + const { nostr } = useNostr(); + return useQuery({ + queryKey: ['nostr', 'images', tag], + queryFn: async ({ signal }) => { + const events = await nostr.query([{ + kinds: [20], + ...(tag ? { '#t': [tag] } : {}), + limit: PAGE_SIZE, + }], { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }); + return events.filter(isPicturePost).sort((a, b) => b.created_at - a.created_at); + }, + staleTime: 30_000, + }); +} + +function usePicture(id: string | undefined) { + const { nostr } = useNostr(); + return useQuery({ + queryKey: ['nostr', 'image', id ?? ''], + enabled: Boolean(id), + queryFn: async ({ signal }) => { + const events = await nostr.query([{ ids: [id!], kinds: [20] }], { + signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), + }); + return events.find(isPicturePost) ?? null; + }, + staleTime: 5 * 60_000, + }); +} + +function useReplies(id: string | undefined) { + const { nostr } = useNostr(); + return useQuery({ + queryKey: ['nostr', 'image-replies', id ?? ''], + enabled: Boolean(id), + queryFn: async ({ signal }) => { + const events = await nostr.query([{ kinds: [1], '#e': [id!], limit: 100 }], { + signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), + }); + return events.filter((event) => event.content.trim()).sort((a, b) => a.created_at - b.created_at); + }, + staleTime: 30_000, + }); +} + +export default function ImagesApp({ params, setParams, setTitle }: AppProps) { + const [search, setSearch] = useState(''); + const [tag, setTag] = useState(''); + const selected = usePicture(params.id); + const replies = useReplies(params.id); + + useEffect(() => { setTitle(params.id ? 'Image' : 'Images'); }, [params.id, setTitle]); + + if (params.id) { + return setParams({})} onRefresh={() => replies.refetch()} />; + } + + return setParams({ id })} + />; +} + +function PictureFeed({ tag, search, onTagChange, onSearchChange, onOpen }: { + tag: string; search: string; onTagChange: (tag: string) => void; onSearchChange: (value: string) => void; onOpen: (id: string) => void; +}) { + const { user } = useCurrentUser(); + const query = usePictures(tag); + const normalizedSearch = search.trim().toLocaleLowerCase(); + const pictures = useMemo(() => (query.data ?? []).filter((event) => + !normalizedSearch || `${event.content} ${pictureTags(event).join(' ')}`.toLocaleLowerCase().includes(normalizedSearch), + ), [query.data, normalizedSearch]); + + return ( + + +
+ + onSearchChange(event.target.value)} placeholder="Search pictures" className="h-8 pl-8 text-sm" /> +
+ +
+ + {user && query.refetch()} />} +
+ onTagChange(event.target.value.replace(/^#/, '').trim())} placeholder="Filter #tag" className="h-7 max-w-44 text-xs" /> + {tag && } + {query.isFetching && } +
+ {query.isLoading ? : query.isError ? ( + query.refetch()}>Try again} /> + ) : pictures.length ? ( +
+ {pictures.map((event) => )} +
+ ) : } +
+
+ ); +} + +function PictureTile({ event, onOpen }: { event: NostrEvent; onOpen: (id: string) => void }) { + const url = pictureUrl(event)!; + return ; +} + +function PictureDetail({ event, loading, replies, onBack, onRefresh }: { event: NostrEvent | null | undefined; loading: boolean; replies: NostrEvent[]; onBack: () => void; onRefresh: () => void }) { + const { user } = useCurrentUser(); + if (loading) return ; + if (!event) return ; + const url = pictureUrl(event)!; + const replyTags = [['e', event.id, '', 'root'], ['p', event.pubkey]]; + return +
+ {event.content +

{event.content}

{absoluteTime(event.created_at)}

+ {pictureTags(event).length > 0 &&
{pictureTags(event).map((tag) => #{tag})}
} +
+ {user && } + {replies.length ?
{replies.map((reply) =>

{reply.content}

)}
: } +
+
; +} + +function PictureComposer({ onPublished }: { onPublished: () => void }) { + const upload = useUploadFile(); + const publish = useNostrPublish(); + const { toast } = useToast(); + const [file, setFile] = useState(null); + const [description, setDescription] = useState(''); + const publishPicture = async () => { + if (!file) return; + try { + const uploadTags = await upload.mutateAsync(file); + const url = uploadTags.find(([name, value]) => name === 'url' && value)?.[1]; + if (!url) throw new Error('Upload did not return a URL.'); + const imeta = ['imeta', `url ${url}`, ...uploadTags + .filter(([name, value]) => name !== 'url' && value) + .map(([name, value]) => `${name} ${value}`)]; + await publish.mutateAsync({ kind: 20, content: description.trim(), tags: [imeta] }); + setFile(null); setDescription(''); toast({ title: 'Picture published' }); onPublished(); + } catch (error) { toast({ title: 'Could not publish picture', description: error instanceof Error ? error.message : 'Upload failed.', variant: 'destructive' }); } + }; + return
+ setFile(event.target.files?.[0] ?? null)} /> +
{file?.name ?? 'Choose an image to share'}
+ {file && <>