feat: GIF search (Klipy), stickers, emoji picker, and bug fixes

- Add GIF search powered by Klipy API with correct response mapping
  (file.sm/hd tiers, not flat files structure)
- Add sticker system: packs, upload with auto-downscale, send in messages
- Add tabbed InputPopover with emoji, GIF, and sticker pickers
- Fix GIF API key migration race condition (column-add loop vs rename)
- Fix masked API key corruption on settings save (server + client guards)
- Fix sticker packs 403 (reversed isMember parameter order)
- Fix emoji picker not filling popover width (perLine 8→9, CSS 100%)
- Add error logging for Klipy API failures
This commit is contained in:
Jannis Braun
2026-03-15 02:04:37 +01:00
parent 7113f47b17
commit 3de6e4a668
26 changed files with 2160 additions and 50 deletions
+181
View File
@@ -0,0 +1,181 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { GifResult } from '@backspace/shared';
interface KlipyGifFile {
url: string;
width: number;
height: number;
size: number;
}
interface KlipySizeTier {
gif?: KlipyGifFile;
webp?: KlipyGifFile;
mp4?: KlipyGifFile;
jpg?: KlipyGifFile;
webm?: KlipyGifFile;
}
interface KlipyGif {
id: number;
slug: string;
title: string;
file: {
hd?: KlipySizeTier;
md?: KlipySizeTier;
sm?: KlipySizeTier;
xs?: KlipySizeTier;
};
}
interface KlipyResponse {
result: boolean;
data: {
data: KlipyGif[];
current_page: number;
per_page: number;
has_next: boolean;
};
}
function mapKlipyResults(gifs: KlipyGif[]): GifResult[] {
return gifs
.map((g) => {
if (!g.file) return null;
// Preview: small tier, prefer webp (smaller) then gif
const smTier = g.file.sm ?? g.file.xs;
const preview = smTier?.webp ?? smTier?.gif;
// Full: HD tier, prefer gif (original quality) then webp
const hdTier = g.file.hd ?? g.file.md;
const full = hdTier?.gif ?? hdTier?.webp;
if (!preview || !full) return null;
return {
id: g.slug || String(g.id),
title: g.title ?? '',
previewUrl: preview.url,
url: full.url,
width: preview.width,
height: preview.height,
};
})
.filter((r): r is GifResult => r !== null);
}
function getGifApiKey(): string | null {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
return row?.gifApiKey ?? null;
}
export async function gifRoutes(app: FastifyInstance): Promise<void> {
// GET /api/gif/enabled — any authenticated user, returns whether GIF search is available
app.get('/api/gif/enabled', { preHandler: authenticate }, async (_request, reply) => {
const key = getGifApiKey();
return reply.code(200).send({ enabled: !!key });
});
// GET /api/gif/trending
app.get<{ Querystring: { limit?: string; pos?: string } }>('/api/gif/trending', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/trending?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy trending error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy trending returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy trending fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
// GET /api/gif/search
app.get<{ Querystring: { q?: string; limit?: string; pos?: string } }>('/api/gif/search', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const q = request.query.q?.trim();
if (!q) {
return reply.code(400).send({ error: 'Search query is required', statusCode: 400 });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
q,
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/search?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy search error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy search returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy search fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
}