feat(web): add Tenor-backed GIF search in composer
All checks were successful
CI / test (push) Successful in 22s

This commit is contained in:
2026-03-08 13:41:35 +03:00
parent 5d69d53301
commit 688cf0dd39
2 changed files with 61 additions and 3 deletions

View File

@@ -26,7 +26,7 @@ Legend:
17. Voice Messages - `PARTIAL` (record/send/play/seek/speed; UX still being polished)
18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic)
19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites)
20. GIF - `PARTIAL` (web GIF picker with preset catalog + local search + favorites)
20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites)
21. Message History/Search - `DONE` (history/pagination/chat+global search)
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links supported; toolbar still evolving)
23. Groups - `PARTIAL` (create/add/remove/invite link; advanced moderation partial)

View File

@@ -31,6 +31,8 @@ const GIF_PRESETS: Array<{ name: string; url: string }> = [
const STICKER_FAVORITES_KEY = "bm_sticker_favorites_v1";
const GIF_FAVORITES_KEY = "bm_gif_favorites_v1";
const TENOR_API_KEY = "LIVDSRZULELA";
const TENOR_CLIENT_KEY = "benya_messenger_web";
function loadFavorites(key: string): Set<string> {
try {
@@ -98,6 +100,8 @@ export function MessageComposer() {
const [stickerTab, setStickerTab] = useState<"all" | "favorites">("all");
const [gifTab, setGifTab] = useState<"all" | "favorites">("all");
const [gifQuery, setGifQuery] = useState("");
const [gifResults, setGifResults] = useState<Array<{ name: string; url: string }>>([]);
const [gifLoading, setGifLoading] = useState(false);
const [favoriteStickers, setFavoriteStickers] = useState<Set<string>>(() => loadFavorites(STICKER_FAVORITES_KEY));
const [favoriteGifs, setFavoriteGifs] = useState<Set<string>>(() => loadFavorites(GIF_FAVORITES_KEY));
const [captionDraft, setCaptionDraft] = useState("");
@@ -312,6 +316,59 @@ export function MessageComposer() {
});
}
useEffect(() => {
const term = gifQuery.trim();
if (!showGifMenu || term.length < 2) {
setGifResults([]);
setGifLoading(false);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setGifLoading(true);
void (async () => {
try {
const params = new URLSearchParams({
q: term,
key: TENOR_API_KEY,
client_key: TENOR_CLIENT_KEY,
limit: "24",
media_filter: "gif",
contentfilter: "medium",
});
const response = await fetch(`https://tenor.googleapis.com/v2/search?${params.toString()}`);
if (!response.ok) {
throw new Error("gif search failed");
}
const data = (await response.json()) as {
results?: Array<{ content_description?: string; media_formats?: { gif?: { url?: string } } }>;
};
if (cancelled) return;
const rows =
data.results
?.map((item) => ({
name: item.content_description || "GIF",
url: item.media_formats?.gif?.url || "",
}))
.filter((item) => item.url.length > 0) ?? [];
setGifResults(rows);
} catch {
if (!cancelled) {
setGifResults([]);
}
} finally {
if (!cancelled) {
setGifLoading(false);
}
}
})();
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [gifQuery, showGifMenu]);
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter") {
return;
@@ -925,8 +982,8 @@ export function MessageComposer() {
</button>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{GIF_PRESETS
.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase()))
{(gifResults.length > 0 ? gifResults : GIF_PRESETS)
.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase()) || gifResults.length > 0)
.filter((item) => (gifTab === "favorites" ? favoriteGifs.has(item.url) : true))
.map((gif) => (
<button
@@ -950,6 +1007,7 @@ export function MessageComposer() {
</button>
))}
</div>
{gifLoading ? <p className="mt-2 text-xs text-slate-400">Searching GIF...</p> : null}
</div>
) : null}