import { Debouncer } from "@tanstack/pacer"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger, } from "@methanium/ui"; import { useStorage } from "@tensamin/storage/context"; import { Loader2, Search } from "lucide-react"; import MediaSaveButton from "./mediaSaveButton"; import React, { useEffect, useLayoutEffect, useRef, useState, startTransition, } from "react"; const klipyApiKey = "KzyghnvdlaHjJuDycVx6rLIEu5eaacPyFERieoI7L8UWLt2rnJloL45UL5MVc9IG"; const klipyBaseUrl = "https://api.klipy.com/api/v1"; const pageSize = 24; const minColumnWidth = 200; function getColumnCount(width: number, itemCount: number) { return Math.max( 1, Math.min(itemCount || 1, Math.floor(width / minColumnWidth)), ); } type KlipyKind = "gif" | "meme"; type KlipyItem = { id: number | string; title?: string; file?: Record< string, | Record< string, | { url?: string; width?: number; height?: number; } | undefined > | undefined >; blur_preview?: string; }; type PickerMedia = { key: React.Key; url: string; alt: string; width?: number; height?: number; }; function distributeByHeight( items: T[], columnCount: number, getAspectRatio: (item: T) => number, ) { const columns = Array.from({ length: columnCount }, () => [] as T[]); const columnHeights = Array.from({ length: columnCount }, () => 0); for (const item of items) { const shortestColumnIndex = columnHeights.indexOf( Math.min(...columnHeights), ); columns[shortestColumnIndex].push(item); columnHeights[shortestColumnIndex] += getAspectRatio(item); } return columns; } function getKlipyPath(kind: KlipyKind) { const resource = kind === "gif" ? "gifs" : "static-memes"; return `${resource}/search`; } async function fetchKlipyPage({ kind, page, search, }: { kind: KlipyKind; page: number; search: string; }): Promise<{ items: KlipyItem[]; currentPage: number; hasNext: boolean; }> { const params = new URLSearchParams({ page: String(page), per_page: String(pageSize), content_filter: "medium", }); if (search !== "") { params.set("q", search); } if (kind === "gif") { params.set("format_filter", "gif,webp"); } const response = await fetch( `${klipyBaseUrl}/${klipyApiKey}/${getKlipyPath(kind)}?${params}`, ); if (!response.ok) { throw new Error(`Klipy request failed with status ${response.status}`); } const body = (await response.json()) as { data?: { data?: KlipyItem[]; current_page?: number; has_next?: boolean; }; }; const data = body.data; return { items: data?.data ?? [], currentPage: data?.current_page ?? page, hasNext: data?.has_next ?? false, }; } function getFormat(item: KlipyItem, size: string, format: string) { return item.file?.[size]?.[format]; } function pickDisplayMedia(kind: KlipyKind, item: KlipyItem) { if (kind === "gif") { return ( getFormat(item, "sm", "webp") ?? getFormat(item, "md", "webp") ?? getFormat(item, "sm", "gif") ?? getFormat(item, "md", "gif") ?? getFormat(item, "hd", "webp") ?? getFormat(item, "hd", "gif") ); } return ( getFormat(item, "sm", "webp") ?? getFormat(item, "md", "webp") ?? getFormat(item, "sm", "png") ?? getFormat(item, "md", "png") ?? getFormat(item, "hd", "webp") ?? getFormat(item, "hd", "png") ); } function pickSelectionUrl(kind: KlipyKind, item: KlipyItem) { if (kind === "gif") { return ( getFormat(item, "md", "gif")?.url ?? getFormat(item, "sm", "gif")?.url ?? getFormat(item, "hd", "gif")?.url ); } return ( getFormat(item, "md", "png")?.url ?? getFormat(item, "md", "webp")?.url ?? getFormat(item, "sm", "png")?.url ?? getFormat(item, "sm", "webp")?.url ?? getFormat(item, "hd", "png")?.url ?? getFormat(item, "hd", "webp")?.url ); } function useMeasuredWidth() { const scrollRef = useRef(null); const [scrollWidth, setScrollWidth] = useState(0); useLayoutEffect(() => { const element = scrollRef.current; if (!element || typeof ResizeObserver === "undefined") return; const updateWidth = () => setScrollWidth(element.clientWidth); const observer = new ResizeObserver(updateWidth); updateWidth(); observer.observe(element); return () => observer.disconnect(); }, []); return [scrollRef, scrollWidth] as const; } function useImageSizes(urls: string[]) { const [sizes, setSizes] = useState< Record >({}); useEffect(() => { let active = true; for (const url of urls) { if (sizes[url]) continue; const image = new Image(); image.onload = () => { if (!active) return; setSizes((current) => ({ ...current, [url]: { width: image.naturalWidth, height: image.naturalHeight, }, })); }; image.src = url; } return () => { active = false; }; }, [sizes, urls]); return sizes; } function MediaGrid({ hasNextPage = false, isFetchingNextPage = false, items, onLoadMore, onSelect, resizeWidth, }: { hasNextPage?: boolean; isFetchingNextPage?: boolean; items: PickerMedia[]; onLoadMore?: () => void; onSelect: (url: string) => void; resizeWidth?: number; }) { const [scrollRef, measuredWidth] = useMeasuredWidth(); const columnWidth = resizeWidth ?? measuredWidth; const columnCount = getColumnCount(columnWidth, items.length); const columns = distributeByHeight(items, columnCount, (item) => { if (!item.width || !item.height) return 1; return item.height / item.width; }); function handleScroll(event: React.UIEvent) { const element = event.currentTarget; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; if ( distanceFromBottom > 240 || !hasNextPage || isFetchingNextPage || !onLoadMore ) { return; } onLoadMore(); } useEffect(() => { const element = scrollRef.current; if (!element || !hasNextPage || isFetchingNextPage || !onLoadMore) return; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; if (distanceFromBottom <= 240) { onLoadMore(); } }, [ columnCount, hasNextPage, isFetchingNextPage, items.length, onLoadMore, scrollRef, ]); return (
{columns.map((column, columnIndex) => (
{column.map((item) => ( ))}
))}
{hasNextPage ? (
Loading more...
) : null}
); } function KlipyPanel({ kind, onSelect, resizeWidth, searchString, }: { kind: KlipyKind; onSelect: (url: string) => void; resizeWidth?: number; searchString: string; }) { const query = useInfiniteQuery({ queryKey: ["klipy", kind, searchString], initialPageParam: 1, enabled: searchString !== "", queryFn: ({ pageParam }) => fetchKlipyPage({ kind, page: Number(pageParam), search: searchString, }), getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.currentPage + 1 : undefined, }); const items: PickerMedia[] = query.data?.pages.flatMap((page) => page.items.flatMap((item) => { const displayMedia = pickDisplayMedia(kind, item); const selectionUrl = pickSelectionUrl(kind, item); if (!displayMedia?.url || !selectionUrl) return []; return [ { key: item.id, url: selectionUrl, alt: item.title ?? "Klipy result", width: displayMedia.width, height: displayMedia.height, }, ]; }), ) ?? []; if (searchString === "") { return (

{kind === "gif" && 'Try searching for "Funny cat"'} {kind === "meme" && 'Try searching for "Spiderman pointing at Spiderman"'}

); } if (query.isLoading) { return (
Loading...
); } if (query.isError) { return (

Failed to load {kind === "gif" ? "GIFs" : "memes"}.

); } if (items.length === 0) { return (
No {kind === "gif" ? "GIFs" : "memes"} found.
); } return ( void query.fetchNextPage()} onSelect={onSelect} resizeWidth={resizeWidth} /> ); } function SavedPanel({ onSelect, onSavedMediaChange, resizeWidth, urls, }: { onSelect: (url: string) => void; onSavedMediaChange: (savedMedia: string[]) => void; resizeWidth?: number; urls: string[]; }) { const [scrollRef, measuredWidth] = useMeasuredWidth(); const imageSizes = useImageSizes(urls); const columnWidth = resizeWidth ?? measuredWidth; const columnCount = getColumnCount(columnWidth, urls.length); const columns = distributeByHeight(urls, columnCount, (url) => { const size = imageSizes[url]; if (!size?.width || !size.height) return 1; return size.height / size.width; }); if (urls.length === 0) { return (
Saved media will appear here.
); } return (
{columns.map((column, columnIndex) => (
{column.map((url) => { const size = imageSizes[url]; return (
); })}
))}
); } export default function GifPicker({ onSelect, resizeHeight, resizeWidth, }: { onSelect: (url: string) => void; resizeHeight?: number; resizeWidth?: number; }) { const { load, save } = useStorage(); const [tab, setTab] = useState("gif"); const [searchString, setSearchString] = useState(""); const [debouncedSearchString, setDebouncedSearchString] = useState(""); const [savedMedia, setSavedMedia] = useState([]); const didLoadLastTabRef = useRef(false); const searchDebouncerRef = useRef void> | null>( null, ); if (searchDebouncerRef.current === null) { searchDebouncerRef.current = new Debouncer( (value) => { startTransition(() => { setDebouncedSearchString(value.trim()); }); }, { wait: 300 }, ); } useEffect(() => { return () => { searchDebouncerRef.current?.cancel(); }; }, []); useEffect(() => { if (tab !== "saved") return; void load("chat_picker_saved_media").then(setSavedMedia); }, [load, tab]); useEffect(() => { void load("chat_picker_last_tab").then((savedTab) => { setTab(savedTab); didLoadLastTabRef.current = true; }); }, [load]); useEffect(() => { if (!didLoadLastTabRef.current) return; void save("chat_picker_last_tab", tab); }, [save, tab]); function handleTabChange(nextTab: string) { if (nextTab !== "gif" && nextTab !== "meme" && nextTab !== "saved") return; setTab(nextTab); } function handleSearchChange(event: React.ChangeEvent) { const nextValue = event.target.value; setSearchString(nextValue); searchDebouncerRef.current?.maybeExecute(nextValue); } return ( GIFs Memes Saved ); }