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:
@@ -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 */
|
||||
|
||||
@@ -70,6 +70,7 @@ export const messages = sqliteTable('messages', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
stickerId: text('sticker_id'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
@@ -112,6 +113,7 @@ export const dmMessages = sqliteTable('dm_messages', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
stickerId: text('sticker_id'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
@@ -213,6 +215,30 @@ export const userSpaceLayout = sqliteTable('user_space_layout', {
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const stickerPacks = sqliteTable('sticker_packs', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
createdBy: text('created_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const stickers = sqliteTable('stickers', {
|
||||
id: text('id').primaryKey(),
|
||||
packId: text('pack_id').notNull().references(() => stickerPacks.id, { onDelete: 'cascade' }),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tags: text('tags').default(''),
|
||||
filename: text('filename').notNull(),
|
||||
mimetype: text('mimetype').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
uploadedBy: text('uploaded_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const instanceSettings = sqliteTable('instance_settings', {
|
||||
id: integer('id').primaryKey().default(1),
|
||||
instanceName: text('instance_name').default('Backspace'),
|
||||
@@ -226,6 +252,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
maxResolution: integer('max_resolution').notNull().default(1080),
|
||||
maxFramerate: integer('max_framerate').notNull().default(60),
|
||||
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit
|
||||
gifApiKey: text('gif_api_key'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user