feat: GIF search (Klipy), stickers, emoji picker, and bug fixes
- Add GIF search powered by Klipy API with correct response mapping (file.sm/hd tiers, not flat files structure) - Add sticker system: packs, upload with auto-downscale, send in messages - Add tabbed InputPopover with emoji, GIF, and sticker pickers - Fix GIF API key migration race condition (column-add loop vs rename) - Fix masked API key corruption on settings save (server + client guards) - Fix sticker packs 403 (reversed isMember parameter order) - Fix emoji picker not filling popover width (perLine 8→9, CSS 100%) - Add error logging for Klipy API failures
This commit is contained in:
@@ -150,6 +150,20 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'password_changed_at', type: 'INTEGER' },
|
||||
]
|
||||
},
|
||||
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
|
||||
// or it will race with the tenor_api_key → gif_api_key rename migration
|
||||
{
|
||||
name: 'messages',
|
||||
columns: [
|
||||
{ name: 'sticker_id', type: 'TEXT' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'dm_messages',
|
||||
columns: [
|
||||
{ name: 'sticker_id', type: 'TEXT' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -315,6 +329,38 @@ export function runMigrations(db: Database.Database): void {
|
||||
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
|
||||
migrateDmMessagesReplyToFk(db);
|
||||
|
||||
// ─── Ensure sticker tables exist ─────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sticker_packs (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stickers (
|
||||
id TEXT PRIMARY KEY,
|
||||
pack_id TEXT NOT NULL REFERENCES sticker_packs(id) ON DELETE CASCADE,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
tags TEXT DEFAULT '',
|
||||
filename TEXT NOT NULL,
|
||||
mimetype TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sticker_packs_space_id ON sticker_packs(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stickers_pack_id ON stickers(pack_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stickers_space_id ON stickers(space_id);
|
||||
`);
|
||||
|
||||
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
|
||||
migrateRenameGifApiKey(db);
|
||||
|
||||
// ─── Add indexes on FK columns for query performance ─────────────────────
|
||||
migrateAddIndexes(db);
|
||||
|
||||
@@ -333,22 +379,74 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
|
||||
if (tableInfo.sql.includes('REFERENCES dm_messages')) return;
|
||||
|
||||
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
|
||||
db.exec(`
|
||||
CREATE TABLE dm_messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
|
||||
content TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
|
||||
DROP TABLE dm_messages;
|
||||
ALTER TABLE dm_messages_new RENAME TO dm_messages;
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
|
||||
`);
|
||||
|
||||
// Check if sticker_id column exists (may have been added by column migration)
|
||||
const dmCols = db.pragma('table_info(dm_messages)') as { name: string }[];
|
||||
const hasStickerId = dmCols.some(c => c.name === 'sticker_id');
|
||||
|
||||
if (hasStickerId) {
|
||||
db.exec(`
|
||||
CREATE TABLE dm_messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
|
||||
content TEXT,
|
||||
sticker_id TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, sticker_id, edited_at, created_at FROM dm_messages;
|
||||
DROP TABLE dm_messages;
|
||||
ALTER TABLE dm_messages_new RENAME TO dm_messages;
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
|
||||
`);
|
||||
} else {
|
||||
db.exec(`
|
||||
CREATE TABLE dm_messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
|
||||
content TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
|
||||
DROP TABLE dm_messages;
|
||||
ALTER TABLE dm_messages_new RENAME TO dm_messages;
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
|
||||
function migrateRenameGifApiKey(db: Database.Database): void {
|
||||
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
||||
const hasTenor = cols.some(c => c.name === 'tenor_api_key');
|
||||
const hasGif = cols.some(c => c.name === 'gif_api_key');
|
||||
|
||||
if (hasTenor && !hasGif) {
|
||||
// Clean case: rename the old column
|
||||
console.log('Migrating: Renaming tenor_api_key → gif_api_key in instance_settings');
|
||||
db.exec('ALTER TABLE instance_settings RENAME COLUMN tenor_api_key TO gif_api_key');
|
||||
} else if (hasTenor && hasGif) {
|
||||
// Race condition: column-add loop created empty gif_api_key before rename could run.
|
||||
// Copy the real key from tenor_api_key if gif_api_key is still NULL/empty.
|
||||
const row = db.prepare('SELECT tenor_api_key, gif_api_key FROM instance_settings WHERE id = 1').get() as
|
||||
{ tenor_api_key: string | null; gif_api_key: string | null } | undefined;
|
||||
if (row && row.tenor_api_key && !row.gif_api_key) {
|
||||
db.prepare('UPDATE instance_settings SET gif_api_key = ? WHERE id = 1').run(row.tenor_api_key);
|
||||
console.log('Migrating: Copied API key from tenor_api_key → gif_api_key (fixing race condition)');
|
||||
}
|
||||
} else if (!hasTenor && !hasGif) {
|
||||
// Fresh install or never had Tenor — just add the column
|
||||
console.log('Migrating: Adding gif_api_key column to instance_settings');
|
||||
db.exec('ALTER TABLE instance_settings ADD COLUMN gif_api_key TEXT');
|
||||
}
|
||||
// !hasTenor && hasGif → already correct, no-op
|
||||
}
|
||||
|
||||
/** Add database indexes on FK columns to prevent full table scans */
|
||||
|
||||
@@ -70,6 +70,7 @@ export const messages = sqliteTable('messages', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
stickerId: text('sticker_id'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
@@ -112,6 +113,7 @@ export const dmMessages = sqliteTable('dm_messages', {
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
stickerId: text('sticker_id'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
@@ -213,6 +215,30 @@ export const userSpaceLayout = sqliteTable('user_space_layout', {
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const stickerPacks = sqliteTable('sticker_packs', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
createdBy: text('created_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const stickers = sqliteTable('stickers', {
|
||||
id: text('id').primaryKey(),
|
||||
packId: text('pack_id').notNull().references(() => stickerPacks.id, { onDelete: 'cascade' }),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tags: text('tags').default(''),
|
||||
filename: text('filename').notNull(),
|
||||
mimetype: text('mimetype').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
uploadedBy: text('uploaded_by').notNull().references(() => users.id),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const instanceSettings = sqliteTable('instance_settings', {
|
||||
id: integer('id').primaryKey().default(1),
|
||||
instanceName: text('instance_name').default('Backspace'),
|
||||
@@ -226,6 +252,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
|
||||
maxResolution: integer('max_resolution').notNull().default(1080),
|
||||
maxFramerate: integer('max_framerate').notNull().default(60),
|
||||
registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit
|
||||
gifApiKey: text('gif_api_key'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Generated
+27
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user