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
+114 -16
View File
@@ -150,6 +150,20 @@ export function runMigrations(db: Database.Database): void {
columns: [
{ name: 'password_changed_at', type: 'INTEGER' },
]
},
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
// or it will race with the tenor_api_key → gif_api_key rename migration
{
name: 'messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
},
{
name: 'dm_messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
}
];
@@ -315,6 +329,38 @@ export function runMigrations(db: Database.Database): void {
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
migrateDmMessagesReplyToFk(db);
// ─── Ensure sticker tables exist ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS sticker_packs (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS stickers (
id TEXT PRIMARY KEY,
pack_id TEXT NOT NULL REFERENCES sticker_packs(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tags TEXT DEFAULT '',
filename TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
uploaded_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sticker_packs_space_id ON sticker_packs(space_id);
CREATE INDEX IF NOT EXISTS idx_stickers_pack_id ON stickers(pack_id);
CREATE INDEX IF NOT EXISTS idx_stickers_space_id ON stickers(space_id);
`);
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
migrateRenameGifApiKey(db);
// ─── Add indexes on FK columns for query performance ─────────────────────
migrateAddIndexes(db);
@@ -333,22 +379,74 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
if (tableInfo.sql.includes('REFERENCES dm_messages')) return;
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
// Check if sticker_id column exists (may have been added by column migration)
const dmCols = db.pragma('table_info(dm_messages)') as { name: string }[];
const hasStickerId = dmCols.some(c => c.name === 'sticker_id');
if (hasStickerId) {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
sticker_id TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, sticker_id, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
} else {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
}
}
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
function migrateRenameGifApiKey(db: Database.Database): void {
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
const hasTenor = cols.some(c => c.name === 'tenor_api_key');
const hasGif = cols.some(c => c.name === 'gif_api_key');
if (hasTenor && !hasGif) {
// Clean case: rename the old column
console.log('Migrating: Renaming tenor_api_key → gif_api_key in instance_settings');
db.exec('ALTER TABLE instance_settings RENAME COLUMN tenor_api_key TO gif_api_key');
} else if (hasTenor && hasGif) {
// Race condition: column-add loop created empty gif_api_key before rename could run.
// Copy the real key from tenor_api_key if gif_api_key is still NULL/empty.
const row = db.prepare('SELECT tenor_api_key, gif_api_key FROM instance_settings WHERE id = 1').get() as
{ tenor_api_key: string | null; gif_api_key: string | null } | undefined;
if (row && row.tenor_api_key && !row.gif_api_key) {
db.prepare('UPDATE instance_settings SET gif_api_key = ? WHERE id = 1').run(row.tenor_api_key);
console.log('Migrating: Copied API key from tenor_api_key → gif_api_key (fixing race condition)');
}
} else if (!hasTenor && !hasGif) {
// Fresh install or never had Tenor — just add the column
console.log('Migrating: Adding gif_api_key column to instance_settings');
db.exec('ALTER TABLE instance_settings ADD COLUMN gif_api_key TEXT');
}
// !hasTenor && hasGif → already correct, no-op
}
/** Add database indexes on FK columns to prevent full table scans */