diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index b3f1d0de65..f0ca777307 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; +import { useTheme } from "@/shared/theme/ThemeProvider"; // emoji-mart builds its searchable index synchronously inside `init`, which // `` calls on mount — so the first reaction popover open paid the full @@ -105,13 +106,17 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); + const { isDark } = useTheme(); const custom = React.useMemo( () => buildCustomEmojiCategory(customEmoji), [customEmoji], @@ -139,11 +144,11 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" - theme="auto" + theme={isDark ? "dark" : "light"} /> ); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 4aa5bd47f3..2cd3e45035 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -492,6 +492,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={disabled ?? false} + gifUploadController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={isSending ?? false} diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 0000000000..ef240d180a --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { klipyGifFilename, normalizeKlipyGifs } from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const filename = klipyGifFilename({ + id: 1, + original: GIF_ASSET, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 0000000000..bc97b0d40b --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,197 @@ +const KLIPY_API_ROOT = "https://api.klipy.com/api/v1"; +const KLIPY_CUSTOMER_ID_STORAGE_KEY = "buzz:klipy-customer-id:v1"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + errors?: { + message?: string[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number; + original: Required; + preview: Required; + slug: string; + title: string; +}; + +function configuredApiKey(): string { + return import.meta.env?.VITE_KLIPY_API_KEY?.trim() ?? ""; +} + +export function isKlipyConfigured(): boolean { + return configuredApiKey().length > 0; +} + +function customerId(): string { + if (typeof window === "undefined") return "buzz-desktop"; + + try { + const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created); + return created; + } catch { + return "buzz-desktop"; + } +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + gifs.push({ + id: item.id ?? gifs.length, + original, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +function apiUrl(path: string): URL { + const apiKey = configuredApiKey(); + if (!apiKey) { + throw new Error("KLIPY is not configured for this build"); + } + return new URL(`${KLIPY_API_ROOT}/${encodeURIComponent(apiKey)}/${path}`); +} + +function responseError(response: KlipyResponse, status: number): Error { + const message = response.errors?.message?.filter(Boolean).join(" "); + return new Error(message || `KLIPY request failed (${status})`); +} + +export async function fetchKlipyGifs( + query: string, + signal?: AbortSignal, +): Promise { + const normalizedQuery = query.trim(); + const path = normalizedQuery ? "gifs/search" : "gifs/trending"; + const url = apiUrl(path); + url.searchParams.set("page", "1"); + url.searchParams.set("per_page", "24"); + url.searchParams.set("customer_id", customerId()); + url.searchParams.set("locale", navigator.language || "en-US"); + if (normalizedQuery) url.searchParams.set("q", normalizedQuery); + + const httpResponse = await fetch(url, { signal }); + const response = (await httpResponse.json()) as KlipyResponse; + if (!httpResponse.ok || response.result === false) { + throw responseError(response, httpResponse.status); + } + + return normalizeKlipyGifs(response.data?.data ?? []); +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +export async function downloadKlipyGif(gif: KlipyGif): Promise { + const response = await fetch(gif.original.url); + if (!response.ok) { + throw new Error(`Could not download GIF (${response.status})`); + } + + const blob = await response.blob(); + return new File([blob], klipyGifFilename(gif), { + type: blob.type || "image/gif", + }); +} + +/** Record the provider's share event after the user chooses a GIF. */ +export async function trackKlipyGifShare(slug: string): Promise { + const response = await fetch( + apiUrl(`gifs/share/${encodeURIComponent(slug)}`), + { + body: JSON.stringify({ customer_id: customerId() }), + headers: { "content-type": "application/json" }, + method: "POST", + }, + ); + if (!response.ok) { + throw new Error(`Could not record KLIPY share (${response.status})`); + } +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 0000000000..d25e22524c --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,141 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { + fetchKlipyGifs, + isKlipyConfigured, + type KlipyGif, +} from "@/features/gifs/api"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 250, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const configured = isKlipyConfigured(); + const gifsQuery = useQuery({ + enabled: configured, + queryFn: ({ signal }) => fetchKlipyGifs(debouncedSearch, signal), + queryKey: ["klipy-gifs", debouncedSearch], + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {!configured ? ( +
+ GIF search is not configured for this build. +
+ ) : gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => ( + + ))} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index da23c396e8..3c4cbf175c 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -1,13 +1,25 @@ -import { SmilePlus } from "lucide-react"; +import { Images, Smile, SmilePlus } from "lucide-react"; import * as React from "react"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; +import { + downloadKlipyGif, + type KlipyGif, + trackKlipyGifShare, +} from "@/features/gifs/api"; +import { KlipyGifPicker } from "@/features/gifs/ui/KlipyGifPicker"; +import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type ComposerEmojiPickerProps = { disabled?: boolean; + gifUploadController: Pick< + MediaUploadController, + "setUploadState" | "uploadFile" + >; /** Called when the popover closes without an emoji selection (Escape, * click-outside). Use this to restore focus to the editor. */ onClose?: () => void; @@ -19,19 +31,41 @@ type ComposerEmojiPickerProps = { export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ disabled = false, + gifUploadController, onClose, onEmojiSelect, onOpenChange, onTriggerMouseDown, open, }: ComposerEmojiPickerProps) { + const handleGifSelect = React.useCallback( + (gif: KlipyGif) => { + onOpenChange(false); + void downloadKlipyGif(gif) + .then(gifUploadController.uploadFile) + .catch((error: unknown) => { + gifUploadController.setUploadState({ + status: "error", + message: String(error), + }); + }); + // Provider analytics must never block the user's attachment flow. + void trackKlipyGifShare(gif.slug).catch(() => undefined); + }, + [ + gifUploadController.setUploadState, + gifUploadController.uploadFile, + onOpenChange, + ], + ); + return (