feat: launch readiness — PWA, API hardening, memory leak fixes, sticker removal
- Add PWA infrastructure: vite-plugin-pwa, manifest, service worker, SW update prompt component, placeholder icons, Apple meta tags - Harden API client: 401 auto-logout, AbortController timeouts (30s standard, 120s uploads), onUnauthorized callback - Fix memory leaks: clear voice user status on leave, clean up all Maps (channelToSpaceMap, permissions, etc.) on removeSpace - Upgrade error boundary to Aether Drift design with Try Again button, collapsible stack trace, and componentDidCatch logging - Configure desktop icon paths in electron-builder.yml - Remove sticker feature (server routes, schema, types, UI components) - Fix Docker build: use **/node_modules in .dockerignore to prevent COPY from clobbering pnpm-installed workspace dependencies - Add vite-env.d.ts declarations for noise suppressor wasm imports - Exclude test files from tsc build via tsconfig
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ COPY packages/server/ packages/server/
|
|||||||
COPY packages/web/ packages/web/
|
COPY packages/web/ packages/web/
|
||||||
|
|
||||||
# Build the web frontend
|
# Build the web frontend
|
||||||
RUN cd packages/web && pnpm run build
|
RUN pnpm --filter @backspace/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 2: Production runtime
|
# Stage 2: Production runtime
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ mac:
|
|||||||
target:
|
target:
|
||||||
- dmg
|
- dmg
|
||||||
- zip
|
- zip
|
||||||
icon: null
|
icon: build/icon.icns
|
||||||
win:
|
win:
|
||||||
target:
|
target:
|
||||||
- nsis
|
- nsis
|
||||||
icon: null
|
icon: build/icon.ico
|
||||||
linux:
|
linux:
|
||||||
target:
|
target:
|
||||||
- AppImage
|
- AppImage
|
||||||
- deb
|
- deb
|
||||||
category: Network
|
category: Network
|
||||||
icon: null
|
icon: build/icons
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
|
|||||||
@@ -153,18 +153,6 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
},
|
},
|
||||||
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
|
// 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
|
// 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' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
@@ -329,35 +317,6 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
|
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
|
||||||
migrateDmMessagesReplyToFk(db);
|
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) ────────────────────
|
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
|
||||||
migrateRenameGifApiKey(db);
|
migrateRenameGifApiKey(db);
|
||||||
|
|
||||||
@@ -380,29 +339,6 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
|
|||||||
|
|
||||||
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
|
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_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(`
|
db.exec(`
|
||||||
CREATE TABLE dm_messages_new (
|
CREATE TABLE dm_messages_new (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -420,7 +356,6 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_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 */
|
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
|
||||||
function migrateRenameGifApiKey(db: Database.Database): void {
|
function migrateRenameGifApiKey(db: Database.Database): void {
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export const messages = sqliteTable('messages', {
|
|||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
stickerId: text('sticker_id'),
|
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -113,7 +112,6 @@ export const dmMessages = sqliteTable('dm_messages', {
|
|||||||
userId: text('user_id').notNull().references(() => users.id),
|
userId: text('user_id').notNull().references(() => users.id),
|
||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
stickerId: text('sticker_id'),
|
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -215,30 +213,6 @@ export const userSpaceLayout = sqliteTable('user_space_layout', {
|
|||||||
updatedAt: integer('updated_at').notNull(),
|
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', {
|
export const instanceSettings = sqliteTable('instance_settings', {
|
||||||
id: integer('id').primaryKey().default(1),
|
id: integer('id').primaryKey().default(1),
|
||||||
instanceName: text('instance_name').default('Backspace'),
|
instanceName: text('instance_name').default('Backspace'),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { exploreRoutes } from './routes/explore.js';
|
|||||||
import { searchRoutes } from './routes/search.js';
|
import { searchRoutes } from './routes/search.js';
|
||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { gifRoutes } from './routes/gif.js';
|
import { gifRoutes } from './routes/gif.js';
|
||||||
import { stickerRoutes } from './routes/stickers.js';
|
|
||||||
import { registerWebSocket } from './ws/handler.js';
|
import { registerWebSocket } from './ws/handler.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -94,7 +94,6 @@ async function main(): Promise<void> {
|
|||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(gifRoutes);
|
await app.register(gifRoutes);
|
||||||
await app.register(stickerRoutes);
|
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
|
|||||||
@@ -68,29 +68,6 @@ export function buildDmMessageWithUser(
|
|||||||
reactions: Reaction[] = [],
|
reactions: Reaction[] = [],
|
||||||
replyTo: DmMessageWithUser | null = null,
|
replyTo: DmMessageWithUser | null = null,
|
||||||
): DmMessageWithUser {
|
): 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 {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
dmChannelId: message.dmChannelId,
|
dmChannelId: message.dmChannelId,
|
||||||
@@ -112,8 +89,6 @@ export function buildDmMessageWithUser(
|
|||||||
})),
|
})),
|
||||||
reactions,
|
reactions,
|
||||||
replyTo,
|
replyTo,
|
||||||
stickerId,
|
|
||||||
sticker,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,7 +881,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
const { content, attachments: attachmentIds, replyToId } = request.body;
|
||||||
|
|
||||||
if (!isDmMember(id, request.userId)) {
|
if (!isDmMember(id, request.userId)) {
|
||||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
||||||
@@ -914,24 +889,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||||
const hasSticker = stickerId && typeof stickerId === 'string';
|
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments && !hasSticker) {
|
if (!hasContent && !hasAttachments) {
|
||||||
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
|
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
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 });
|
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 db = getDb();
|
||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -957,7 +923,6 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
stickerId: stickerId || null,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -121,28 +121,6 @@ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSel
|
|||||||
return map;
|
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(
|
export function buildMessageWithUser(
|
||||||
message: typeof schema.messages.$inferSelect,
|
message: typeof schema.messages.$inferSelect,
|
||||||
user: typeof schema.users.$inferSelect,
|
user: typeof schema.users.$inferSelect,
|
||||||
@@ -150,7 +128,6 @@ export function buildMessageWithUser(
|
|||||||
reactions: Reaction[] = [],
|
reactions: Reaction[] = [],
|
||||||
replyTo: MessageWithUser | null = null,
|
replyTo: MessageWithUser | null = null,
|
||||||
): MessageWithUser {
|
): MessageWithUser {
|
||||||
const stickerId = (message as any).stickerId ?? null;
|
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
@@ -172,8 +149,6 @@ export function buildMessageWithUser(
|
|||||||
})),
|
})),
|
||||||
reactions,
|
reactions,
|
||||||
replyTo,
|
replyTo,
|
||||||
stickerId,
|
|
||||||
sticker: hydrateSticker(stickerId),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +252,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
const { content, attachments: attachmentIds, replyToId } = request.body;
|
||||||
|
|
||||||
const spaceId = getChannelSpaceId(id);
|
const spaceId = getChannelSpaceId(id);
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
@@ -295,18 +270,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||||
const hasSticker = stickerId && typeof stickerId === 'string';
|
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments && !hasSticker) {
|
if (!hasContent && !hasAttachments) {
|
||||||
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
|
return reply.code(400).send({ error: 'Message must have content or attachments', 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) {
|
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||||
@@ -339,7 +305,6 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
stickerId: stickerId || null,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -1,414 +0,0 @@
|
|||||||
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,7 +8,6 @@ import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
|||||||
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared';
|
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
import { hydrateSticker } from '../routes/messages.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-evaluate SPEAK permission for all participants in voice channels
|
* Re-evaluate SPEAK permission for all participants in voice channels
|
||||||
@@ -100,7 +99,6 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stickerId = (message as any).stickerId ?? null;
|
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
@@ -113,8 +111,6 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
|||||||
attachments,
|
attachments,
|
||||||
reactions,
|
reactions,
|
||||||
replyTo,
|
replyTo,
|
||||||
stickerId,
|
|
||||||
sticker: hydrateSticker(stickerId),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,8 +192,6 @@ export interface MessageWithUser extends Message {
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
replyTo?: MessageWithUser | null;
|
replyTo?: MessageWithUser | null;
|
||||||
stickerId?: string | null;
|
|
||||||
sticker?: Sticker | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reaction Types ────────────────────────────────────────────────────────
|
// ─── Reaction Types ────────────────────────────────────────────────────────
|
||||||
@@ -257,8 +255,6 @@ export interface DmMessageWithUser extends DmMessage {
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
replyTo?: DmMessageWithUser | null;
|
replyTo?: DmMessageWithUser | null;
|
||||||
stickerId?: string | null;
|
|
||||||
sticker?: Sticker | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WebSocket Event Types ──────────────────────────────────────────────────
|
// ─── WebSocket Event Types ──────────────────────────────────────────────────
|
||||||
@@ -342,11 +338,6 @@ export type ServerEvent =
|
|||||||
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
||||||
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
||||||
| { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }
|
| { 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: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -426,7 +417,6 @@ export interface CreateMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
stickerId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
@@ -458,7 +448,6 @@ export interface CreateDmMessageRequest {
|
|||||||
content?: string;
|
content?: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
stickerId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedQuery {
|
export interface PaginatedQuery {
|
||||||
@@ -539,34 +528,6 @@ export interface GifResult {
|
|||||||
height: 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 ────────────────────────────────────────────────
|
// ─── Instance Settings Types ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface InstanceAdminSettings {
|
export interface InstanceAdminSettings {
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Self-hosted chat platform" />
|
||||||
|
<meta name="theme-color" content="#0b0b10" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Backspace" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<title>Backspace</title>
|
<title>Backspace</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-surface-base text-txt-primary">
|
<body class="bg-surface-base text-txt-primary">
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^4.0.18"
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"workbox-window": "^7.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 562 B |
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
Binary file not shown.
|
After Width: | Height: | Size: 592 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -4,6 +4,7 @@ import { LoginPage } from './components/auth/LoginPage';
|
|||||||
import { RegisterPage } from './components/auth/RegisterPage';
|
import { RegisterPage } from './components/auth/RegisterPage';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
import { JoinPage } from './components/JoinPage';
|
import { JoinPage } from './components/JoinPage';
|
||||||
|
import { SwUpdatePrompt } from './components/ui/SwUpdatePrompt';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -27,6 +28,8 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SwUpdatePrompt />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
@@ -67,5 +70,7 @@ export function App() {
|
|||||||
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
|
<Route path="/" element={<Navigate to="/channels/@me" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
|
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ import type {
|
|||||||
SpaceFolder,
|
SpaceFolder,
|
||||||
InvitePreview,
|
InvitePreview,
|
||||||
GifResult,
|
GifResult,
|
||||||
StickerPack,
|
|
||||||
Sticker,
|
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
@@ -196,16 +194,6 @@ export class BackspaceApiClient {
|
|||||||
enabled: () => Promise<{ enabled: boolean }>;
|
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: {
|
readonly admin: {
|
||||||
storageStats: () => Promise<StorageStats>;
|
storageStats: () => Promise<StorageStats>;
|
||||||
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
|
||||||
@@ -216,7 +204,7 @@ export class BackspaceApiClient {
|
|||||||
deleteUser: (userId: string) => Promise<{ success: boolean }>;
|
deleteUser: (userId: string) => Promise<{ success: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(baseUrl: string, getToken: () => string | null) {
|
constructor(baseUrl: string, getToken: () => string | null, onUnauthorized?: () => void) {
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -236,13 +224,30 @@ export class BackspaceApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}${path}`, {
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 && requireAuth && onUnauthorized) {
|
||||||
|
onUnauthorized();
|
||||||
|
}
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
const retryAfter = (body as { retryAfter?: number }).retryAfter
|
const retryAfter = (body as { retryAfter?: number }).retryAfter
|
||||||
@@ -266,13 +271,30 @@ export class BackspaceApiClient {
|
|||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/uploads`, {
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}/uploads`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 && onUnauthorized) {
|
||||||
|
onUnauthorized();
|
||||||
|
}
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
const retryAfter = (body as { retryAfter?: number }).retryAfter
|
const retryAfter = (body as { retryAfter?: number }).retryAfter
|
||||||
@@ -540,42 +562,6 @@ export class BackspaceApiClient {
|
|||||||
enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'),
|
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 = {
|
this.admin = {
|
||||||
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
|
||||||
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
|
||||||
@@ -598,9 +584,23 @@ export class BackspaceApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new BackspaceApiClient('/api', () => localStorage.getItem('backspace_token'));
|
function handleUnauthorized(): void {
|
||||||
|
localStorage.removeItem('backspace_token');
|
||||||
|
if (
|
||||||
|
!window.location.pathname.startsWith('/login') &&
|
||||||
|
!window.location.pathname.startsWith('/register')
|
||||||
|
) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createApiClient(origin: string, getToken: () => string | null): BackspaceApiClient {
|
export const api = new BackspaceApiClient(
|
||||||
|
'/api',
|
||||||
|
() => localStorage.getItem('backspace_token'),
|
||||||
|
handleUnauthorized,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function createApiClient(origin: string, getToken: () => string | null, onUnauthorized?: () => void): BackspaceApiClient {
|
||||||
const baseUrl = origin ? `${origin}/api` : '/api';
|
const baseUrl = origin ? `${origin}/api` : '/api';
|
||||||
return new BackspaceApiClient(baseUrl, getToken);
|
return new BackspaceApiClient(baseUrl, getToken, onUnauthorized);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
|
|||||||
skinTonePosition="search"
|
skinTonePosition="search"
|
||||||
previewPosition="none"
|
previewPosition="none"
|
||||||
navPosition="bottom"
|
navPosition="bottom"
|
||||||
perLine={9}
|
perLine={10}
|
||||||
maxFrequentRows={2}
|
maxFrequentRows={2}
|
||||||
emojiSize={24}
|
emojiSize={24}
|
||||||
emojiButtonSize={32}
|
emojiButtonSize={32}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MentionPopover } from './MentionPopover';
|
|||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser, type Sticker } from '@backspace/shared';
|
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
@@ -31,7 +31,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
const sendStickerMessage = useChatStore((s) => s.sendStickerMessage);
|
|
||||||
const replyTo = useChatStore((s) => s.replyTo);
|
const replyTo = useChatStore((s) => s.replyTo);
|
||||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
@@ -39,8 +38,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
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
|
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||||
@@ -284,11 +281,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
sendMessage(channelId, url);
|
sendMessage(channelId, url);
|
||||||
}, [channelId, sendMessage]);
|
}, [channelId, sendMessage]);
|
||||||
|
|
||||||
const handleStickerSelect = useCallback((sticker: Sticker) => {
|
|
||||||
setActivePopover(null);
|
|
||||||
sendStickerMessage(channelId, sticker.id);
|
|
||||||
}, [channelId, sendStickerMessage]);
|
|
||||||
|
|
||||||
const togglePopover = useCallback((tab: InputPopoverTab) => {
|
const togglePopover = useCallback((tab: InputPopoverTab) => {
|
||||||
setActivePopover((prev) => prev === tab ? null : tab);
|
setActivePopover((prev) => prev === tab ? null : tab);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -309,17 +301,15 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
<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]">
|
<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} />
|
<TypingIndicator channelId={channelId} />
|
||||||
|
|
||||||
{/* Input popover (emoji / gif / stickers) */}
|
{/* Input popover (emoji / gif) */}
|
||||||
{activePopover && (
|
{activePopover && (
|
||||||
<InputPopover
|
<InputPopover
|
||||||
activeTab={activePopover}
|
activeTab={activePopover}
|
||||||
onClose={() => setActivePopover(null)}
|
onClose={() => setActivePopover(null)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
onGifSelect={handleGifSelect}
|
onGifSelect={handleGifSelect}
|
||||||
onStickerSelect={handleStickerSelect}
|
|
||||||
anchorRef={popoverAnchorRef}
|
anchorRef={popoverAnchorRef}
|
||||||
gifEnabled={gifEnabled}
|
gifEnabled={gifEnabled}
|
||||||
stickersEnabled={stickersEnabled}
|
|
||||||
onTabChange={setActivePopover}
|
onTabChange={setActivePopover}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -460,19 +450,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sticker button */}
|
|
||||||
<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 */}
|
{/* Emoji button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => togglePopover('emoji')}
|
onClick={() => togglePopover('emoji')}
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
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,7 +10,6 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
|||||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||||
import { StickersPanel } from './spaceSettingsPanels/StickersPanel';
|
|
||||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||||
|
|
||||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||||
@@ -271,7 +270,7 @@ export function SpaceSettingsModal() {
|
|||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview');
|
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
const space = spaces.find(s => s.id === currentSpaceId);
|
const space = spaces.find(s => s.id === currentSpaceId);
|
||||||
@@ -301,11 +300,6 @@ export function SpaceSettingsModal() {
|
|||||||
Discovery
|
Discovery
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canManageSpace && (
|
|
||||||
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
|
|
||||||
Stickers
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||||
Members
|
Members
|
||||||
</button>
|
</button>
|
||||||
@@ -326,7 +320,6 @@ export function SpaceSettingsModal() {
|
|||||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||||
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
|
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
|
|
||||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||||
|
|
||||||
|
export function SwUpdatePrompt() {
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW();
|
||||||
|
|
||||||
|
if (!needRefresh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] glass-pill px-4 py-2.5 flex items-center gap-3 text-sm text-txt-primary shadow-lg">
|
||||||
|
<span>A new version is available</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateServiceWorker(true)}
|
||||||
|
className="px-3 py-1 rounded-md bg-accent-primary text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -333,6 +333,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
addVoiceUser(event.channelId, event.userId);
|
addVoiceUser(event.channelId, event.userId);
|
||||||
} else {
|
} else {
|
||||||
removeVoiceUser(event.channelId, event.userId);
|
removeVoiceUser(event.channelId, event.userId);
|
||||||
|
clearVoiceUserStatus(event.userId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -755,20 +756,6 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
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':
|
case 'pong':
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
+59
-11
@@ -6,17 +6,21 @@ import './styles/globals.css';
|
|||||||
|
|
||||||
class ErrorBoundary extends React.Component<
|
class ErrorBoundary extends React.Component<
|
||||||
{ children: React.ReactNode },
|
{ children: React.ReactNode },
|
||||||
{ hasError: boolean; error: Error | null }
|
{ hasError: boolean; error: Error | null; showStack: boolean }
|
||||||
> {
|
> {
|
||||||
constructor(props: { children: React.ReactNode }) {
|
constructor(props: { children: React.ReactNode }) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false, error: null };
|
this.state = { hasError: false, error: null, showStack: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error) {
|
static getDerivedStateFromError(error: Error) {
|
||||||
return { hasError: true, error };
|
return { hasError: true, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary]', error, errorInfo.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
@@ -25,29 +29,73 @@ class ErrorBoundary extends React.Component<
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#232428',
|
backgroundColor: '#0b0b10',
|
||||||
color: '#ffffff',
|
color: '#efefef',
|
||||||
fontFamily: 'sans-serif',
|
fontFamily: "'DM Sans', sans-serif",
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
|
padding: '24px',
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Something went wrong</h1>
|
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Something went wrong</h1>
|
||||||
<p style={{ color: '#abacb2' }}>{this.state.error?.message}</p>
|
<p style={{ color: '#a0a0aa', maxWidth: '480px', textAlign: 'center' }}>{this.state.error?.message}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
style={{
|
||||||
|
padding: '8px 24px',
|
||||||
|
backgroundColor: '#7c6cf6',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: "'DM Sans', sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 24px',
|
padding: '8px 24px',
|
||||||
backgroundColor: '#5865f2',
|
backgroundColor: 'transparent',
|
||||||
color: 'white',
|
color: '#a0a0aa',
|
||||||
border: 'none',
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
fontFamily: "'DM Sans', sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reload
|
Reload Page
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{this.state.error?.stack && (
|
||||||
|
<details
|
||||||
|
open={this.state.showStack}
|
||||||
|
onToggle={(e) => this.setState({ showStack: (e.target as HTMLDetailsElement).open })}
|
||||||
|
style={{ maxWidth: '600px', width: '100%', marginTop: '8px' }}
|
||||||
|
>
|
||||||
|
<summary style={{ color: '#a0a0aa', cursor: 'pointer', fontSize: '13px' }}>
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<pre style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#a0a0aa',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '200px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ interface ChatState {
|
|||||||
clearAllMessages: () => void;
|
clearAllMessages: () => void;
|
||||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
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>;
|
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
@@ -290,45 +289,6 @@ 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) => {
|
editMessage: async (messageId: string, content: string, channelId: string) => {
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const origin = getChannelOrigin(channelId);
|
const origin = getChannelOrigin(channelId);
|
||||||
|
|||||||
@@ -408,10 +408,37 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeSpace: (spaceId: string) => {
|
removeSpace: (spaceId: string) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
// Collect channel IDs belonging to this space for map cleanup
|
||||||
|
const channelIdsToRemove = new Set<string>();
|
||||||
|
for (const [channelId, sid] of state.channelToSpaceMap) {
|
||||||
|
if (sid === spaceId) channelIdsToRemove.add(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelToSpaceMap = new Map(state.channelToSpaceMap);
|
||||||
|
const channelPermissions = new Map(state.channelPermissions);
|
||||||
|
const channelOriginMap = new Map(state.channelOriginMap);
|
||||||
|
const channelLastMessageIds = new Map(state.channelLastMessageIds);
|
||||||
|
const spacePermissions = new Map(state.spacePermissions);
|
||||||
|
|
||||||
|
for (const channelId of channelIdsToRemove) {
|
||||||
|
channelToSpaceMap.delete(channelId);
|
||||||
|
channelPermissions.delete(channelId);
|
||||||
|
channelOriginMap.delete(channelId);
|
||||||
|
channelLastMessageIds.delete(channelId);
|
||||||
|
}
|
||||||
|
spacePermissions.delete(spaceId);
|
||||||
|
|
||||||
|
return {
|
||||||
spaces: state.spaces.filter(s => s.id !== spaceId),
|
spaces: state.spaces.filter(s => s.id !== spaceId),
|
||||||
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
|
||||||
}));
|
channelToSpaceMap,
|
||||||
|
channelPermissions,
|
||||||
|
channelOriginMap,
|
||||||
|
channelLastMessageIds,
|
||||||
|
spacePermissions,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMemberPresence: (userId: string, status: string) => {
|
updateMemberPresence: (userId: string, status: string) => {
|
||||||
|
|||||||
Vendored
+13
@@ -1 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url' {
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
||||||
|
declare module '@sapphi-red/web-noise-suppressor/rnnoise.wasm?url' {
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
||||||
|
declare module '@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url' {
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,5 +20,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*", "node_modules/vite-plugin-pwa/client.d.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'prompt',
|
||||||
|
includeAssets: ['icons/favicon-32.png', 'icons/favicon-16.png', 'icons/apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Backspace',
|
||||||
|
short_name: 'Backspace',
|
||||||
|
description: 'Self-hosted chat platform',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
theme_color: '#0b0b10',
|
||||||
|
background_color: '#0b0b10',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{ src: '/icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/ws/, /^\/uploads/],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
Generated
+2467
-17
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user