From 3de6e4a668d738f1465e2dfadbd1d363e8a09a34 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:04:37 +0100 Subject: [PATCH] feat: GIF search (Klipy), stickers, emoji picker, and bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/server/src/db/migrate.ts | 130 +++++- packages/server/src/db/schema.ts | 27 ++ packages/server/src/index.ts | 4 + packages/server/src/routes/dm.ts | 45 +- packages/server/src/routes/gif.ts | 181 ++++++++ packages/server/src/routes/messages.ts | 45 +- packages/server/src/routes/settings.ts | 19 +- packages/server/src/routes/stickers.ts | 414 ++++++++++++++++++ packages/server/src/ws/events.ts | 4 + packages/shared/src/types.ts | 52 +++ packages/web/package.json | 2 + packages/web/src/api/client.ts | 72 +++ .../web/src/components/chat/EmojiPicker.tsx | 39 ++ .../web/src/components/chat/GifPicker.tsx | 149 +++++++ .../web/src/components/chat/InputPopover.tsx | 156 +++++++ packages/web/src/components/chat/Message.tsx | 125 +++++- .../web/src/components/chat/MessageInput.tsx | 123 +++++- .../web/src/components/chat/StickerPicker.tsx | 140 ++++++ .../src/components/modals/SpaceSettings.tsx | 9 +- .../instanceSettingsPanels/GeneralPanel.tsx | 64 ++- .../spaceSettingsPanels/StickersPanel.tsx | 301 +++++++++++++ packages/web/src/hooks/useWebSocket.ts | 15 + packages/web/src/stores/chatStore.ts | 40 ++ packages/web/src/stores/settingsStore.ts | 12 + packages/web/src/styles/globals.css | 15 + pnpm-lock.yaml | 27 ++ 26 files changed, 2160 insertions(+), 50 deletions(-) create mode 100644 packages/server/src/routes/gif.ts create mode 100644 packages/server/src/routes/stickers.ts create mode 100644 packages/web/src/components/chat/EmojiPicker.tsx create mode 100644 packages/web/src/components/chat/GifPicker.tsx create mode 100644 packages/web/src/components/chat/InputPopover.tsx create mode 100644 packages/web/src/components/chat/StickerPicker.tsx create mode 100644 packages/web/src/components/modals/spaceSettingsPanels/StickersPanel.tsx diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index c2ae644d..18f4af98 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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 */ diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 206bb9dc..fee3ea46 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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(), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 39ed5ca1..0d83c474 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,6 +23,8 @@ import { instanceRoutes } from './routes/instance.js'; import { exploreRoutes } from './routes/explore.js'; import { searchRoutes } from './routes/search.js'; import { adminRoutes } from './routes/admin.js'; +import { gifRoutes } from './routes/gif.js'; +import { stickerRoutes } from './routes/stickers.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -91,6 +93,8 @@ async function main(): Promise { await app.register(exploreRoutes); await app.register(searchRoutes); await app.register(adminRoutes); + await app.register(gifRoutes); + await app.register(stickerRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index e25f0d49..c6d68ccb 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -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 { }, }, 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 { userId: request.userId, replyToId: replyToId || null, content: content?.trim() || null, + stickerId: stickerId || null, createdAt: now, }).run(); diff --git a/packages/server/src/routes/gif.ts b/packages/server/src/routes/gif.ts new file mode 100644 index 00000000..f3132f03 --- /dev/null +++ b/packages/server/src/routes/gif.ts @@ -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 { + // 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: '' }); + } + }); +} diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index f176fd2c..35475574 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -121,6 +121,28 @@ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSel return map; } +/** Hydrate a sticker by ID, returns null if not found */ +export function hydrateSticker(stickerId: string | null | undefined): any { + if (!stickerId) return null; + const db = getDb(); + const row = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get(); + if (!row) return null; + return { + 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, + }; +} + export function buildMessageWithUser( message: typeof schema.messages.$inferSelect, user: typeof schema.users.$inferSelect, @@ -128,6 +150,7 @@ export function buildMessageWithUser( reactions: Reaction[] = [], replyTo: MessageWithUser | null = null, ): MessageWithUser { + const stickerId = (message as any).stickerId ?? null; return { id: message.id, channelId: message.channelId, @@ -149,6 +172,8 @@ export function buildMessageWithUser( })), reactions, replyTo, + stickerId, + sticker: hydrateSticker(stickerId), }; } @@ -252,7 +277,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { }, }, async (request, reply) => { const { id } = request.params; - const { content, attachments: attachmentIds, replyToId } = request.body; + const { content, attachments: attachmentIds, replyToId, stickerId } = request.body; const spaceId = getChannelSpaceId(id); if (!spaceId) { @@ -268,9 +293,20 @@ export async function messageRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', 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 }); + } + + // 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 }); + } } if (content && content.length > MAX_MESSAGE_LENGTH) { @@ -303,6 +339,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { userId: request.userId, replyToId: replyToId || null, content: content?.trim() || null, + stickerId: stickerId || null, createdAt: now, }).run(); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 46e96be4..04edb836 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -131,10 +131,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 }); } + const gifKey = row.gifApiKey as string | null; const response: InstanceAdminSettings = { instanceName: row.instanceName ?? 'Backspace', registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen, discoveryEnabled: row.discoveryEnabled === 1, + gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined, + gifEnabled: !!gifKey, }; return reply.code(200).send(response); @@ -145,7 +148,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const db = getDb(); const body = request.body; - const updateData: Record = { updatedAt: Date.now() }; + const updateData: Record = { updatedAt: Date.now() }; if (body.instanceName !== undefined) { if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) { @@ -162,6 +165,17 @@ export async function settingsRoutes(app: FastifyInstance): Promise { updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; } + if (body.gifApiKey !== undefined) { + // Skip masked placeholder values — the GET endpoint returns '****xxxx' for security, + // so if the client sends that back unchanged, don't corrupt the real key + if (typeof body.gifApiKey === 'string' && body.gifApiKey.startsWith('****')) { + // Masked value — ignore, keep existing key + } else { + // Allow empty string to clear the key + updateData.gifApiKey = body.gifApiKey ? body.gifApiKey.trim() : null; + } + } + db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run(); const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); @@ -169,10 +183,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 }); } + const updatedGifKey = updatedRow.gifApiKey as string | null; const response: InstanceAdminSettings = { instanceName: updatedRow.instanceName ?? 'Backspace', registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen, discoveryEnabled: updatedRow.discoveryEnabled === 1, + gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined, + gifEnabled: !!updatedGifKey, }; return reply.code(200).send(response); diff --git a/packages/server/src/routes/stickers.ts b/packages/server/src/routes/stickers.ts new file mode 100644 index 00000000..7cb5326d --- /dev/null +++ b/packages/server/src/routes/stickers.ts @@ -0,0 +1,414 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { hasPermission, PermissionBits, isMember } from '../utils/permissions.js'; +import { connectionManager } from '../ws/handler.js'; +import { config } from '../config.js'; +import type { Sticker, StickerPack } from '@backspace/shared'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; + +const MAX_STICKER_SIZE = 500 * 1024; // 500KB +const MAX_STICKER_DIMENSION = 512; +const ALLOWED_STICKER_TYPES = ['image/png', 'image/webp', 'image/gif']; + +function getSpaceIdForPack(packId: string): string | null { + const db = getDb(); + const pack = db.select({ spaceId: schema.stickerPacks.spaceId }) + .from(schema.stickerPacks) + .where(eq(schema.stickerPacks.id, packId)) + .get(); + return pack?.spaceId ?? null; +} + +function getStickerSpaceId(stickerId: string): string | null { + const db = getDb(); + const sticker = db.select({ spaceId: schema.stickers.spaceId }) + .from(schema.stickers) + .where(eq(schema.stickers.id, stickerId)) + .get(); + return sticker?.spaceId ?? null; +} + +function packToResponse(pack: typeof schema.stickerPacks.$inferSelect, stickerRows: (typeof schema.stickers.$inferSelect)[]): StickerPack { + return { + id: pack.id, + spaceId: pack.spaceId, + name: pack.name, + description: pack.description, + createdBy: pack.createdBy, + createdAt: pack.createdAt, + stickers: stickerRows.map(stickerToResponse), + }; +} + +function stickerToResponse(s: typeof schema.stickers.$inferSelect): Sticker { + return { + id: s.id, + packId: s.packId, + spaceId: s.spaceId, + name: s.name, + tags: s.tags ?? '', + filename: s.filename, + mimetype: s.mimetype, + size: s.size, + width: s.width, + height: s.height, + uploadedBy: s.uploadedBy, + createdAt: s.createdAt, + }; +} + +export async function stickerRoutes(app: FastifyInstance): Promise { + // GET /api/spaces/:id/sticker-packs — list all packs in a space + app.get<{ Params: { id: string } }>('/api/spaces/:id/sticker-packs', { + preHandler: authenticate, + }, async (request, reply) => { + const spaceId = request.params.id; + if (!isMember(spaceId, request.userId)) { + return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 }); + } + + const db = getDb(); + const packs = db.select().from(schema.stickerPacks) + .where(eq(schema.stickerPacks.spaceId, spaceId)) + .all(); + + const packIds = packs.map(p => p.id); + const allStickers = packIds.length > 0 + ? db.select().from(schema.stickers).where(inArray(schema.stickers.packId, packIds)).all() + : []; + + const stickersByPack = new Map(); + for (const s of allStickers) { + const arr = stickersByPack.get(s.packId) ?? []; + arr.push(s); + stickersByPack.set(s.packId, arr); + } + + return reply.code(200).send({ + packs: packs.map(p => packToResponse(p, stickersByPack.get(p.id) ?? [])), + }); + }); + + // POST /api/spaces/:id/sticker-packs — create a pack + app.post<{ Params: { id: string }; Body: { name: string; description?: string } }>('/api/spaces/:id/sticker-packs', { + preHandler: authenticate, + }, async (request, reply) => { + const spaceId = request.params.id; + if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) { + return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); + } + + const { name, description } = request.body; + if (!name || typeof name !== 'string' || name.trim().length === 0 || name.trim().length > 32) { + return reply.code(400).send({ error: 'Pack name must be 1-32 characters', statusCode: 400 }); + } + + const db = getDb(); + const packId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.stickerPacks).values({ + id: packId, + spaceId, + name: name.trim(), + description: description?.trim() || null, + createdBy: request.userId, + createdAt: now, + }).run(); + + const pack = db.select().from(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).get()!; + const response = packToResponse(pack, []); + + connectionManager.sendToSpace(spaceId, { + type: 'sticker_pack_created', + spaceId, + pack: response, + }); + + return reply.code(201).send(response); + }); + + // PATCH /api/spaces/:id/sticker-packs/:packId — update a pack + app.patch<{ Params: { id: string; packId: string }; Body: { name?: string; description?: string } }>('/api/spaces/:id/sticker-packs/:packId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id: spaceId, packId } = request.params; + if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) { + return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); + } + + const db = getDb(); + const pack = db.select().from(schema.stickerPacks).where( + and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId)) + ).get(); + if (!pack) { + return reply.code(404).send({ error: 'Pack not found', statusCode: 404 }); + } + + const updates: Record = {}; + if (request.body.name !== undefined) { + if (typeof request.body.name !== 'string' || request.body.name.trim().length === 0 || request.body.name.trim().length > 32) { + return reply.code(400).send({ error: 'Pack name must be 1-32 characters', statusCode: 400 }); + } + updates.name = request.body.name.trim(); + } + if (request.body.description !== undefined) { + updates.description = request.body.description?.trim() || null; + } + + if (Object.keys(updates).length > 0) { + db.update(schema.stickerPacks).set(updates).where(eq(schema.stickerPacks.id, packId)).run(); + } + + const updatedPack = db.select().from(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).get()!; + const stickers = db.select().from(schema.stickers).where(eq(schema.stickers.packId, packId)).all(); + const response = packToResponse(updatedPack, stickers); + + connectionManager.sendToSpace(spaceId, { + type: 'sticker_pack_updated', + spaceId, + pack: response, + }); + + return reply.code(200).send(response); + }); + + // DELETE /api/spaces/:id/sticker-packs/:packId — delete a pack (cascades stickers) + app.delete<{ Params: { id: string; packId: string } }>('/api/spaces/:id/sticker-packs/:packId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id: spaceId, packId } = request.params; + if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) { + return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); + } + + const db = getDb(); + const pack = db.select().from(schema.stickerPacks).where( + and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId)) + ).get(); + if (!pack) { + return reply.code(404).send({ error: 'Pack not found', statusCode: 404 }); + } + + // Get sticker files to clean up + const stickers = db.select().from(schema.stickers).where(eq(schema.stickers.packId, packId)).all(); + + // Delete pack (cascade deletes stickers) + db.delete(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).run(); + + // Clean up files + for (const s of stickers) { + const filePath = path.join(config.uploadDir, path.basename(s.filename)); + try { fs.unlinkSync(filePath); } catch {} + } + + connectionManager.sendToSpace(spaceId, { + type: 'sticker_pack_deleted', + spaceId, + packId, + }); + + return reply.code(200).send({ success: true }); + }); + + // POST /api/spaces/:id/sticker-packs/:packId/stickers — upload a sticker + app.post<{ Params: { id: string; packId: string } }>('/api/spaces/:id/sticker-packs/:packId/stickers', { + preHandler: authenticate, + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', + keyGenerator: (request: any) => request.userId || request.ip, + }, + }, + }, async (request, reply) => { + const { id: spaceId, packId } = request.params; + if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) { + return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); + } + + const db = getDb(); + const pack = db.select().from(schema.stickerPacks).where( + and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId)) + ).get(); + if (!pack) { + return reply.code(404).send({ error: 'Pack not found', statusCode: 404 }); + } + + const data = await request.file(); + if (!data) { + return reply.code(400).send({ error: 'No file uploaded', statusCode: 400 }); + } + + const chunks: Buffer[] = []; + for await (const chunk of data.file) { + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + + if (buffer.length > MAX_STICKER_SIZE) { + return reply.code(400).send({ error: 'Sticker must be 500KB or less', statusCode: 400 }); + } + + if (!ALLOWED_STICKER_TYPES.includes(data.mimetype)) { + return reply.code(400).send({ error: 'Sticker must be PNG, WebP, or GIF', statusCode: 400 }); + } + + // Read name and tags from multipart fields + const fields = data.fields as Record; + const name = (fields.name?.value || 'sticker').toString().trim().slice(0, 32); + const tags = (fields.tags?.value || '').toString().trim().slice(0, 100); + + // Read dimensions with sharp, auto-downscale if oversized + let width: number | null = null; + let height: number | null = null; + let finalBuffer = buffer; + try { + const sharp = (await import('sharp')).default; + const meta = await sharp(buffer).metadata(); + width = meta.width ?? null; + height = meta.height ?? null; + + if ((width && width > MAX_STICKER_DIMENSION) || (height && height > MAX_STICKER_DIMENSION)) { + // Auto-downscale to fit within 512x512, preserving aspect ratio and GIF animation + const isAnimated = data.mimetype === 'image/gif'; + const resized = sharp(buffer, isAnimated ? { animated: true } : undefined) + .resize({ width: MAX_STICKER_DIMENSION, height: MAX_STICKER_DIMENSION, fit: 'inside' }); + finalBuffer = Buffer.from(await resized.toBuffer()); + const resizedMeta = await sharp(finalBuffer, isAnimated ? { animated: true } : undefined).metadata(); + width = resizedMeta.width ?? null; + height = resizedMeta.height ?? null; + console.log(`[Stickers] Auto-downscaled sticker from ${meta.width}x${meta.height} to ${width}x${height}`); + } + } catch { + // Can't read dimensions — allow anyway + } + + // Save file + const ext = data.mimetype === 'image/png' ? '.png' : data.mimetype === 'image/webp' ? '.webp' : '.gif'; + const filename = `sticker_${crypto.randomBytes(16).toString('hex')}${ext}`; + const filePath = path.join(config.uploadDir, filename); + fs.writeFileSync(filePath, finalBuffer); + + const stickerId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.stickers).values({ + id: stickerId, + packId, + spaceId, + name, + tags, + filename, + mimetype: data.mimetype, + size: finalBuffer.length, + width, + height, + uploadedBy: request.userId, + createdAt: now, + }).run(); + + const sticker = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get()!; + const response = stickerToResponse(sticker); + + connectionManager.sendToSpace(spaceId, { + type: 'sticker_created', + spaceId, + sticker: response, + }); + + return reply.code(201).send(response); + }); + + // DELETE /api/stickers/:id — delete a sticker + app.delete<{ Params: { id: string } }>('/api/stickers/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const stickerId = request.params.id; + const spaceId = getStickerSpaceId(stickerId); + if (!spaceId) { + return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 }); + } + if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) { + return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); + } + + const db = getDb(); + const sticker = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get(); + if (!sticker) { + return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 }); + } + + db.delete(schema.stickers).where(eq(schema.stickers.id, stickerId)).run(); + + // Clean up file + const filePath = path.join(config.uploadDir, path.basename(sticker.filename)); + try { fs.unlinkSync(filePath); } catch {} + + connectionManager.sendToSpace(spaceId, { + type: 'sticker_deleted', + spaceId, + stickerId, + }); + + return reply.code(200).send({ success: true }); + }); + + // GET /api/users/@me/stickers — all stickers from joined spaces + app.get('/api/users/@me/stickers', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + // Get all spaces the user is a member of + const memberships = db.select({ spaceId: schema.spaceMembers.spaceId }) + .from(schema.spaceMembers) + .where(eq(schema.spaceMembers.userId, request.userId)) + .all(); + + const spaceIds = memberships.map(m => m.spaceId); + if (spaceIds.length === 0) { + return reply.code(200).send({ packs: [] }); + } + + // Get all packs from those spaces + const packs = db.select().from(schema.stickerPacks) + .where(inArray(schema.stickerPacks.spaceId, spaceIds)) + .all(); + + if (packs.length === 0) { + return reply.code(200).send({ packs: [] }); + } + + const packIds = packs.map(p => p.id); + const allStickers = db.select().from(schema.stickers) + .where(inArray(schema.stickers.packId, packIds)) + .all(); + + const stickersByPack = new Map(); + for (const s of allStickers) { + const arr = stickersByPack.get(s.packId) ?? []; + arr.push(s); + stickersByPack.set(s.packId, arr); + } + + // Get space names for labeling + const spaceRows = db.select({ id: schema.spaces.id, name: schema.spaces.name }) + .from(schema.spaces) + .where(inArray(schema.spaces.id, spaceIds)) + .all(); + const spaceNameMap = new Map(spaceRows.map(s => [s.id, s.name])); + + const result: StickerPack[] = packs.map(p => ({ + ...packToResponse(p, stickersByPack.get(p.id) ?? []), + spaceName: spaceNameMap.get(p.spaceId) ?? 'Unknown', + })); + + return reply.code(200).send({ packs: result }); + }); +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index eaa5df5d..c4a27440 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -8,6 +8,7 @@ import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { hydrateSticker } from '../routes/messages.js'; /** * Re-evaluate SPEAK permission for all participants in voice channels @@ -99,6 +100,7 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { } } + const stickerId = (message as any).stickerId ?? null; return { id: message.id, channelId: message.channelId, @@ -111,6 +113,8 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { attachments, reactions, replyTo, + stickerId, + sticker: hydrateSticker(stickerId), }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 25e7f84a..fdabcef2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -192,6 +192,8 @@ export interface MessageWithUser extends Message { attachments: Attachment[]; reactions: Reaction[]; replyTo?: MessageWithUser | null; + stickerId?: string | null; + sticker?: Sticker | null; } // ─── Reaction Types ──────────────────────────────────────────────────────── @@ -255,6 +257,8 @@ export interface DmMessageWithUser extends DmMessage { attachments: Attachment[]; reactions: Reaction[]; replyTo?: DmMessageWithUser | null; + stickerId?: string | null; + sticker?: Sticker | null; } // ─── WebSocket Event Types ────────────────────────────────────────────────── @@ -338,6 +342,11 @@ export type ServerEvent = | { type: 'category_deleted'; categoryId: string; spaceId: string } | { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] } | { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number } + | { type: 'sticker_pack_created'; spaceId: string; pack: StickerPack } + | { type: 'sticker_pack_updated'; spaceId: string; pack: StickerPack } + | { type: 'sticker_pack_deleted'; spaceId: string; packId: string } + | { type: 'sticker_created'; spaceId: string; sticker: Sticker } + | { type: 'sticker_deleted'; spaceId: string; stickerId: string } | { type: 'pong' } | { type: 'error'; message: string }; @@ -417,6 +426,7 @@ export interface CreateMessageRequest { content: string; attachments?: string[]; replyToId?: string; + stickerId?: string; } export interface UpdateMessageRequest { @@ -448,6 +458,7 @@ export interface CreateDmMessageRequest { content?: string; attachments?: string[]; replyToId?: string; + stickerId?: string; } export interface PaginatedQuery { @@ -517,12 +528,53 @@ export interface UpdateFriendRequest { status: 'accepted' | 'declined'; } +// ─── GIF Types ────────────────────────────────────────────────────────────── + +export interface GifResult { + id: string; + title: string; + previewUrl: string; + url: string; + width: number; + height: number; +} + +// ─── Sticker Types ────────────────────────────────────────────────────────── + +export interface Sticker { + id: string; + packId: string; + spaceId: string; + name: string; + tags: string; + filename: string; + mimetype: string; + size: number; + width: number | null; + height: number | null; + uploadedBy: string; + createdAt: number; +} + +export interface StickerPack { + id: string; + spaceId: string; + name: string; + description: string | null; + createdBy: string; + createdAt: number; + stickers: Sticker[]; + spaceName?: string; +} + // ─── Instance Settings Types ──────────────────────────────────────────────── export interface InstanceAdminSettings { instanceName: string; registrationOpen: boolean; discoveryEnabled: boolean; + gifApiKey?: string; + gifEnabled?: boolean; } export interface InstanceStreamingLimits { diff --git a/packages/web/package.json b/packages/web/package.json index e4ef22d9..e7df2c70 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@backspace/shared": "workspace:*", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@livekit/components-react": "^2.7.4", "@sapphi-red/web-noise-suppressor": "^0.3.5", "livekit-client": "^2.9.0", diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index d3812307..c280b8ee 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -46,6 +46,9 @@ import type { SpaceLayoutItem, SpaceFolder, InvitePreview, + GifResult, + StickerPack, + Sticker, } from '@backspace/shared'; export class RateLimitError extends Error { @@ -187,6 +190,22 @@ export class BackspaceApiClient { myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>; }; + readonly gif: { + trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>; + search: (q: string, limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>; + enabled: () => Promise<{ enabled: boolean }>; + }; + + readonly stickers: { + getPacks: (spaceId: string) => Promise<{ packs: StickerPack[] }>; + createPack: (spaceId: string, data: { name: string; description?: string }) => Promise; + updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => Promise; + deletePack: (spaceId: string, packId: string) => Promise<{ success: boolean }>; + uploadSticker: (spaceId: string, packId: string, file: File, name: string, tags?: string) => Promise; + deleteSticker: (stickerId: string) => Promise<{ success: boolean }>; + myStickers: () => Promise<{ packs: StickerPack[] }>; + }; + readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; @@ -504,6 +523,59 @@ export class BackspaceApiClient { }, }; + this.gif = { + trending: (limit = 30, pos?: string) => { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + if (pos) params.set('pos', pos); + return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`); + }, + search: (q: string, limit = 30, pos?: string) => { + const params = new URLSearchParams(); + params.set('q', q); + params.set('limit', String(limit)); + if (pos) params.set('pos', pos); + return request<{ results: GifResult[]; next: string }>('GET', `/gif/search?${params}`); + }, + enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'), + }; + + this.stickers = { + getPacks: (spaceId: string) => + request<{ packs: StickerPack[] }>('GET', `/spaces/${spaceId}/sticker-packs`), + createPack: (spaceId: string, data: { name: string; description?: string }) => + request('POST', `/spaces/${spaceId}/sticker-packs`, data), + updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => + request('PATCH', `/spaces/${spaceId}/sticker-packs/${packId}`, data), + deletePack: (spaceId: string, packId: string) => + request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/sticker-packs/${packId}`), + uploadSticker: async (spaceId: string, packId: string, file: File, name: string, tags = '') => { + const formData = new FormData(); + formData.append('file', file); + formData.append('name', name); + formData.append('tags', tags); + + const token = getToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const response = await fetch(`${baseUrl}/spaces/${spaceId}/sticker-packs/${packId}/stickers`, { + method: 'POST', + headers, + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Upload failed' })); + throw new Error((error as { error: string }).error || `HTTP ${response.status}`); + } + return response.json() as Promise; + }, + deleteSticker: (stickerId: string) => + request<{ success: boolean }>('DELETE', `/stickers/${stickerId}`), + myStickers: () => + request<{ packs: StickerPack[] }>('GET', '/users/@me/stickers'), + }; + this.admin = { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), diff --git a/packages/web/src/components/chat/EmojiPicker.tsx b/packages/web/src/components/chat/EmojiPicker.tsx new file mode 100644 index 00000000..054a29ab --- /dev/null +++ b/packages/web/src/components/chat/EmojiPicker.tsx @@ -0,0 +1,39 @@ +import React, { useRef, useEffect } from 'react'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; + +interface EmojiPickerProps { + onEmojiSelect: (emoji: { native: string }) => void; +} + +export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) { + const containerRef = useRef(null); + + // Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input) + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const stop = (e: KeyboardEvent) => e.stopPropagation(); + el.addEventListener('keydown', stop); + return () => el.removeEventListener('keydown', stop); + }, []); + + return ( +
+ +
+ ); +} diff --git a/packages/web/src/components/chat/GifPicker.tsx b/packages/web/src/components/chat/GifPicker.tsx new file mode 100644 index 00000000..44f25719 --- /dev/null +++ b/packages/web/src/components/chat/GifPicker.tsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { api } from '../../api/client'; +import type { GifResult } from '@backspace/shared'; + +interface GifPickerProps { + onGifSelect: (url: string) => void; +} + +export function GifPicker({ onGifSelect }: GifPickerProps) { + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(true); + const [nextPos, setNextPos] = useState(''); + const [loadingMore, setLoadingMore] = useState(false); + const scrollRef = useRef(null); + const debounceRef = useRef>(); + + // Debounce search query + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedQuery(query); + }, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + // Fetch results when debounced query changes + useEffect(() => { + let cancelled = false; + setLoading(true); + setResults([]); + setNextPos(''); + + const fetchGifs = async () => { + try { + const data = debouncedQuery.trim() + ? await api.gif.search(debouncedQuery.trim(), 30) + : await api.gif.trending(30); + if (!cancelled) { + setResults(data.results); + setNextPos(data.next); + setLoading(false); + } + } catch { + if (!cancelled) setLoading(false); + } + }; + fetchGifs(); + return () => { cancelled = true; }; + }, [debouncedQuery]); + + // Infinite scroll + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || loadingMore || !nextPos) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { + setLoadingMore(true); + const fetchMore = async () => { + try { + const data = debouncedQuery.trim() + ? await api.gif.search(debouncedQuery.trim(), 30, nextPos) + : await api.gif.trending(30, nextPos); + setResults((prev) => [...prev, ...data.results]); + setNextPos(data.next); + } finally { + setLoadingMore(false); + } + }; + fetchMore(); + } + }, [loadingMore, nextPos, debouncedQuery]); + + // Prevent keyboard events from bubbling + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + }; + + return ( +
+ {/* Search */} +
+ setQuery(e.target.value)} + placeholder="Search GIFs" + className="input-search w-full" + autoFocus + /> +
+ + {/* Results grid */} +
+ {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : results.length === 0 ? ( +
+ {debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'} +
+ ) : ( +
+ {results.map((gif) => ( + + ))} +
+ )} + {loadingMore && ( +
+
+
+ )} +
+ + {/* Attribution */} +
+ Powered by Klipy +
+
+ ); +} diff --git a/packages/web/src/components/chat/InputPopover.tsx b/packages/web/src/components/chat/InputPopover.tsx new file mode 100644 index 00000000..ea839bd6 --- /dev/null +++ b/packages/web/src/components/chat/InputPopover.tsx @@ -0,0 +1,156 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { EmojiPicker } from './EmojiPicker'; +import { GifPicker } from './GifPicker'; +import { StickerPicker } from './StickerPicker'; +import type { Sticker } from '@backspace/shared'; + +export type InputPopoverTab = 'emoji' | 'gif' | 'stickers'; + +interface InputPopoverProps { + activeTab: InputPopoverTab; + onClose: () => void; + onEmojiSelect: (emoji: { native: string }) => void; + onGifSelect: (url: string) => void; + onStickerSelect: (sticker: Sticker) => void; + anchorRef: React.RefObject; + gifEnabled: boolean; + stickersEnabled: boolean; + onTabChange: (tab: InputPopoverTab) => void; +} + +export function InputPopover({ + activeTab, + onClose, + onEmojiSelect, + onGifSelect, + onStickerSelect, + anchorRef, + gifEnabled, + stickersEnabled, + onTabChange, +}: InputPopoverProps) { + const floatingRef = useRef(null); + + // Position above the anchor + const updatePosition = useCallback(() => { + const anchor = anchorRef.current; + const floating = floatingRef.current; + if (!anchor || !floating) return; + + const anchorRect = anchor.getBoundingClientRect(); + const floatingRect = floating.getBoundingClientRect(); + const vw = window.innerWidth; + + let left = anchorRect.right - floatingRect.width; + let top = anchorRect.top - floatingRect.height - 8; + + // Flip below if no room above + if (top < 8) { + top = anchorRect.bottom + 8; + } + + // Clamp horizontal + left = Math.max(8, Math.min(left, vw - floatingRect.width - 8)); + + floating.style.top = `${top}px`; + floating.style.left = `${left}px`; + }, [anchorRef]); + + useEffect(() => { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [updatePosition, activeTab]); + + // Re-position after the picker renders (it may change height) + useEffect(() => { + const frame = requestAnimationFrame(updatePosition); + return () => cancelAnimationFrame(frame); + }, [activeTab, updatePosition]); + + // Click outside to close + useEffect(() => { + const handler = (e: MouseEvent) => { + const floating = floatingRef.current; + const anchor = anchorRef.current; + if (!floating) return; + if (floating.contains(e.target as Node)) return; + if (anchor && anchor.contains(e.target as Node)) return; + onClose(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose, anchorRef]); + + // Escape to close + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + const availableTabs: { key: InputPopoverTab; label: string }[] = [ + { key: 'emoji', label: 'Emoji' }, + ]; + if (gifEnabled) { + availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' }); + } + if (stickersEnabled) { + availableTabs.push({ key: 'stickers', label: 'Stickers' }); + } + + const showTabs = availableTabs.length > 1; + + return createPortal( +
+
+ {/* Tab bar */} + {showTabs && ( +
+ {availableTabs.map((t) => ( + + ))} +
+ )} + + {/* Content */} +
+ {activeTab === 'emoji' && ( + + )} + {activeTab === 'gif' && gifEnabled && ( + + )} + {activeTab === 'stickers' && stickersEnabled && ( + + )} +
+
+
, + document.body, + ); +} diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 40d1d80a..fe0e05b5 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; import type { MessageWithUser } from '@backspace/shared'; import { MarkdownRenderer } from './MarkdownRenderer'; import { Avatar } from '../ui/Avatar'; @@ -9,6 +10,7 @@ import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { Embed } from './Embed'; import { Username } from '../ui/Username'; +import { EmojiPicker } from './EmojiPicker'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; @@ -37,10 +39,21 @@ function formatHoverTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } +const GIF_URL_REGEX = /^https:\/\/(?:media\.tenor\.com|media\.klipy\.com)\/.+$/; + +function isGifOnlyMessage(content: string | null): boolean { + if (!content) return false; + const trimmed = content.trim(); + return GIF_URL_REGEX.test(trimmed); +} + export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content ?? ''); const [isHovered, setIsHovered] = useState(false); + const [showReactionPicker, setShowReactionPicker] = useState(false); + const reactionPickerBtnRef = useRef(null); + const reactionPickerRef = useRef(null); const currentUser = useAuthStore((s) => s.user); const editMessage = useChatStore((s) => s.editMessage); const deleteMessage = useChatStore((s) => s.deleteMessage); @@ -83,8 +96,39 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { return acc; }, {} as Record); + const isGifOnly = isGifOnlyMessage(message.content); + const isSticker = !!(message.stickerId || (message as any).sticker); + const stickerData = (message as any).sticker ?? null; + const urlRegex = /(https?:\/\/[^\s]+)/g; - const firstUrl = message.content?.match(urlRegex)?.[0]; + const firstUrl = isGifOnly ? null : message.content?.match(urlRegex)?.[0]; + + // Close reaction picker on outside click + useEffect(() => { + if (!showReactionPicker) return; + const handler = (e: MouseEvent) => { + if (reactionPickerRef.current?.contains(e.target as Node)) return; + if (reactionPickerBtnRef.current?.contains(e.target as Node)) return; + setShowReactionPicker(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showReactionPicker]); + + // Close reaction picker on Escape + useEffect(() => { + if (!showReactionPicker) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowReactionPicker(false); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [showReactionPicker]); + + const handleReactionEmojiSelect = useCallback((emoji: { native: string }) => { + addReaction(message.id, emoji.native); + setShowReactionPicker(false); + }, [addReaction, message.id]); const handleUsernameClick = (e: React.MouseEvent) => { if (!message.user) return; @@ -245,17 +289,42 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
) : (
- {message.content && ( -
- - {message.editedAt && ( - (edited) - )} + {/* Sticker rendering */} + {isSticker && stickerData ? ( +
+ {stickerData.name}
- )} + ) : isSticker ? ( +
Sticker unavailable
+ ) : isGifOnly ? ( +
+ GIF +
+ ) : ( + <> + {message.content && ( +
+ + {message.editedAt && ( + (edited) + )} +
+ )} - {/* Embeds */} - {!isEditing && firstUrl && } + {/* Embeds */} + {!isEditing && firstUrl && } + + )} {/* Attachments */} {message.attachments && message.attachments.length > 0 && ( @@ -327,8 +396,28 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { )}
+ {/* Reaction emoji picker */} + {showReactionPicker && canAddReactions && reactionPickerBtnRef.current && createPortal( +
+
+ +
+
, + document.body, + )} + {/* Action buttons on hover */} - {isHovered && !isEditing && ( + {(isHovered || showReactionPicker) && !isEditing && (
{canAddReactions && (
@@ -341,6 +430,18 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {emoji} ))} +
)} + {gifEnabled && ( + + )} {/* Sticker button */} - {/* Emoji button */} - + + {/* Send button — appears when there's content to send */} + {canSend && ( + + )}
diff --git a/packages/web/src/components/chat/StickerPicker.tsx b/packages/web/src/components/chat/StickerPicker.tsx new file mode 100644 index 00000000..857018bc --- /dev/null +++ b/packages/web/src/components/chat/StickerPicker.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { api } from '../../api/client'; +import type { Sticker, StickerPack } from '@backspace/shared'; + +interface StickerPickerProps { + onStickerSelect: (sticker: Sticker) => void; +} + +interface StickerCache { + packs: StickerPack[]; + fetchedAt: number; +} + +let stickerCache: StickerCache | null = null; +const CACHE_TTL = 60_000; // 60s + +export function StickerPicker({ onStickerSelect }: StickerPickerProps) { + const [packs, setPacks] = useState(stickerCache?.packs ?? []); + const [loading, setLoading] = useState(!stickerCache || Date.now() - stickerCache.fetchedAt > CACHE_TTL); + const [query, setQuery] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + if (stickerCache && Date.now() - stickerCache.fetchedAt <= CACHE_TTL) { + setPacks(stickerCache.packs); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + api.stickers.myStickers() + .then((data) => { + if (cancelled) return; + stickerCache = { packs: data.packs, fetchedAt: Date.now() }; + setPacks(data.packs); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + const filteredPacks = query.trim() + ? packs + .map((pack) => ({ + ...pack, + stickers: pack.stickers.filter( + (s) => + s.name.toLowerCase().includes(query.toLowerCase()) || + s.tags.toLowerCase().includes(query.toLowerCase()), + ), + })) + .filter((pack) => pack.stickers.length > 0) + : packs; + + const totalStickers = packs.reduce((sum, p) => sum + p.stickers.length, 0); + + // Prevent keyboard events from bubbling + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + }; + + const getStickerUrl = useCallback((sticker: Sticker) => { + const filename = sticker.filename; + if (filename.startsWith('http') || filename.startsWith('/')) return filename; + return `/api/uploads/${filename}`; + }, []); + + return ( +
+ {/* Search */} +
+ setQuery(e.target.value)} + placeholder="Search stickers" + className="input-search w-full" + autoFocus + /> +
+ + {/* Results */} +
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ) : totalStickers === 0 ? ( +
+
No stickers available
+
+ Space admins can add sticker packs in Space Settings. +
+
+ ) : filteredPacks.length === 0 ? ( +
+ No stickers matching "{query}" +
+ ) : ( +
+ {filteredPacks.map((pack) => ( +
+
+ {pack.name} +
+
+ {pack.stickers.map((sticker) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} + +/** Invalidate the sticker cache (called when WS events indicate sticker changes) */ +export function invalidateStickerCache(): void { + stickerCache = null; +} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index 721f0e4a..e3d28afe 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -10,6 +10,7 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; import { BansPanel } from './spaceSettingsPanels/BansPanel'; +import { StickersPanel } from './spaceSettingsPanels/StickersPanel'; import type { SpaceVisibility, JoinRequest } from '@backspace/shared'; function DiscoveryPanel({ spaceId }: { spaceId: string }) { @@ -270,7 +271,7 @@ export function SpaceSettingsModal() { const spaces = useSpaceStore((s) => s.spaces); const spacePermissions = useSpaceStore((s) => s.spacePermissions); - const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview'); + const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview'); const isOpen = activeModal === 'spaceSettings'; const space = spaces.find(s => s.id === currentSpaceId); @@ -300,6 +301,11 @@ export function SpaceSettingsModal() { Discovery )} + {canManageSpace && ( + + )} @@ -320,6 +326,7 @@ export function SpaceSettingsModal() {
{tab === 'overview' && } {tab === 'discovery' && canManageSpace && } + {tab === 'stickers' && canManageSpace && } {tab === 'members' && } {tab === 'roles' && canManageRoles && } {tab === 'bans' && canBanMembers && } diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx index ccae85dc..1f896ad7 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -11,21 +11,44 @@ export function GeneralPanel() { const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(''); const [saveSuccess, setSaveSuccess] = useState(false); + const [gifKeyDirty, setGifKeyDirty] = useState(false); + const [gifKeyDraft, setGifKeyDraft] = useState(''); useEffect(() => { - if (instanceSettings) setDraft({ ...instanceSettings }); + if (instanceSettings) { + setDraft({ ...instanceSettings }); + // Don't populate the input with the masked value — show empty field + setGifKeyDraft(''); + setGifKeyDirty(false); + } }, [instanceSettings]); if (!draft) return
Loading settings...
; - const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings); + const baseChanges = instanceSettings && draft + ? draft.instanceName !== instanceSettings.instanceName || + draft.registrationOpen !== instanceSettings.registrationOpen || + draft.discoveryEnabled !== instanceSettings.discoveryEnabled + : false; + const hasChanges = baseChanges || gifKeyDirty; const handleSave = async () => { setSaving(true); setSaveError(''); setSaveSuccess(false); try { - await updateInstanceSettings(draft); + const payload: Partial = { + instanceName: draft!.instanceName, + registrationOpen: draft!.registrationOpen, + discoveryEnabled: draft!.discoveryEnabled, + }; + // Only include gifApiKey when the user actually modified it + if (gifKeyDirty) { + payload.gifApiKey = gifKeyDraft; + } + await updateInstanceSettings(payload); + setGifKeyDirty(false); + setGifKeyDraft(''); setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 2000); } catch (err) { @@ -37,6 +60,8 @@ export function GeneralPanel() { const handleReset = () => { if (instanceSettings) setDraft({ ...instanceSettings }); + setGifKeyDirty(false); + setGifKeyDraft(''); setSaveError(''); }; @@ -90,6 +115,39 @@ export function GeneralPanel() {
+ {/* GIF Search */} +
+
GIF Search
+

+ Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal. +

+
+ { setGifKeyDraft(e.target.value); setGifKeyDirty(true); }} + placeholder={draft.gifEnabled ? 'Key saved — enter new key to replace' : 'Klipy API key'} + className="input-standard w-full" + autoComplete="off" + /> +
+ + {draft.gifEnabled ? 'Enabled' : 'Not configured'} + + {draft.gifEnabled && !gifKeyDirty && ( + + )} +
+
+
+ {/* Status messages */} {saveError && (
{saveError}
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/StickersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/StickersPanel.tsx new file mode 100644 index 00000000..9106a94c --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/StickersPanel.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect, useRef } from 'react'; +import { api } from '../../../api/client'; +import { ConfirmDialog } from '../../ui/ConfirmDialog'; +import type { StickerPack, Sticker } from '@backspace/shared'; + +interface StickersPanelProps { + spaceId: string; +} + +export function StickersPanel({ spaceId }: StickersPanelProps) { + const [packs, setPacks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Create pack state + const [newPackName, setNewPackName] = useState(''); + const [newPackDesc, setNewPackDesc] = useState(''); + const [creating, setCreating] = useState(false); + + // Upload sticker state + const [uploadPackId, setUploadPackId] = useState(null); + const [stickerName, setStickerName] = useState(''); + const [stickerTags, setStickerTags] = useState(''); + const [stickerFile, setStickerFile] = useState(null); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + // Delete confirmation + const [deleteTarget, setDeleteTarget] = useState<{ type: 'pack' | 'sticker'; id: string; name: string } | null>(null); + const [deleting, setDeleting] = useState(false); + + const fetchPacks = async () => { + try { + const { packs: data } = await api.stickers.getPacks(spaceId); + setPacks(data); + setLoading(false); + } catch { + setError('Failed to load sticker packs'); + setLoading(false); + } + }; + + useEffect(() => { + fetchPacks(); + }, [spaceId]); + + const handleCreatePack = async () => { + if (!newPackName.trim()) return; + setCreating(true); + setError(''); + try { + const pack = await api.stickers.createPack(spaceId, { + name: newPackName.trim(), + description: newPackDesc.trim() || undefined, + }); + setPacks((prev) => [...prev, { ...pack, stickers: [] }]); + setNewPackName(''); + setNewPackDesc(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create pack'); + } finally { + setCreating(false); + } + }; + + const handleUploadSticker = async () => { + if (!uploadPackId || !stickerFile || !stickerName.trim()) return; + setUploading(true); + setError(''); + try { + const sticker = await api.stickers.uploadSticker( + spaceId, + uploadPackId, + stickerFile, + stickerName.trim(), + stickerTags.trim(), + ); + setPacks((prev) => + prev.map((p) => + p.id === uploadPackId + ? { ...p, stickers: [...p.stickers, sticker] } + : p, + ), + ); + setStickerName(''); + setStickerTags(''); + setStickerFile(null); + setUploadPackId(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload sticker'); + } finally { + setUploading(false); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + setError(''); + try { + if (deleteTarget.type === 'pack') { + await api.stickers.deletePack(spaceId, deleteTarget.id); + setPacks((prev) => prev.filter((p) => p.id !== deleteTarget.id)); + } else { + await api.stickers.deleteSticker(deleteTarget.id); + setPacks((prev) => + prev.map((p) => ({ + ...p, + stickers: p.stickers.filter((s) => s.id !== deleteTarget.id), + })), + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete'); + } finally { + setDeleting(false); + setDeleteTarget(null); + } + }; + + const getStickerUrl = (sticker: Sticker) => { + if (sticker.filename.startsWith('http') || sticker.filename.startsWith('/')) + return sticker.filename; + return `/api/uploads/${sticker.filename}`; + }; + + if (loading) { + return
Loading sticker packs...
; + } + + return ( +
+
+ Manage sticker packs for this space. Members can use these stickers in messages. +
+ + {error && ( +
+ {error} +
+ )} + + {/* Create Pack */} +
+
+ Create Sticker Pack +
+
+ setNewPackName(e.target.value.slice(0, 32))} + placeholder="Pack name" + className="input-standard w-full" + /> + setNewPackDesc(e.target.value.slice(0, 100))} + placeholder="Description (optional)" + className="input-standard w-full" + /> + +
+
+ + {/* Existing Packs */} + {packs.length === 0 ? ( +
No sticker packs yet.
+ ) : ( +
+ {packs.map((pack) => ( +
+
+
+
{pack.name}
+ {pack.description && ( +
{pack.description}
+ )} +
+
+ + +
+
+ + {/* Upload form for this pack */} + {uploadPackId === pack.id && ( +
+
+ setStickerName(e.target.value.slice(0, 32))} + placeholder="Sticker name" + className="input-standard flex-1" + /> + setStickerTags(e.target.value.slice(0, 100))} + placeholder="Tags (optional)" + className="input-standard flex-1" + /> +
+
+ setStickerFile(e.target.files?.[0] ?? null)} + className="text-sm text-txt-secondary file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-interactive-hover file:text-txt-primary hover:file:bg-interactive-active" + /> + +
+
+ PNG, WebP, or GIF. Max 512x512px, 500KB. +
+
+ )} + + {/* Sticker grid */} + {pack.stickers.length > 0 && ( +
+ {pack.stickers.map((sticker) => ( +
+ {sticker.name} +
+ +
+
+ {sticker.name} +
+
+ ))} +
+ )} + + {pack.stickers.length === 0 && ( +
No stickers in this pack yet.
+ )} +
+ ))} +
+ )} + + {/* Delete confirmation */} + setDeleteTarget(null)} + title={`Delete ${deleteTarget?.type === 'pack' ? 'Sticker Pack' : 'Sticker'}`} + description={`Are you sure you want to delete "${deleteTarget?.name}"?${ + deleteTarget?.type === 'pack' ? ' All stickers in this pack will be deleted.' : '' + } Existing messages will show "Sticker unavailable".`} + confirmLabel={deleting ? 'Deleting...' : 'Delete'} + onConfirm={handleDelete} + variant="danger" + loading={deleting} + /> +
+ ); +} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f1589d40..5a2265d9 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -96,6 +96,7 @@ function handleEvent(origin: string, event: ServerEvent): void { setUser(event.user); useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false); useSettingsStore.getState().fetchStreamingLimits(); + useSettingsStore.getState().fetchGifEnabled(); } // Normalize asset URLs for remote origins before dispatching to stores @@ -754,6 +755,20 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + // ─── Sticker events (all origins) ──────────────────────────────────── + + case 'sticker_pack_created': + case 'sticker_pack_updated': + case 'sticker_pack_deleted': + case 'sticker_created': + case 'sticker_deleted': { + // Invalidate the sticker picker cache so next open fetches fresh data + import('../components/chat/StickerPicker').then(({ invalidateStickerCache }) => { + invalidateStickerCache(); + }); + break; + } + case 'pong': break; diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index c1bc211f..9bef7a9e 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -38,6 +38,7 @@ interface ChatState { clearAllMessages: () => void; loadMoreMessages: (channelId: string) => Promise; sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise; + sendStickerMessage: (channelId: string, stickerId: string) => Promise; editMessage: (messageId: string, content: string, channelId: string) => Promise; deleteMessage: (messageId: string, channelId: string) => Promise; addMessage: (channelId: string, message: MessageWithUser) => void; @@ -289,6 +290,45 @@ export const useChatStore = create((set, get) => ({ } }, + sendStickerMessage: async (channelId: string, stickerId: string) => { + const isDm = isDmChannel(channelId); + const currentUser = useAuthStore.getState().user; + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); + + // Optimistic message + const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + if (currentUser) { + const optimisticMessage: MessageWithUser = { + id: tempId, + channelId: isDm ? '' : channelId, + userId: currentUser.id, + content: null, + replyToId: null, + editedAt: null, + createdAt: Date.now(), + user: currentUser, + attachments: [], + reactions: [], + stickerId, + }; + if (isDm) { + (optimisticMessage as any).dmChannelId = channelId; + } + get().addMessage(channelId, optimisticMessage); + } + + try { + if (isDm) { + await client.dm.sendMessage(channelId, { stickerId }); + } else { + await client.channels.sendMessage(channelId, { content: '', stickerId }); + } + } catch { + get().removeMessage(tempId, channelId); + } + }, + editMessage: async (messageId: string, content: string, channelId: string) => { const isDm = isDmChannel(channelId); const origin = getChannelOrigin(channelId); diff --git a/packages/web/src/stores/settingsStore.ts b/packages/web/src/stores/settingsStore.ts index 61e4fa20..b199c9de 100644 --- a/packages/web/src/stores/settingsStore.ts +++ b/packages/web/src/stores/settingsStore.ts @@ -6,10 +6,12 @@ interface SettingsState { streamingLimits: InstanceStreamingLimits | null; instanceSettings: InstanceAdminSettings | null; isAdmin: boolean; + gifEnabled: boolean; fetchStreamingLimits: () => Promise; updateStreamingLimits: (limits: Partial) => Promise; fetchInstanceSettings: () => Promise; updateInstanceSettings: (data: Partial) => Promise; + fetchGifEnabled: () => Promise; setIsAdmin: (isAdmin: boolean) => void; } @@ -32,6 +34,7 @@ export const useSettingsStore = create((set) => ({ streamingLimits: null, instanceSettings: null, isAdmin: false, + gifEnabled: false, fetchStreamingLimits: async () => { try { @@ -70,5 +73,14 @@ export const useSettingsStore = create((set) => ({ } }, + fetchGifEnabled: async () => { + try { + const { enabled } = await api.gif.enabled(); + set({ gifEnabled: enabled }); + } catch { + set({ gifEnabled: false }); + } + }, + setIsAdmin: (isAdmin: boolean) => set({ isAdmin }), })); diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index ad6e3129..13552da7 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -292,6 +292,21 @@ } } +/* ── Emoji Mart Overrides ── */ +.emoji-picker-wrapper em-emoji-picker { + --em-rgb-background: 20, 20, 26; + --em-rgb-input: 17, 17, 24; + --em-rgb-color: 216, 216, 222; + --em-color-border: rgba(255, 255, 255, 0.07); + --em-color-border-over: rgba(255, 255, 255, 0.12); + --rgb-accent: 124, 108, 246; + --font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + border: none !important; + background: transparent !important; + width: 100% !important; + max-height: 400px; +} + /* Animations */ @keyframes fadeIn { from { opacity: 0; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cae9bb9..2cfffc09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,12 @@ importers: '@backspace/shared': specifier: workspace:* version: link:../shared + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1) '@livekit/components-react': specifier: ^2.7.4 version: 2.9.19(livekit-client@2.17.1(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1) @@ -368,6 +374,15 @@ packages: '@emnapi/runtime@1.9.0': resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + + '@emoji-mart/react@1.1.1': + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -2275,6 +2290,9 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4551,6 +4569,13 @@ snapshots: tslib: 2.8.1 optional: true + '@emoji-mart/data@1.2.1': {} + + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)': + dependencies: + emoji-mart: 5.6.0 + react: 18.3.1 + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -6177,6 +6202,8 @@ snapshots: transitivePeerDependencies: - supports-color + emoji-mart@5.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {}