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

- Add GIF search powered by Klipy API with correct response mapping
  (file.sm/hd tiers, not flat files structure)
- Add sticker system: packs, upload with auto-downscale, send in messages
- Add tabbed InputPopover with emoji, GIF, and sticker pickers
- Fix GIF API key migration race condition (column-add loop vs rename)
- Fix masked API key corruption on settings save (server + client guards)
- Fix sticker packs 403 (reversed isMember parameter order)
- Fix emoji picker not filling popover width (perLine 8→9, CSS 100%)
- Add error logging for Klipy API failures
This commit is contained in:
Jannis Braun
2026-03-15 02:04:37 +01:00
parent 7113f47b17
commit 3de6e4a668
26 changed files with 2160 additions and 50 deletions
+114 -16
View File
@@ -150,6 +150,20 @@ export function runMigrations(db: Database.Database): void {
columns: [
{ name: 'password_changed_at', type: 'INTEGER' },
]
},
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
// or it will race with the tenor_api_key → gif_api_key rename migration
{
name: 'messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
},
{
name: 'dm_messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
}
];
@@ -315,6 +329,38 @@ export function runMigrations(db: Database.Database): void {
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
migrateDmMessagesReplyToFk(db);
// ─── Ensure sticker tables exist ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS sticker_packs (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS stickers (
id TEXT PRIMARY KEY,
pack_id TEXT NOT NULL REFERENCES sticker_packs(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tags TEXT DEFAULT '',
filename TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
uploaded_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sticker_packs_space_id ON sticker_packs(space_id);
CREATE INDEX IF NOT EXISTS idx_stickers_pack_id ON stickers(pack_id);
CREATE INDEX IF NOT EXISTS idx_stickers_space_id ON stickers(space_id);
`);
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
migrateRenameGifApiKey(db);
// ─── Add indexes on FK columns for query performance ─────────────────────
migrateAddIndexes(db);
@@ -333,22 +379,74 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
if (tableInfo.sql.includes('REFERENCES dm_messages')) return;
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
// Check if sticker_id column exists (may have been added by column migration)
const dmCols = db.pragma('table_info(dm_messages)') as { name: string }[];
const hasStickerId = dmCols.some(c => c.name === 'sticker_id');
if (hasStickerId) {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
sticker_id TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, sticker_id, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
} else {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
}
}
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
function migrateRenameGifApiKey(db: Database.Database): void {
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
const hasTenor = cols.some(c => c.name === 'tenor_api_key');
const hasGif = cols.some(c => c.name === 'gif_api_key');
if (hasTenor && !hasGif) {
// Clean case: rename the old column
console.log('Migrating: Renaming tenor_api_key → gif_api_key in instance_settings');
db.exec('ALTER TABLE instance_settings RENAME COLUMN tenor_api_key TO gif_api_key');
} else if (hasTenor && hasGif) {
// Race condition: column-add loop created empty gif_api_key before rename could run.
// Copy the real key from tenor_api_key if gif_api_key is still NULL/empty.
const row = db.prepare('SELECT tenor_api_key, gif_api_key FROM instance_settings WHERE id = 1').get() as
{ tenor_api_key: string | null; gif_api_key: string | null } | undefined;
if (row && row.tenor_api_key && !row.gif_api_key) {
db.prepare('UPDATE instance_settings SET gif_api_key = ? WHERE id = 1').run(row.tenor_api_key);
console.log('Migrating: Copied API key from tenor_api_key → gif_api_key (fixing race condition)');
}
} else if (!hasTenor && !hasGif) {
// Fresh install or never had Tenor — just add the column
console.log('Migrating: Adding gif_api_key column to instance_settings');
db.exec('ALTER TABLE instance_settings ADD COLUMN gif_api_key TEXT');
}
// !hasTenor && hasGif → already correct, no-op
}
/** Add database indexes on FK columns to prevent full table scans */
+27
View File
@@ -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(),
});
+4
View File
@@ -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<void> {
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 () => {
+41 -4
View File
@@ -68,6 +68,29 @@ export function buildDmMessageWithUser(
reactions: Reaction[] = [],
replyTo: DmMessageWithUser | null = null,
): DmMessageWithUser {
const stickerId = (message as any).stickerId ?? null;
// Lazy import to avoid circular dependency
let sticker = null;
if (stickerId) {
const db = getDb();
const row = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get();
if (row) {
sticker = {
id: row.id,
packId: row.packId,
spaceId: row.spaceId,
name: row.name,
tags: row.tags ?? '',
filename: row.filename,
mimetype: row.mimetype,
size: row.size,
width: row.width,
height: row.height,
uploadedBy: row.uploadedBy,
createdAt: row.createdAt,
};
}
}
return {
id: message.id,
dmChannelId: message.dmChannelId,
@@ -89,6 +112,8 @@ export function buildDmMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker,
};
}
@@ -881,21 +906,32 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
const hasSticker = stickerId && typeof stickerId === 'string';
if (!hasContent && !hasAttachments && !hasSticker) {
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
// Validate sticker exists if provided
if (hasSticker) {
const sticker = getDb().select().from(schema.stickers).where(eq(schema.stickers.id, stickerId!)).get();
if (!sticker) {
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
}
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
@@ -921,6 +957,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
+181
View File
@@ -0,0 +1,181 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { GifResult } from '@backspace/shared';
interface KlipyGifFile {
url: string;
width: number;
height: number;
size: number;
}
interface KlipySizeTier {
gif?: KlipyGifFile;
webp?: KlipyGifFile;
mp4?: KlipyGifFile;
jpg?: KlipyGifFile;
webm?: KlipyGifFile;
}
interface KlipyGif {
id: number;
slug: string;
title: string;
file: {
hd?: KlipySizeTier;
md?: KlipySizeTier;
sm?: KlipySizeTier;
xs?: KlipySizeTier;
};
}
interface KlipyResponse {
result: boolean;
data: {
data: KlipyGif[];
current_page: number;
per_page: number;
has_next: boolean;
};
}
function mapKlipyResults(gifs: KlipyGif[]): GifResult[] {
return gifs
.map((g) => {
if (!g.file) return null;
// Preview: small tier, prefer webp (smaller) then gif
const smTier = g.file.sm ?? g.file.xs;
const preview = smTier?.webp ?? smTier?.gif;
// Full: HD tier, prefer gif (original quality) then webp
const hdTier = g.file.hd ?? g.file.md;
const full = hdTier?.gif ?? hdTier?.webp;
if (!preview || !full) return null;
return {
id: g.slug || String(g.id),
title: g.title ?? '',
previewUrl: preview.url,
url: full.url,
width: preview.width,
height: preview.height,
};
})
.filter((r): r is GifResult => r !== null);
}
function getGifApiKey(): string | null {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
return row?.gifApiKey ?? null;
}
export async function gifRoutes(app: FastifyInstance): Promise<void> {
// GET /api/gif/enabled — any authenticated user, returns whether GIF search is available
app.get('/api/gif/enabled', { preHandler: authenticate }, async (_request, reply) => {
const key = getGifApiKey();
return reply.code(200).send({ enabled: !!key });
});
// GET /api/gif/trending
app.get<{ Querystring: { limit?: string; pos?: string } }>('/api/gif/trending', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/trending?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy trending error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy trending returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy trending fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
// GET /api/gif/search
app.get<{ Querystring: { q?: string; limit?: string; pos?: string } }>('/api/gif/search', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const q = request.query.q?.trim();
if (!q) {
return reply.code(400).send({ error: 'Search query is required', statusCode: 400 });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
q,
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/search?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy search error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy search returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy search fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
}
+41 -4
View File
@@ -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<void> {
},
}, 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<void> {
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<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
+18 -1
View File
@@ -131,10 +131,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
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<void> {
const db = getDb();
const body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
const updateData: Record<string, number | string | null> = { 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<void> {
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<void> {
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);
+414
View File
@@ -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<void> {
// 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<string, (typeof schema.stickers.$inferSelect)[]>();
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<string, string | null> = {};
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<string, any>;
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<string, (typeof schema.stickers.$inferSelect)[]>();
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 });
});
}
+4
View File
@@ -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),
};
}
+52
View File
@@ -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 {
+2
View File
@@ -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",
+72
View File
@@ -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<StickerPack>;
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => Promise<StickerPack>;
deletePack: (spaceId: string, packId: string) => Promise<{ success: boolean }>;
uploadSticker: (spaceId: string, packId: string, file: File, name: string, tags?: string) => Promise<Sticker>;
deleteSticker: (stickerId: string) => Promise<{ success: boolean }>;
myStickers: () => Promise<{ packs: StickerPack[] }>;
};
readonly admin: {
storageStats: () => Promise<StorageStats>;
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<StickerPack>('POST', `/spaces/${spaceId}/sticker-packs`, data),
updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) =>
request<StickerPack>('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<string, string> = {};
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<Sticker>;
},
deleteSticker: (stickerId: string) =>
request<{ success: boolean }>('DELETE', `/stickers/${stickerId}`),
myStickers: () =>
request<{ packs: StickerPack[] }>('GET', '/users/@me/stickers'),
};
this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
@@ -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<HTMLDivElement>(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 (
<div ref={containerRef} className="emoji-picker-wrapper">
<Picker
data={data}
onEmojiSelect={onEmojiSelect}
theme="dark"
set="native"
skinTonePosition="search"
previewPosition="none"
navPosition="bottom"
perLine={9}
maxFrequentRows={2}
emojiSize={24}
emojiButtonSize={32}
categories={['frequent', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags']}
/>
</div>
);
}
@@ -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<GifResult[]>([]);
const [loading, setLoading] = useState(true);
const [nextPos, setNextPos] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// 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 (
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs"
className="input-search w-full"
autoFocus
/>
</div>
{/* Results grid */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
onScroll={handleScroll}
>
{loading ? (
<div className="grid grid-cols-2 gap-1.5 p-1">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-surface-elevated rounded-lg animate-pulse"
style={{ height: 100 + Math.random() * 60 }}
/>
))}
</div>
) : results.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
</div>
) : (
<div className="columns-2 gap-1.5 p-1">
{results.map((gif) => (
<button
key={gif.id}
onClick={() => onGifSelect(gif.url)}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
>
<img
src={gif.previewUrl}
alt={gif.title}
className="w-full object-cover rounded-lg"
loading="lazy"
style={{
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
}}
/>
</button>
))}
</div>
)}
{loadingMore && (
<div className="flex justify-center py-2">
<div className="w-5 h-5 border-2 border-txt-tertiary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Attribution */}
<div className="px-3 py-1 text-[10px] text-txt-tertiary text-right">
Powered by Klipy
</div>
</div>
);
}
@@ -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<HTMLElement | null>;
gifEnabled: boolean;
stickersEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
}
export function InputPopover({
activeTab,
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
stickersEnabled,
onTabChange,
}: InputPopoverProps) {
const floatingRef = useRef<HTMLDivElement>(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(
<div
ref={floatingRef}
className="fixed z-[300] animate-slide-up"
style={{ top: -9999, left: -9999 }}
>
<div className="glass rounded-xl overflow-hidden flex flex-col" style={{ width: 352, maxHeight: 435 }}>
{/* Tab bar */}
{showTabs && (
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
{availableTabs.map((t) => (
<button
key={t.key}
onClick={() => onTabChange(t.key)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
activeTab === t.key
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`}
>
{t.label}
</button>
))}
</div>
)}
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && (
<EmojiPicker onEmojiSelect={onEmojiSelect} />
)}
{activeTab === 'gif' && gifEnabled && (
<GifPicker onGifSelect={onGifSelect} />
)}
{activeTab === 'stickers' && stickersEnabled && (
<StickerPicker onStickerSelect={onStickerSelect} />
)}
</div>
</div>
</div>,
document.body,
);
}
+113 -12
View File
@@ -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<HTMLButtonElement>(null);
const reactionPickerRef = useRef<HTMLDivElement>(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<string, { count: number; me: boolean }>);
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) {
</div>
) : (
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
{/* Sticker rendering */}
{isSticker && stickerData ? (
<div className="mt-1" title={`${stickerData.name}`}>
<img
src={stickerData.filename.startsWith('http') || stickerData.filename.startsWith('/') ? stickerData.filename : `/api/uploads/${stickerData.filename}`}
alt={stickerData.name}
className="max-w-[160px] max-h-[160px] object-contain"
loading="lazy"
/>
</div>
)}
) : isSticker ? (
<div className="mt-1 text-txt-tertiary text-sm italic">Sticker unavailable</div>
) : isGifOnly ? (
<div className="mt-1 max-w-[350px] rounded-lg overflow-hidden">
<img
src={message.content!.trim()}
alt="GIF"
className="max-w-full max-h-[350px] object-contain rounded-lg"
loading="lazy"
/>
</div>
) : (
<>
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
</div>
)}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
{/* Embeds */}
{!isEditing && firstUrl && <Embed url={firstUrl} />}
</>
)}
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
@@ -327,8 +396,28 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
)}
</div>
{/* Reaction emoji picker */}
{showReactionPicker && canAddReactions && reactionPickerBtnRef.current && createPortal(
<div
ref={reactionPickerRef}
className="fixed z-[300] animate-slide-up"
style={{
top: reactionPickerBtnRef.current.getBoundingClientRect().bottom + 8,
left: Math.min(
reactionPickerBtnRef.current.getBoundingClientRect().left,
window.innerWidth - 360,
),
}}
>
<div className="glass rounded-xl overflow-hidden">
<EmojiPicker onEmojiSelect={handleReactionEmojiSelect} />
</div>
</div>,
document.body,
)}
{/* Action buttons on hover */}
{isHovered && !isEditing && (
{(isHovered || showReactionPicker) && !isEditing && (
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
{canAddReactions && (
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
@@ -341,6 +430,18 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
{emoji}
</button>
))}
<button
ref={reactionPickerBtnRef}
onClick={() => setShowReactionPicker((v) => !v)}
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Add reaction"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
</svg>
</button>
</div>
)}
<button
@@ -4,8 +4,10 @@ import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '.
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
import { InputPopover, type InputPopoverTab } from './InputPopover';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
import { MAX_MESSAGE_LENGTH, type MemberWithUser, type Sticker } from '@backspace/shared';
import { useSettingsStore } from '../../stores/settingsStore';
interface MessageInputProps {
channelId: string;
@@ -23,15 +25,23 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(null);
const popoverAnchorRef = useRef<HTMLDivElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const sendStickerMessage = useChatStore((s) => s.sendStickerMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
const spaces = useSpaceStore((s) => s.spaces);
const stickersEnabled = spaces.length > 0; // stickers available if user is in any space
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
const isDm = isDmChannel(channelId);
@@ -50,6 +60,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}
}, [replyTo]);
// Close popover on channel change
useEffect(() => {
setActivePopover(null);
}, [channelId]);
// Filter members for the mention popover
const filteredMembers = useMemo(() => {
if (!mentionState) return [];
@@ -86,6 +101,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
setIsUploading(true);
setMentionState(null);
setActivePopover(null);
try {
// Upload files first — route to the correct instance for this channel
const attachmentIds: string[] = [];
@@ -241,6 +257,44 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
const handleEmojiSelect = useCallback((emoji: { native: string }) => {
const textarea = textareaRef.current;
if (!textarea) {
setContent((prev) => prev + emoji.native);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = content.slice(0, start);
const after = content.slice(end);
const newContent = before + emoji.native + after;
setContent(newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
});
}, [content]);
const handleGifSelect = useCallback((url: string) => {
setActivePopover(null);
sendMessage(channelId, url);
}, [channelId, sendMessage]);
const handleStickerSelect = useCallback((sticker: Sticker) => {
setActivePopover(null);
sendStickerMessage(channelId, sticker.id);
}, [channelId, sendStickerMessage]);
const togglePopover = useCallback((tab: InputPopoverTab) => {
setActivePopover((prev) => prev === tab ? null : tab);
}, []);
const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading;
if (!canSendMessages) {
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
@@ -252,8 +306,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}
return (
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<div ref={popoverAnchorRef} data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
<TypingIndicator channelId={channelId} />
{/* Input popover (emoji / gif / stickers) */}
{activePopover && (
<InputPopover
activeTab={activePopover}
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
stickersEnabled={stickersEnabled}
onTabChange={setActivePopover}
/>
)}
{replyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
@@ -376,25 +446,60 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
)}
{/* GIF button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="GIF">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</svg>
</button>
{gifEnabled && (
<button
onClick={() => togglePopover('gif')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="GIF"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</svg>
</button>
)}
{/* Sticker button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Stickers">
<button
onClick={() => togglePopover('stickers')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'stickers' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Stickers"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
</svg>
</button>
{/* Emoji button */}
<button className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" title="Emoji">
<button
onClick={() => togglePopover('emoji')}
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Emoji"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
</button>
{/* Send button — appears when there's content to send */}
{canSend && (
<button
onClick={handleSubmit}
disabled={isUploading}
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
</svg>
</button>
)}
</div>
</div>
</div>
@@ -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<StickerPack[]>(stickerCache?.packs ?? []);
const [loading, setLoading] = useState(!stickerCache || Date.now() - stickerCache.fetchedAt > CACHE_TTL);
const [query, setQuery] = useState('');
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
{/* Search */}
<div className="px-3 pt-2 pb-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stickers"
className="input-search w-full"
autoFocus
/>
</div>
{/* Results */}
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{loading ? (
<div className="grid grid-cols-4 gap-2 p-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-square bg-surface-elevated rounded-lg animate-pulse" />
))}
</div>
) : totalStickers === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="text-txt-tertiary text-sm mb-1">No stickers available</div>
<div className="text-txt-tertiary text-xs">
Space admins can add sticker packs in Space Settings.
</div>
</div>
) : filteredPacks.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
No stickers matching "{query}"
</div>
) : (
<div className="space-y-3">
{filteredPacks.map((pack) => (
<div key={pack.id}>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider px-1 mb-1.5">
{pack.name}
</div>
<div className="grid grid-cols-4 gap-1.5">
{pack.stickers.map((sticker) => (
<button
key={sticker.id}
onClick={() => onStickerSelect(sticker)}
className="aspect-square rounded-lg overflow-hidden hover:bg-interactive-hover transition-colors p-1.5 group"
title={sticker.name}
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain group-hover:scale-110 transition-transform"
loading="lazy"
/>
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
/** Invalidate the sticker cache (called when WS events indicate sticker changes) */
export function invalidateStickerCache(): void {
stickerCache = null;
}
@@ -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
</button>
)}
{canManageSpace && (
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
Stickers
</button>
)}
<button onClick={() => setTab('members')} className={tabClass('members')}>
Members
</button>
@@ -320,6 +326,7 @@ export function SpaceSettingsModal() {
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
@@ -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 <div className="text-sm text-txt-tertiary">Loading settings...</div>;
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<InstanceAdminSettings> = {
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() {
</div>
</div>
{/* GIF Search */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">GIF Search</div>
<p className="text-xs text-txt-tertiary mb-2">
Enable GIF search powered by Klipy. Get a free API key from the Klipy developer portal.
</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="password"
value={gifKeyDirty ? gifKeyDraft : ''}
onChange={(e) => { 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"
/>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-1.5 py-0.5 rounded ${
draft.gifEnabled ? 'bg-status-online/15 text-status-online' : 'bg-white/5 text-txt-tertiary'
}`}>
{draft.gifEnabled ? 'Enabled' : 'Not configured'}
</span>
{draft.gifEnabled && !gifKeyDirty && (
<button
onClick={() => { setGifKeyDraft(''); setGifKeyDirty(true); }}
className="text-[11px] text-txt-tertiary hover:text-txt-danger transition-colors"
>
Clear key
</button>
)}
</div>
</div>
</div>
{/* Status messages */}
{saveError && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
@@ -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<StickerPack[]>([]);
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<string | null>(null);
const [stickerName, setStickerName] = useState('');
const [stickerTags, setStickerTags] = useState('');
const [stickerFile, setStickerFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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 <div className="text-sm text-txt-tertiary">Loading sticker packs...</div>;
}
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Manage sticker packs for this space. Members can use these stickers in messages.
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
{/* Create Pack */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Create Sticker Pack
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
<input
type="text"
value={newPackName}
onChange={(e) => setNewPackName(e.target.value.slice(0, 32))}
placeholder="Pack name"
className="input-standard w-full"
/>
<input
type="text"
value={newPackDesc}
onChange={(e) => setNewPackDesc(e.target.value.slice(0, 100))}
placeholder="Description (optional)"
className="input-standard w-full"
/>
<button
onClick={handleCreatePack}
disabled={creating || !newPackName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Pack'}
</button>
</div>
</div>
{/* Existing Packs */}
{packs.length === 0 ? (
<div className="text-sm text-txt-tertiary">No sticker packs yet.</div>
) : (
<div className="space-y-4">
{packs.map((pack) => (
<div key={pack.id} className="rounded-lg bg-white/[0.02] p-3.5">
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-txt-primary">{pack.name}</div>
{pack.description && (
<div className="text-xs text-txt-tertiary">{pack.description}</div>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadPackId(uploadPackId === pack.id ? null : pack.id)}
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-interactive-hover hover:bg-interactive-active rounded transition-colors"
>
{uploadPackId === pack.id ? 'Cancel' : 'Add Sticker'}
</button>
<button
onClick={() => setDeleteTarget({ type: 'pack', id: pack.id, name: pack.name })}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
>
Delete Pack
</button>
</div>
</div>
{/* Upload form for this pack */}
{uploadPackId === pack.id && (
<div className="border-t border-white/[0.06] pt-2 mt-2 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={stickerName}
onChange={(e) => setStickerName(e.target.value.slice(0, 32))}
placeholder="Sticker name"
className="input-standard flex-1"
/>
<input
type="text"
value={stickerTags}
onChange={(e) => setStickerTags(e.target.value.slice(0, 100))}
placeholder="Tags (optional)"
className="input-standard flex-1"
/>
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/webp,image/gif"
onChange={(e) => 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"
/>
<button
onClick={handleUploadSticker}
disabled={uploading || !stickerFile || !stickerName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50 flex-shrink-0"
>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
<div className="text-[10px] text-txt-tertiary">
PNG, WebP, or GIF. Max 512x512px, 500KB.
</div>
</div>
)}
{/* Sticker grid */}
{pack.stickers.length > 0 && (
<div className="grid grid-cols-5 gap-2 mt-2">
{pack.stickers.map((sticker) => (
<div
key={sticker.id}
className="relative group aspect-square rounded-lg bg-surface-base overflow-hidden"
>
<img
src={getStickerUrl(sticker)}
alt={sticker.name}
className="w-full h-full object-contain p-1"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => setDeleteTarget({ type: 'sticker', id: sticker.id, name: sticker.name })}
className="p-1 text-white hover:text-txt-danger transition-colors"
title="Delete sticker"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 text-[9px] text-white truncate opacity-0 group-hover:opacity-100 transition-opacity">
{sticker.name}
</div>
</div>
))}
</div>
)}
{pack.stickers.length === 0 && (
<div className="text-xs text-txt-tertiary mt-1">No stickers in this pack yet.</div>
)}
</div>
))}
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => 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}
/>
</div>
);
}
+15
View File
@@ -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;
+40
View File
@@ -38,6 +38,7 @@ interface ChatState {
clearAllMessages: () => void;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
sendStickerMessage: (channelId: string, stickerId: string) => Promise<void>;
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
addMessage: (channelId: string, message: MessageWithUser) => void;
@@ -289,6 +290,45 @@ export const useChatStore = create<ChatState>((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);
+12
View File
@@ -6,10 +6,12 @@ interface SettingsState {
streamingLimits: InstanceStreamingLimits | null;
instanceSettings: InstanceAdminSettings | null;
isAdmin: boolean;
gifEnabled: boolean;
fetchStreamingLimits: () => Promise<void>;
updateStreamingLimits: (limits: Partial<InstanceStreamingLimits>) => Promise<void>;
fetchInstanceSettings: () => Promise<void>;
updateInstanceSettings: (data: Partial<InstanceAdminSettings>) => Promise<void>;
fetchGifEnabled: () => Promise<void>;
setIsAdmin: (isAdmin: boolean) => void;
}
@@ -32,6 +34,7 @@ export const useSettingsStore = create<SettingsState>((set) => ({
streamingLimits: null,
instanceSettings: null,
isAdmin: false,
gifEnabled: false,
fetchStreamingLimits: async () => {
try {
@@ -70,5 +73,14 @@ export const useSettingsStore = create<SettingsState>((set) => ({
}
},
fetchGifEnabled: async () => {
try {
const { enabled } = await api.gif.enabled();
set({ gifEnabled: enabled });
} catch {
set({ gifEnabled: false });
}
},
setIsAdmin: (isAdmin: boolean) => set({ isAdmin }),
}));
+15
View File
@@ -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; }
+27
View File
@@ -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: {}