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
+41 -4
View File
@@ -68,6 +68,29 @@ export function buildDmMessageWithUser(
reactions: Reaction[] = [],
replyTo: DmMessageWithUser | null = null,
): DmMessageWithUser {
const stickerId = (message as any).stickerId ?? null;
// Lazy import to avoid circular dependency
let sticker = null;
if (stickerId) {
const db = getDb();
const row = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get();
if (row) {
sticker = {
id: row.id,
packId: row.packId,
spaceId: row.spaceId,
name: row.name,
tags: row.tags ?? '',
filename: row.filename,
mimetype: row.mimetype,
size: row.size,
width: row.width,
height: row.height,
uploadedBy: row.uploadedBy,
createdAt: row.createdAt,
};
}
}
return {
id: message.id,
dmChannelId: message.dmChannelId,
@@ -89,6 +112,8 @@ export function buildDmMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker,
};
}
@@ -881,21 +906,32 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
const hasSticker = stickerId && typeof stickerId === 'string';
if (!hasContent && !hasAttachments && !hasSticker) {
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
// Validate sticker exists if provided
if (hasSticker) {
const sticker = getDb().select().from(schema.stickers).where(eq(schema.stickers.id, stickerId!)).get();
if (!sticker) {
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
}
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
@@ -921,6 +957,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();