mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(client): call own server for GIFs, drop VITE_KLIPY_API_KEY
gifProvider.ts now goes through api.ts (TOFU-pinned via the Rust http proxy) instead of api.klipy.com, and the VITE_KLIPY_API_KEY path is deleted outright. The built bundle greps clean of the key name and of api.klipy.com. The klipy.com CDN allowlist stays: media URLs still load from Klipy's CDN, and the server is trusted to hold the key but not to dictate what the client renders. Degradation: on 503 GIF_DISABLED the picker shows "GIFs are not enabled on this server" and calls onUnavailable, which disables the composer's GIF button with a title/aria-label reason — mirroring the existing attach-button rule so re-enabling the composer does not resurrect it. A MessageInput with no gifApi wired renders the button disabled from the start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,32 @@
|
||||
// GifPicker — searchable GIF selector powered by Klipy API.
|
||||
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
|
||||
// GifPicker — searchable GIF selector, served by the user's own OwnCord server
|
||||
// (which proxies Klipy). Uses @lib/dom helpers exclusively. Never sets
|
||||
// innerHTML with user content.
|
||||
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { ApiClientError } from "@lib/api";
|
||||
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
|
||||
import type { GifResult } from "@lib/gifProvider";
|
||||
import type { GifApi, GifResult } from "@lib/gifProvider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GifPickerOptions {
|
||||
/** GIF endpoints on the user's own server. */
|
||||
readonly api: GifApi;
|
||||
readonly onSelect: (gifUrl: string) => void;
|
||||
readonly onClose: () => void;
|
||||
/**
|
||||
* Called when the server reports GIFs are not configured (503 GIF_DISABLED).
|
||||
* The caller uses this to disable its GIF affordance so the user is not
|
||||
* offered a feature this server does not have.
|
||||
*/
|
||||
readonly onUnavailable?: (reason: string) => void;
|
||||
}
|
||||
|
||||
/** Shown in-picker and passed to onUnavailable when the server has no key. */
|
||||
export const GIF_UNAVAILABLE_MESSAGE = "GIFs are not enabled on this server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,18 +127,28 @@ export function createGifPicker(options: GifPickerOptions): {
|
||||
|
||||
try {
|
||||
const gifs =
|
||||
query.length > 0 ? await searchGifs(query, GIF_LIMIT) : await getTrendingGifs(GIF_LIMIT);
|
||||
query.length > 0
|
||||
? await searchGifs(options.api, query, GIF_LIMIT)
|
||||
: await getTrendingGifs(options.api, GIF_LIMIT);
|
||||
|
||||
// Only render if this is still the latest request
|
||||
if (requestId === currentRequestId) {
|
||||
renderGifs(gifs);
|
||||
}
|
||||
} catch (err) {
|
||||
// The server has no GIF key configured — degrade calmly and tell the
|
||||
// caller so it can disable its GIF button, rather than looking broken.
|
||||
const disabled = err instanceof ApiClientError && err.code === "GIF_DISABLED";
|
||||
if (disabled) {
|
||||
root.classList.add("gp-unavailable");
|
||||
searchInput.disabled = true;
|
||||
options.onUnavailable?.(GIF_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
if (requestId === currentRequestId) {
|
||||
clearChildren(gridArea);
|
||||
const errEl = createElement("div", { class: "gp-empty" });
|
||||
const msg = err instanceof Error ? err.message : "Failed to load GIFs";
|
||||
setText(errEl, msg);
|
||||
const fallback = err instanceof Error ? err.message : "Failed to load GIFs";
|
||||
setText(errEl, disabled ? GIF_UNAVAILABLE_MESSAGE : fallback);
|
||||
gridArea.appendChild(errEl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,17 @@ import { createIcon } from "@lib/icons";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { createEmojiPicker } from "@components/EmojiPicker";
|
||||
import { createGifPicker } from "@components/GifPicker";
|
||||
import type { GifApi } from "@lib/gifProvider";
|
||||
|
||||
export interface MessageInputOptions {
|
||||
readonly channelId: number;
|
||||
readonly channelName: string;
|
||||
/**
|
||||
* GIF endpoints on the user's own server. Omit to hide the GIF affordance
|
||||
* entirely — the button is rendered disabled rather than offering a picker
|
||||
* that cannot load.
|
||||
*/
|
||||
readonly gifApi?: GifApi;
|
||||
readonly onSend: (
|
||||
content: string,
|
||||
replyTo: number | null,
|
||||
@@ -52,6 +59,13 @@ const ALLOWED_TYPES = [
|
||||
"application/json",
|
||||
];
|
||||
|
||||
/** Disable the GIF button and say why, instead of silently doing nothing. */
|
||||
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
|
||||
gifBtn.setAttribute("disabled", "true");
|
||||
gifBtn.title = reason;
|
||||
gifBtn.setAttribute("aria-label", `GIF — ${reason}`);
|
||||
}
|
||||
|
||||
export function createMessageInput(options: MessageInputOptions): MessageInputComponent {
|
||||
const ac = new AbortController();
|
||||
const signal = ac.signal;
|
||||
@@ -68,6 +82,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
let replyText: HTMLSpanElement | null = null;
|
||||
let editBar: HTMLDivElement | null = null;
|
||||
let disabledReason: string | null = options.disabledReason ?? null;
|
||||
/** True once the server has told us GIFs are off, or if no GIF api was wired. */
|
||||
let gifUnavailable = options.gifApi === undefined;
|
||||
const controlButtons: HTMLButtonElement[] = [];
|
||||
let attachmentPreviewBar: HTMLDivElement | null = null;
|
||||
|
||||
@@ -150,6 +166,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
} else {
|
||||
// Don't re-enable the attach button when uploads aren't wired.
|
||||
if (btn.classList.contains("attach-btn") && options.onUploadFile === undefined) continue;
|
||||
// Likewise for GIFs when this server has no GIF provider configured.
|
||||
if (btn.classList.contains("gif-btn") && gifUnavailable) continue;
|
||||
btn.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
@@ -428,6 +446,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
{ class: "input-btn gif-btn", "aria-label": "GIF" },
|
||||
"GIF",
|
||||
);
|
||||
if (gifUnavailable) {
|
||||
markGifUnavailable(gifBtn, "GIFs are not enabled on this server");
|
||||
}
|
||||
const sendBtn = createElement("button", {
|
||||
class: "input-btn send-btn",
|
||||
"aria-label": "Send message",
|
||||
@@ -573,6 +594,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
}
|
||||
|
||||
function toggleGifPicker(): void {
|
||||
const gifApi = options.gifApi;
|
||||
if (gifApi === undefined) return;
|
||||
// Close emoji picker if open
|
||||
if (emojiPicker !== null) {
|
||||
closeEmojiPicker();
|
||||
@@ -582,6 +605,11 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
|
||||
return;
|
||||
}
|
||||
gifPicker = createGifPicker({
|
||||
api: gifApi,
|
||||
onUnavailable: (reason: string) => {
|
||||
gifUnavailable = true;
|
||||
markGifUnavailable(gifBtn, reason);
|
||||
},
|
||||
onSelect: (gifUrl: string) => {
|
||||
if (textarea !== null) {
|
||||
textarea.value = gifUrl;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
DmChannelsResponse,
|
||||
CreateDmResponse,
|
||||
BlockedUsersResponse,
|
||||
GifSearchResponse,
|
||||
} from "./types";
|
||||
|
||||
/** Configuration for the API client. */
|
||||
@@ -358,6 +359,32 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
return request<SearchResponse>("GET", `/search?${params.toString()}`, undefined, signal);
|
||||
},
|
||||
|
||||
// ── GIFs ──────────────────────────────────────────────
|
||||
//
|
||||
// Proxied by the user's own server so the GIF provider API key never
|
||||
// ships in this bundle. A 503 GIF_DISABLED means the operator has not
|
||||
// configured a key — callers must degrade, not retry.
|
||||
|
||||
gifSearch(query: string, limit: number, signal?: AbortSignal): Promise<GifSearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
return request<GifSearchResponse>(
|
||||
"GET",
|
||||
`/gif/search?${params.toString()}`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
gifTrending(limit: number, signal?: AbortSignal): Promise<GifSearchResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
return request<GifSearchResponse>(
|
||||
"GET",
|
||||
`/gif/trending?${params.toString()}`,
|
||||
undefined,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
// ── File Uploads ──────────────────────────────────────
|
||||
|
||||
async uploadFile(file: File, signal?: AbortSignal): Promise<UploadResponse> {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
// Klipy GIF API client — provides GIF search and trending.
|
||||
// Drop-in replacement for Tenor (EOL June 30, 2026).
|
||||
// Register at partner.klipy.com to get a production API key.
|
||||
// Override via VITE_KLIPY_API_KEY at build time.
|
||||
const KLIPY_API_KEY = import.meta.env.VITE_KLIPY_API_KEY ?? "";
|
||||
const GIF_API_BASE = "https://api.klipy.com/v2";
|
||||
// GIF search and trending, proxied by the user's own OwnCord server.
|
||||
//
|
||||
// The client NEVER talks to api.klipy.com and never holds the provider API
|
||||
// key: a VITE_ build variable is inlined into the shipped bundle by design, so
|
||||
// it can never hold a secret. The key lives in the server's `gif.api_key`
|
||||
// config and the server makes the upstream call — see Server/api/gif_handler.go.
|
||||
//
|
||||
// The returned media URLs still point at Klipy's CDN, so they are validated
|
||||
// against the CDN allowlist below before anything is rendered.
|
||||
|
||||
import type { ApiClient } from "./api";
|
||||
import type { GifSearchResponse } from "./types";
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
/** The slice of the API client the GIF picker needs. */
|
||||
export type GifApi = Pick<ApiClient, "gifSearch" | "gifTrending">;
|
||||
|
||||
export interface GifResult {
|
||||
readonly id: string;
|
||||
@@ -19,23 +25,6 @@ export interface GifResult {
|
||||
readonly fullUrl: string;
|
||||
}
|
||||
|
||||
interface GifMediaFormat {
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
interface GifApiResult {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly media_formats: {
|
||||
readonly tinygif?: GifMediaFormat;
|
||||
readonly gif?: GifMediaFormat;
|
||||
};
|
||||
}
|
||||
|
||||
interface GifSearchResponse {
|
||||
readonly results: readonly GifApiResult[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,44 +61,19 @@ function parseResults(data: GifSearchResponse): readonly GifResult[] {
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search Klipy for GIFs matching the given query.
|
||||
*/
|
||||
/** Search the server's GIF proxy for GIFs matching the given query. */
|
||||
export async function searchGifs(
|
||||
api: GifApi,
|
||||
query: string,
|
||||
limit: number = DEFAULT_LIMIT,
|
||||
): Promise<readonly GifResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
key: KLIPY_API_KEY,
|
||||
limit: String(limit),
|
||||
media_filter: "gif,tinygif",
|
||||
});
|
||||
|
||||
const res = await fetch(`${GIF_API_BASE}/search?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GIF search failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data: GifSearchResponse = await res.json();
|
||||
return parseResults(data);
|
||||
return parseResults(await api.gifSearch(query, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch currently trending GIFs from Klipy.
|
||||
*/
|
||||
/** Fetch currently trending GIFs via the server's GIF proxy. */
|
||||
export async function getTrendingGifs(
|
||||
api: GifApi,
|
||||
limit: number = DEFAULT_LIMIT,
|
||||
): Promise<readonly GifResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
key: KLIPY_API_KEY,
|
||||
limit: String(limit),
|
||||
media_filter: "gif,tinygif",
|
||||
});
|
||||
|
||||
const res = await fetch(`${GIF_API_BASE}/featured?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GIF trending failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data: GifSearchResponse = await res.json();
|
||||
return parseResults(data);
|
||||
return parseResults(await api.gifTrending(limit));
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export type ApiErrorCode =
|
||||
| "CONFLICT"
|
||||
| "TOO_LARGE"
|
||||
| "SERVER_ERROR"
|
||||
/** GIF proxy is not configured on this server (no gif.api_key). */
|
||||
| "GIF_DISABLED"
|
||||
| "UNKNOWN";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -638,6 +640,27 @@ export interface UploadResponse {
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/gif/{search,trending} response.
|
||||
*
|
||||
* The GIF provider key lives on the server; the client only ever talks to its
|
||||
* own server here. The media URLs still point at Klipy's CDN and are validated
|
||||
* against the CDN allowlist in gifProvider before being rendered.
|
||||
*/
|
||||
export interface GifApiResult {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly media_formats: {
|
||||
readonly tinygif?: { readonly url: string };
|
||||
readonly gif?: { readonly url: string };
|
||||
};
|
||||
}
|
||||
|
||||
/** Envelope for both GIF endpoints. */
|
||||
export interface GifSearchResponse {
|
||||
readonly results: readonly GifApiResult[];
|
||||
}
|
||||
|
||||
/** GET /api/v1/dms response. */
|
||||
export interface DmChannelsResponse {
|
||||
readonly dm_channels: readonly DmChannelPayload[];
|
||||
|
||||
@@ -272,6 +272,7 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
|
||||
messageInput = createMessageInput({
|
||||
channelId,
|
||||
channelName,
|
||||
gifApi: api,
|
||||
onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => {
|
||||
performSend(content, replyTo, attachments);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createGifPicker } from "@components/GifPicker";
|
||||
import { createGifPicker, GIF_UNAVAILABLE_MESSAGE } from "@components/GifPicker";
|
||||
import { ApiClientError } from "@lib/api";
|
||||
import type { GifPickerOptions } from "@components/GifPicker";
|
||||
import type { GifResult } from "@lib/gifProvider";
|
||||
import type { GifApi, GifResult } from "@lib/gifProvider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mock — must be hoisted before imports in vitest
|
||||
@@ -36,10 +37,17 @@ const SEARCH_GIFS: readonly GifResult[] = [makeGif("s1"), makeGif("s2")];
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stubApi: GifApi = {
|
||||
gifSearch: vi.fn(),
|
||||
gifTrending: vi.fn(),
|
||||
};
|
||||
|
||||
function makePicker(overrides?: Partial<GifPickerOptions>) {
|
||||
const options: GifPickerOptions = {
|
||||
api: overrides?.api ?? stubApi,
|
||||
onSelect: overrides?.onSelect ?? vi.fn(),
|
||||
onClose: overrides?.onClose ?? vi.fn(),
|
||||
onUnavailable: overrides?.onUnavailable,
|
||||
};
|
||||
const picker = createGifPicker(options);
|
||||
return { picker, options };
|
||||
@@ -131,7 +139,7 @@ describe("GifPicker", () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve(); // flush microtasks
|
||||
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("cats", 20);
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "cats", 20);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
@@ -152,7 +160,7 @@ describe("GifPicker", () => {
|
||||
|
||||
// Only one call — the second one after the full debounce window
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("cats", 20);
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "cats", 20);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
@@ -167,7 +175,7 @@ describe("GifPicker", () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith("dogs", 20);
|
||||
expect(vi.mocked(searchGifs)).toHaveBeenCalledWith(stubApi, "dogs", 20);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
@@ -201,7 +209,7 @@ describe("GifPicker", () => {
|
||||
it("calls getTrendingGifs on creation", () => {
|
||||
const { picker } = makePicker();
|
||||
// getTrendingGifs is called synchronously (no timer needed) at init
|
||||
expect(vi.mocked(getTrendingGifs)).toHaveBeenCalledWith(20);
|
||||
expect(vi.mocked(getTrendingGifs)).toHaveBeenCalledWith(stubApi, 20);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
@@ -523,6 +531,81 @@ describe("GifPicker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Server has no GIF key configured (503 GIF_DISABLED) ───────────────────
|
||||
|
||||
describe("graceful degradation when the server has no GIF key", () => {
|
||||
const disabledError = new ApiClientError(
|
||||
503,
|
||||
"GIF_DISABLED",
|
||||
"GIF search is not configured on this server",
|
||||
);
|
||||
|
||||
it("shows a calm reason instead of a raw error", async () => {
|
||||
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
|
||||
|
||||
const { picker } = makePicker();
|
||||
container.appendChild(picker.element);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const el = picker.element.querySelector(".gp-empty");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.textContent).toBe(GIF_UNAVAILABLE_MESSAGE);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it("marks the picker unavailable and disables the search input", async () => {
|
||||
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
|
||||
|
||||
const { picker } = makePicker();
|
||||
container.appendChild(picker.element);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(picker.element.classList.contains("gp-unavailable")).toBe(true);
|
||||
const input = picker.element.querySelector(".gp-search") as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it("notifies the caller via onUnavailable so it can hide its GIF button", async () => {
|
||||
vi.mocked(getTrendingGifs).mockRejectedValue(disabledError);
|
||||
const onUnavailable = vi.fn();
|
||||
|
||||
const { picker } = makePicker({ onUnavailable });
|
||||
container.appendChild(picker.element);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onUnavailable).toHaveBeenCalledWith(GIF_UNAVAILABLE_MESSAGE);
|
||||
picker.destroy();
|
||||
});
|
||||
|
||||
it("does not treat other API errors as unavailable", async () => {
|
||||
vi.mocked(getTrendingGifs).mockRejectedValue(
|
||||
new ApiClientError(502, "BAD_GATEWAY", "GIF provider is unavailable"),
|
||||
);
|
||||
const onUnavailable = vi.fn();
|
||||
|
||||
const { picker } = makePicker({ onUnavailable });
|
||||
container.appendChild(picker.element);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onUnavailable).not.toHaveBeenCalled();
|
||||
expect(picker.element.classList.contains("gp-unavailable")).toBe(false);
|
||||
picker.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stale request cancellation ────────────────────────────────────────────
|
||||
|
||||
describe("stale request cancellation", () => {
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { searchGifs, getTrendingGifs } from "../../src/lib/gifProvider";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { searchGifs, getTrendingGifs, type GifApi } from "../../src/lib/gifProvider";
|
||||
import type { GifSearchResponse } from "../../src/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch mock — global fetch used by gifProvider.ts (no plugin wrapper)
|
||||
// The GIF provider must go through the user's OWN server (api.ts, which is
|
||||
// TOFU-pinned via the Rust http proxy) — never api.klipy.com, and never with
|
||||
// an API key in this bundle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
const gifSearch = vi.fn<GifApi["gifSearch"]>();
|
||||
const gifTrending = vi.fn<GifApi["gifTrending"]>();
|
||||
const api: GifApi = { gifSearch, gifTrending };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
gifSearch.mockReset();
|
||||
gifTrending.mockReset();
|
||||
// A real fetch must never happen from this module.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => {
|
||||
throw new Error("gifProvider must not call fetch directly");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function gifResult(
|
||||
id: string,
|
||||
overrides: {
|
||||
tinygif?: string | null;
|
||||
gif?: string | null;
|
||||
title?: string;
|
||||
} = {},
|
||||
overrides: { tinygif?: string | null; gif?: string | null; title?: string } = {},
|
||||
) {
|
||||
const {
|
||||
tinygif = `https://media.klipy.com/${id}_tiny.gif`,
|
||||
@@ -41,32 +45,8 @@ function gifResult(
|
||||
return { id, title, media_formats };
|
||||
}
|
||||
|
||||
function okResponse(results: unknown[], status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: "OK",
|
||||
json: () => Promise.resolve({ results }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function errorResponse(status: number, statusText: string): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve({}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// Extracts a URLSearchParams object from the URL string passed to fetch.
|
||||
function capturedParams(): URLSearchParams {
|
||||
const url = mockFetch.mock.calls[0]?.[0] as string;
|
||||
return new URLSearchParams(url.split("?")[1] ?? "");
|
||||
}
|
||||
|
||||
function capturedUrl(): string {
|
||||
return mockFetch.mock.calls[0]?.[0] as string;
|
||||
function response(results: unknown[]): GifSearchResponse {
|
||||
return { results } as GifSearchResponse;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,61 +54,53 @@ function capturedUrl(): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchGifs", () => {
|
||||
describe("URL construction", () => {
|
||||
it("calls the Klipy search endpoint", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("cats");
|
||||
expect(capturedUrl()).toMatch(/^https:\/\/api\.klipy\.com\/v2\/search/);
|
||||
describe("transport", () => {
|
||||
it("calls the server's GIF search endpoint via the api client", async () => {
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "cats");
|
||||
expect(gifSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("includes the query param q", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("dogs");
|
||||
expect(capturedParams().get("q")).toBe("dogs");
|
||||
});
|
||||
|
||||
it("includes the API key param", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("dogs");
|
||||
expect(capturedParams().has("key")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes media_filter param", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("dogs");
|
||||
expect(capturedParams().get("media_filter")).toBe("gif,tinygif");
|
||||
it("passes the query through", async () => {
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "dogs");
|
||||
expect(gifSearch).toHaveBeenCalledWith("dogs", 20);
|
||||
});
|
||||
|
||||
it("defaults limit to 20", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("cats");
|
||||
expect(capturedParams().get("limit")).toBe("20");
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "cats");
|
||||
expect(gifSearch.mock.calls[0]?.[1]).toBe(20);
|
||||
});
|
||||
|
||||
it("passes an explicit limit override", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("cats", 5);
|
||||
expect(capturedParams().get("limit")).toBe("5");
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "cats", 5);
|
||||
expect(gifSearch.mock.calls[0]?.[1]).toBe(5);
|
||||
});
|
||||
|
||||
it("URL-encodes special characters in the query", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await searchGifs("hello world & more");
|
||||
const q = capturedParams().get("q");
|
||||
expect(q).toBe("hello world & more");
|
||||
it("never calls global fetch (no direct api.klipy.com traffic)", async () => {
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "cats");
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use the trending endpoint", async () => {
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
await searchGifs(api, "cats");
|
||||
expect(gifTrending).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("result parsing", () => {
|
||||
it("returns an empty array when results are empty", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
const gifs = await searchGifs("nothing");
|
||||
expect(gifs).toEqual([]);
|
||||
gifSearch.mockResolvedValue(response([]));
|
||||
expect(await searchGifs(api, "nothing")).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps id, title, url (tinygif), and fullUrl (gif) correctly", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([gifResult("abc123")]));
|
||||
const gifs = await searchGifs("cats");
|
||||
gifSearch.mockResolvedValue(response([gifResult("abc123")]));
|
||||
const gifs = await searchGifs(api, "cats");
|
||||
expect(gifs).toHaveLength(1);
|
||||
expect(gifs[0]).toEqual({
|
||||
id: "abc123",
|
||||
@@ -139,49 +111,39 @@ describe("searchGifs", () => {
|
||||
});
|
||||
|
||||
it("maps multiple results in order", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([gifResult("a"), gifResult("b"), gifResult("c")]));
|
||||
const gifs = await searchGifs("cats");
|
||||
gifSearch.mockResolvedValue(response([gifResult("a"), gifResult("b"), gifResult("c")]));
|
||||
const gifs = await searchGifs(api, "cats");
|
||||
expect(gifs.map((g) => g.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("filters out results with no tinygif format", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("keep"), gifResult("drop", { tinygif: null })]),
|
||||
gifSearch.mockResolvedValue(
|
||||
response([gifResult("keep"), gifResult("drop", { tinygif: null })]),
|
||||
);
|
||||
const gifs = await searchGifs("cats");
|
||||
expect(gifs).toHaveLength(1);
|
||||
expect(gifs[0]?.id).toBe("keep");
|
||||
const gifs = await searchGifs(api, "cats");
|
||||
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("filters out results with no gif format", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("keep"), gifResult("drop", { gif: null })]),
|
||||
);
|
||||
const gifs = await searchGifs("cats");
|
||||
expect(gifs).toHaveLength(1);
|
||||
expect(gifs[0]?.id).toBe("keep");
|
||||
});
|
||||
|
||||
it("filters out results missing both formats", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("drop", { tinygif: null, gif: null }), gifResult("keep")]),
|
||||
);
|
||||
const gifs = await searchGifs("cats");
|
||||
expect(gifs).toHaveLength(1);
|
||||
expect(gifs[0]?.id).toBe("keep");
|
||||
gifSearch.mockResolvedValue(response([gifResult("keep"), gifResult("drop", { gif: null })]));
|
||||
const gifs = await searchGifs(api, "cats");
|
||||
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when all results lack required formats", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("x", { tinygif: null }), gifResult("y", { gif: null })]),
|
||||
gifSearch.mockResolvedValue(
|
||||
response([gifResult("x", { tinygif: null }), gifResult("y", { gif: null })]),
|
||||
);
|
||||
const gifs = await searchGifs("cats");
|
||||
expect(gifs).toEqual([]);
|
||||
expect(await searchGifs(api, "cats")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The server is trusted to hold the key, but not to dictate what the client
|
||||
// renders — media URLs are still pinned to the Klipy CDN.
|
||||
describe("CDN allowlist", () => {
|
||||
it("filters out results with non-Klipy CDN URLs", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([
|
||||
gifSearch.mockResolvedValue(
|
||||
response([
|
||||
gifResult("drop", {
|
||||
tinygif: "https://media.tenor.com/drop_tiny.gif",
|
||||
gif: "https://media.tenor.com/drop.gif",
|
||||
@@ -192,31 +154,58 @@ describe("searchGifs", () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const gifs = await searchGifs("cats");
|
||||
expect(gifs).toHaveLength(1);
|
||||
expect(gifs[0]?.id).toBe("keep");
|
||||
const gifs = await searchGifs(api, "cats");
|
||||
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("rejects http:// URLs on the allowed host", async () => {
|
||||
gifSearch.mockResolvedValue(
|
||||
response([
|
||||
gifResult("drop", {
|
||||
tinygif: "http://media.klipy.com/a_tiny.gif",
|
||||
gif: "http://media.klipy.com/a.gif",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await searchGifs(api, "cats")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects lookalike hosts that merely end in the allowed name", async () => {
|
||||
gifSearch.mockResolvedValue(
|
||||
response([
|
||||
gifResult("drop", {
|
||||
tinygif: "https://evilklipy.com/a_tiny.gif",
|
||||
gif: "https://evilklipy.com/a.gif",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await searchGifs(api, "cats")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a klipy.com path on an attacker host", async () => {
|
||||
gifSearch.mockResolvedValue(
|
||||
response([
|
||||
gifResult("drop", {
|
||||
tinygif: "https://evil.example.com/klipy.com/a_tiny.gif",
|
||||
gif: "https://evil.example.com/klipy.com/a.gif",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await searchGifs(api, "cats")).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", async () => {
|
||||
gifSearch.mockResolvedValue(
|
||||
response([gifResult("drop", { tinygif: "not a url", gif: "also not a url" })]),
|
||||
);
|
||||
expect(await searchGifs(api, "cats")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP error handling", () => {
|
||||
it("throws when the response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(429, "Too Many Requests"));
|
||||
await expect(searchGifs("cats")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("error message includes the HTTP status code", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(403, "Forbidden"));
|
||||
await expect(searchGifs("cats")).rejects.toThrow("403");
|
||||
});
|
||||
|
||||
it("error message includes the status text", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(403, "Forbidden"));
|
||||
await expect(searchGifs("cats")).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("throws when fetch itself rejects (network error)", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network failure"));
|
||||
await expect(searchGifs("cats")).rejects.toThrow("Network failure");
|
||||
describe("error propagation", () => {
|
||||
it("propagates the api client's error so the picker can degrade", async () => {
|
||||
gifSearch.mockRejectedValue(new Error("Service Unavailable"));
|
||||
await expect(searchGifs(api, "cats")).rejects.toThrow("Service Unavailable");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -226,54 +215,47 @@ describe("searchGifs", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getTrendingGifs", () => {
|
||||
describe("URL construction", () => {
|
||||
it("calls the Klipy featured endpoint", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs();
|
||||
expect(capturedUrl()).toMatch(/^https:\/\/api\.klipy\.com\/v2\/featured/);
|
||||
});
|
||||
|
||||
it("does not include a q param", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs();
|
||||
expect(capturedParams().has("q")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes the API key param", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs();
|
||||
expect(capturedParams().has("key")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes media_filter param", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs();
|
||||
expect(capturedParams().get("media_filter")).toBe("gif,tinygif");
|
||||
describe("transport", () => {
|
||||
it("calls the server's trending endpoint via the api client", async () => {
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
await getTrendingGifs(api);
|
||||
expect(gifTrending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defaults limit to 20", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs();
|
||||
expect(capturedParams().get("limit")).toBe("20");
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
await getTrendingGifs(api);
|
||||
expect(gifTrending).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it("passes an explicit limit override", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
await getTrendingGifs(10);
|
||||
expect(capturedParams().get("limit")).toBe("10");
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
await getTrendingGifs(api, 10);
|
||||
expect(gifTrending).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it("never calls global fetch (no direct api.klipy.com traffic)", async () => {
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
await getTrendingGifs(api);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use the search endpoint", async () => {
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
await getTrendingGifs(api);
|
||||
expect(gifSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("result parsing", () => {
|
||||
it("returns an empty array when results are empty", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([]));
|
||||
const gifs = await getTrendingGifs();
|
||||
expect(gifs).toEqual([]);
|
||||
gifTrending.mockResolvedValue(response([]));
|
||||
expect(await getTrendingGifs(api)).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps fields correctly", async () => {
|
||||
mockFetch.mockResolvedValue(okResponse([gifResult("trend1")]));
|
||||
const gifs = await getTrendingGifs();
|
||||
gifTrending.mockResolvedValue(response([gifResult("trend1")]));
|
||||
const gifs = await getTrendingGifs(api);
|
||||
expect(gifs[0]).toEqual({
|
||||
id: "trend1",
|
||||
title: "Title trend1",
|
||||
@@ -283,41 +265,36 @@ describe("getTrendingGifs", () => {
|
||||
});
|
||||
|
||||
it("filters out results with missing tinygif", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("keep"), gifResult("drop", { tinygif: null })]),
|
||||
gifTrending.mockResolvedValue(
|
||||
response([gifResult("keep"), gifResult("drop", { tinygif: null })]),
|
||||
);
|
||||
const gifs = await getTrendingGifs();
|
||||
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
|
||||
expect((await getTrendingGifs(api)).map((g) => g.id)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("filters out results with missing gif", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
okResponse([gifResult("keep"), gifResult("drop", { gif: null })]),
|
||||
gifTrending.mockResolvedValue(
|
||||
response([gifResult("keep"), gifResult("drop", { gif: null })]),
|
||||
);
|
||||
const gifs = await getTrendingGifs();
|
||||
expect(gifs.map((g) => g.id)).toEqual(["keep"]);
|
||||
expect((await getTrendingGifs(api)).map((g) => g.id)).toEqual(["keep"]);
|
||||
});
|
||||
|
||||
it("enforces the CDN allowlist on trending results too", async () => {
|
||||
gifTrending.mockResolvedValue(
|
||||
response([
|
||||
gifResult("drop", {
|
||||
tinygif: "https://media.tenor.com/drop_tiny.gif",
|
||||
gif: "https://media.tenor.com/drop.gif",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await getTrendingGifs(api)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP error handling", () => {
|
||||
it("throws when the response is not ok", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(500, "Internal Server Error"));
|
||||
await expect(getTrendingGifs()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("error message includes the HTTP status code", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(503, "Service Unavailable"));
|
||||
await expect(getTrendingGifs()).rejects.toThrow("503");
|
||||
});
|
||||
|
||||
it("error message includes the status text", async () => {
|
||||
mockFetch.mockResolvedValue(errorResponse(503, "Service Unavailable"));
|
||||
await expect(getTrendingGifs()).rejects.toThrow("Service Unavailable");
|
||||
});
|
||||
|
||||
it("throws when fetch itself rejects (network error)", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("DNS lookup failed"));
|
||||
await expect(getTrendingGifs()).rejects.toThrow("DNS lookup failed");
|
||||
describe("error propagation", () => {
|
||||
it("propagates the api client's error so the picker can degrade", async () => {
|
||||
gifTrending.mockRejectedValue(new Error("DNS lookup failed"));
|
||||
await expect(getTrendingGifs(api)).rejects.toThrow("DNS lookup failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,15 @@ vi.mock("@components/EmojiPicker", () => ({
|
||||
}));
|
||||
|
||||
/** Captured GIF picker callbacks so tests can simulate selection. */
|
||||
let lastGifPickerOptions: { onSelect: (url: string) => void; onClose: () => void } | null = null;
|
||||
type CapturedGifPickerOptions = {
|
||||
onSelect: (url: string) => void;
|
||||
onClose: () => void;
|
||||
onUnavailable?: (reason: string) => void;
|
||||
};
|
||||
let lastGifPickerOptions: CapturedGifPickerOptions | null = null;
|
||||
|
||||
vi.mock("@components/GifPicker", () => ({
|
||||
createGifPicker: (opts: { onSelect: (url: string) => void; onClose: () => void }) => {
|
||||
createGifPicker: (opts: CapturedGifPickerOptions) => {
|
||||
lastGifPickerOptions = opts;
|
||||
const element = document.createElement("div");
|
||||
element.classList.add("gif-picker");
|
||||
@@ -26,11 +31,19 @@ vi.mock("@components/GifPicker", () => ({
|
||||
}));
|
||||
|
||||
import { createMessageInput, type MessageInputOptions } from "@components/MessageInput";
|
||||
import type { GifApi } from "@lib/gifProvider";
|
||||
|
||||
/** GIF endpoints on the user's own server (never api.klipy.com). */
|
||||
const stubGifApi: GifApi = {
|
||||
gifSearch: vi.fn(async () => ({ results: [] })),
|
||||
gifTrending: vi.fn(async () => ({ results: [] })),
|
||||
};
|
||||
|
||||
function makeOptions(overrides: Partial<MessageInputOptions> = {}): MessageInputOptions {
|
||||
return {
|
||||
channelId: 1,
|
||||
channelName: "general",
|
||||
gifApi: stubGifApi,
|
||||
onSend: vi.fn(),
|
||||
onTyping: vi.fn(),
|
||||
onEditMessage: vi.fn(),
|
||||
@@ -896,4 +909,67 @@ describe("MessageInput", () => {
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
// ── GIF affordance degrades when the server has no GIF provider ───────────
|
||||
|
||||
describe("GIF button degradation", () => {
|
||||
it("is enabled when a gifApi is wired", () => {
|
||||
const comp = createMessageInput(makeOptions());
|
||||
comp.mount(container);
|
||||
|
||||
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
||||
expect(gifBtn.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("renders disabled with a visible reason when no gifApi is wired", () => {
|
||||
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
||||
comp.mount(container);
|
||||
|
||||
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
||||
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
||||
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
|
||||
expect(gifBtn.getAttribute("aria-label")).toContain("not enabled");
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("does not open a picker when no gifApi is wired", () => {
|
||||
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
||||
comp.mount(container);
|
||||
|
||||
(container.querySelector(".gif-btn") as HTMLElement).click();
|
||||
expect(container.querySelector(".gif-picker")).toBeNull();
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("disables the GIF button when the picker reports the server has GIFs off", () => {
|
||||
const comp = createMessageInput(makeOptions());
|
||||
comp.mount(container);
|
||||
|
||||
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
||||
gifBtn.click();
|
||||
lastGifPickerOptions!.onUnavailable!("GIFs are not enabled on this server");
|
||||
|
||||
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
||||
expect(gifBtn.title).toBe("GIFs are not enabled on this server");
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
|
||||
it("keeps the GIF button disabled after the composer is re-enabled", () => {
|
||||
const comp = createMessageInput(makeOptions({ gifApi: undefined }));
|
||||
comp.mount(container);
|
||||
|
||||
comp.setDisabled("Read-only channel");
|
||||
comp.setDisabled(null);
|
||||
|
||||
const gifBtn = container.querySelector(".gif-btn") as HTMLButtonElement;
|
||||
expect(gifBtn.hasAttribute("disabled")).toBe(true);
|
||||
|
||||
comp.destroy?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user