/** * : an Expo/React Native-compatible emoji picker backed by * an AppView's blue.moji.collection.searchItems query. Debounced * search-as-you-type, renders a grid of matches, calls onPick with the * chosen emoji's { uri, did, name, alt } — host apps decide what to do with * that (typically: insert ":alias:" into a composer's text state). * * Pure React Native primitives only (View/TextInput/FlatList/Image) — no * native modules, so this works in a stock Expo Go app with no config * plugins. Mirrors components/webcomponent/picker.ts's behavior (same * debounce window, same stale-response guard) for the DOM equivalent. */ import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Image, Pressable, StyleSheet, Text, TextInput, View, type StyleProp, type ViewStyle, } from "react-native"; export interface BluemojiPickResult { uri: string; did: string; name: string; alt?: string; } interface SearchItem { uri: string; did: string; name: string; alt?: string; formats?: Record; } export interface BluemojiPickerProps { /** AppView origin to query and load blob images from. Defaults to https://moji.blue. */ apiBase?: string; /** * Optional at-identifier. When set, searches substring-match within that * repo's own collection (suited to "my collection" composer autocomplete). * Omit for network-wide whole-word search (suited to a "discover emoji" * browse surface). */ repo?: string; placeholder?: string; /** Max results per search. Defaults to 24. */ limit?: number; /** Debounce window in ms between keystrokes and firing a search. Defaults to 250. */ debounceMs?: number; /** Number of grid columns. Defaults to 5. */ numColumns?: number; onPick: (item: BluemojiPickResult) => void; /** Override to customise how a (did, cid) pair becomes an image URL. */ imgUrl?: (did: string, cid: string) => string; style?: StyleProp; } function blobRefCid(ref: unknown): string | undefined { if (typeof ref === "string") return ref; return (ref as { ref?: { $link?: string } } | undefined)?.ref?.$link; } export function BluemojiPicker({ apiBase = "https://moji.blue", repo, placeholder = "Search emoji…", limit = 24, debounceMs = 250, numColumns = 5, onPick, imgUrl, style, }: BluemojiPickerProps) { const [query, setQuery] = useState(""); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const debounceTimer = useRef | undefined>(undefined); const requestSeq = useRef(0); const resolveImgUrl = useCallback( (did: string, cid: string) => imgUrl ? imgUrl(did, cid) : `${apiBase}/img/${encodeURIComponent(did)}/${encodeURIComponent(cid)}`, [apiBase, imgUrl], ); const search = useCallback( async (q: string) => { const seq = ++requestSeq.current; setLoading(true); setError(null); const url = new URL("/xrpc/blue.moji.collection.searchItems", apiBase); url.searchParams.set("q", q); url.searchParams.set("limit", String(limit)); if (repo) url.searchParams.set("repo", repo); try { const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { items?: SearchItem[] }; if (seq !== requestSeq.current) return; // superseded by a newer search setItems(body.items ?? []); } catch (err) { if (seq !== requestSeq.current) return; setError(err instanceof Error ? err.message : "Search failed."); setItems([]); } finally { if (seq === requestSeq.current) setLoading(false); } }, [apiBase, limit, repo], ); useEffect(() => { clearTimeout(debounceTimer.current); const q = query.trim(); if (!q) { requestSeq.current++; // invalidate any in-flight search setItems([]); setLoading(false); setError(null); return; } debounceTimer.current = setTimeout(() => search(q), debounceMs); return () => clearTimeout(debounceTimer.current); }, [query, debounceMs, search]); return ( {loading && } {!loading && error && {error}} {!loading && !error && query.trim() && items.length === 0 && ( No matches. )} item.uri} renderItem={({ item }) => { const cid = blobRefCid(item.formats?.png_128) ?? blobRefCid(item.formats?.webp_128) ?? blobRefCid(item.formats?.gif_128); return ( onPick({ uri: item.uri, did: item.did, name: item.name, alt: item.alt }) } > {cid ? ( ) : ( )} {item.name} ); }} /> ); } const styles = StyleSheet.create({ container: { flex: 1, }, input: { borderWidth: 1, borderColor: "#ccc", borderRadius: 4, padding: 8, }, status: { marginTop: 6, }, statusText: { marginTop: 6, fontSize: 13, color: "#666", }, item: { flex: 1, alignItems: "center", gap: 2, padding: 6, minWidth: 0, }, itemImage: { width: 32, height: 32, }, itemLabel: { fontSize: 10, maxWidth: 48, }, });