feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a sentence and a sticker is the whole message, so their sizes, limits and render paths differ. Emoji names are unique per space and restricted to letters, digits and underscore — :name: has to resolve to one image, and a space or colon inside the name would make the reference impossible to delimit. Sticker names are labels picked from a grid, so they accept anything. Rendering reuses the existing markdown pipeline by rewriting :name: into image syntax, which needs no plugin. Code spans and fences are left alone: text someone wrapped in backticks was meant to be shown literally, and turning it into an image would destroy what they wrote. Messages carry stickerId with ON DELETE set null — removing a sticker from the space must not delete the history of everyone who used it. The server refuses a sticker from another space, which would otherwise leak images between unrelated servers. Expressions load once per space and stay in memory: the message renderer consults the map for every :name: it finds, and a request per lookup would turn each message into a cascade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE `space_emojis` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `space_stickers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1788295165743,
|
"when": 1788295165743,
|
||||||
"tag": "0016_lyrical_freak",
|
"tag": "0016_lyrical_freak",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295936825,
|
||||||
|
"tag": "0017_purple_wildside",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,9 @@ export const messages = sqliteTable('messages', {
|
|||||||
// add a join to every pin lookup.
|
// add a join to every pin lookup.
|
||||||
pinnedAt: integer('pinned_at'),
|
pinnedAt: integer('pinned_at'),
|
||||||
pinnedBy: text('pinned_by'),
|
pinnedBy: text('pinned_by'),
|
||||||
|
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||||
|
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||||
|
stickerId: text('sticker_id'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
replyToFk: foreignKey({
|
replyToFk: foreignKey({
|
||||||
@@ -654,3 +657,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||||
|
*
|
||||||
|
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||||
|
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||||
|
*/
|
||||||
|
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||||
|
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||||
|
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||||
|
* caminho de render são diferentes.
|
||||||
|
*/
|
||||||
|
export const spaceStickers = sqliteTable('space_stickers', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { spotifyRoutes } from './routes/spotify.js';
|
|||||||
import { auditRoutes } from './routes/audit.js';
|
import { auditRoutes } from './routes/audit.js';
|
||||||
import { statsRoutes } from './routes/stats.js';
|
import { statsRoutes } from './routes/stats.js';
|
||||||
import { soundboardRoutes } from './routes/soundboard.js';
|
import { soundboardRoutes } from './routes/soundboard.js';
|
||||||
|
import { expressionRoutes } from './routes/expressions.js';
|
||||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
@@ -139,6 +140,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(auditRoutes);
|
await app.register(auditRoutes);
|
||||||
await app.register(statsRoutes);
|
await app.register(statsRoutes);
|
||||||
await app.register(soundboardRoutes);
|
await app.register(soundboardRoutes);
|
||||||
|
await app.register(expressionRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||||
|
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
const MAX_EMOJIS_PER_SPACE = 100;
|
||||||
|
const MAX_STICKERS_PER_SPACE = 50;
|
||||||
|
const MAX_NAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||||
|
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||||
|
* delimitar.
|
||||||
|
*/
|
||||||
|
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||||
|
|
||||||
|
function normalizeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||||
|
return NAME_PATTERN.test(name) ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||||
|
function validFilename(raw: unknown): raw is string {
|
||||||
|
return typeof raw === 'string' && raw.length > 0
|
||||||
|
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ emojis: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const name = normalizeName(request.body?.name);
|
||||||
|
if (!name) {
|
||||||
|
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceEmojis).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const emoji = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||||
|
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ stickers: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||||
|
// e aceita acento e espaço — ao contrário do emoji.
|
||||||
|
const name = typeof request.body?.name === 'string'
|
||||||
|
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||||
|
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceStickers).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sticker = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||||
|
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ export function buildMessageWithUser(
|
|||||||
// second request, and so the pins panel and the timeline agree.
|
// second request, and so the pins panel and the timeline agree.
|
||||||
pinnedAt: message.pinnedAt,
|
pinnedAt: message.pinnedAt,
|
||||||
pinnedBy: message.pinnedBy,
|
pinnedBy: message.pinnedBy,
|
||||||
|
stickerId: message.stickerId,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
attachments: attachmentRows.map(a => ({
|
attachments: attachmentRows.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -275,7 +276,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 } = request.body;
|
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||||
|
|
||||||
const spaceId = getChannelSpaceId(id);
|
const spaceId = getChannelSpaceId(id);
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
@@ -291,18 +292,35 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const db0 = getDb();
|
||||||
|
const db = db0;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments) {
|
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
let sticker = null;
|
||||||
|
if (stickerId) {
|
||||||
|
sticker = db0.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||||
|
if (!sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||||
|
// servidores que não têm relação nenhuma.
|
||||||
|
if (sticker.spaceId !== spaceId) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContent && !hasAttachments && !sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', 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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -327,6 +345,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
|
stickerId: sticker ? sticker.id : null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ export interface Message {
|
|||||||
/** Epoch millis when pinned; null when not pinned. */
|
/** Epoch millis when pinned; null when not pinned. */
|
||||||
pinnedAt?: number | null;
|
pinnedAt?: number | null;
|
||||||
pinnedBy?: string | null;
|
pinnedBy?: string | null;
|
||||||
|
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
|
||||||
|
stickerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithUser extends Message {
|
export interface MessageWithUser extends Message {
|
||||||
@@ -599,6 +601,8 @@ export interface CreateMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
/** Envio de figurinha; nesse caso `content` fica vazio. */
|
||||||
|
stickerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ import type {
|
|||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
|
|
||||||
|
export interface SpaceEmoji {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpaceSticker = SpaceEmoji;
|
||||||
|
|
||||||
export interface SoundboardSound {
|
export interface SoundboardSound {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -321,6 +332,15 @@ export class BackspaceApiClient {
|
|||||||
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly expressions: {
|
||||||
|
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
|
||||||
|
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
|
||||||
|
removeEmoji: (id: string) => Promise<void>;
|
||||||
|
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
|
||||||
|
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
|
||||||
|
removeSticker: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly pins: {
|
readonly pins: {
|
||||||
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
||||||
pin: (messageId: string) => Promise<{ success: boolean }>;
|
pin: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
@@ -757,6 +777,15 @@ export class BackspaceApiClient {
|
|||||||
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.expressions = {
|
||||||
|
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
|
||||||
|
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
|
||||||
|
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
|
||||||
|
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
|
||||||
|
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
|
||||||
|
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
this.pins = {
|
this.pins = {
|
||||||
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
||||||
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { isCustomEmojiAlt } from '../../utils/customEmoji';
|
||||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
@@ -186,7 +187,22 @@ function buildComponents(): Components {
|
|||||||
hr: () => <hr className="border-border-soft my-2" />,
|
hr: () => <hr className="border-border-soft my-2" />,
|
||||||
|
|
||||||
// Images (in markdown content — not attachments)
|
// Images (in markdown content — not attachments)
|
||||||
img: ({ src, alt }) => (
|
img: ({ src, alt }) => {
|
||||||
|
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
|
||||||
|
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
|
||||||
|
// vez de virar um bloco como uma imagem comum.
|
||||||
|
if (isCustomEmojiAlt(alt ?? undefined)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt ?? ''}
|
||||||
|
title={alt ?? ''}
|
||||||
|
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="mt-1 max-w-[400px]">
|
<div className="mt-1 max-w-[400px]">
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -197,7 +213,8 @@ function buildComponents(): Components {
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Tables (GFM)
|
// Tables (GFM)
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { renderCustomEmojis } from '../../utils/customEmoji';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -233,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
return () => clearTimeout(confirmDeleteTimeout.current);
|
return () => clearTimeout(confirmDeleteTimeout.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
|
||||||
|
// (DM, por exemplo) o texto passa intacto.
|
||||||
|
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const emojiLookup = useExpressionStore((s) => s.emojiByName);
|
||||||
|
const contentWithEmojis = useMemo(() => {
|
||||||
|
if (!message.content || !emojiSpaceId) return message.content ?? '';
|
||||||
|
return renderCustomEmojis(
|
||||||
|
message.content,
|
||||||
|
(name) => emojiLookup(emojiSpaceId, name),
|
||||||
|
(filename) => api.uploads.url(filename),
|
||||||
|
);
|
||||||
|
}, [message.content, emojiSpaceId, emojiLookup]);
|
||||||
|
|
||||||
|
const stickerId = (message as MessageWithUser).stickerId;
|
||||||
|
const sticker = useExpressionStore((s) =>
|
||||||
|
stickerId && emojiSpaceId
|
||||||
|
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const isGifOnly = isGifOnlyMessage(message.content);
|
const isGifOnly = isGifOnlyMessage(message.content);
|
||||||
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
||||||
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
||||||
@@ -528,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<>
|
<>
|
||||||
{message.content && (
|
{message.content && (
|
||||||
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
||||||
<MarkdownRenderer content={message.content} />
|
{sticker ? (
|
||||||
|
// Figurinha ocupa a mensagem inteira, sem moldura de
|
||||||
|
// anexo: é o conteúdo, não um arquivo acompanhando texto.
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
title={sticker.name}
|
||||||
|
className="w-[160px] h-[160px] object-contain rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer content={contentWithEmojis} />
|
||||||
|
)}
|
||||||
{message.editedAt && (
|
{message.editedAt && (
|
||||||
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { SpaceSidebar } from './SpaceSidebar';
|
import { SpaceSidebar } from './SpaceSidebar';
|
||||||
import { ChannelSidebar } from './ChannelSidebar';
|
import { ChannelSidebar } from './ChannelSidebar';
|
||||||
@@ -217,6 +218,15 @@ export function AppLayout() {
|
|||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
useSpotifyActivity();
|
useSpotifyActivity();
|
||||||
|
|
||||||
|
// Emojis e figurinhas do espaço atual, carregados uma vez por espaço: o
|
||||||
|
// render de mensagem consulta o mapa a cada `:nome:`, e buscar por mensagem
|
||||||
|
// viraria uma cascata de requisições.
|
||||||
|
const expressionSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const loadExpressions = useExpressionStore((s) => s.load);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expressionSpaceId) void loadExpressions(expressionSpaceId);
|
||||||
|
}, [expressionSpaceId, loadExpressions]);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api, type SpaceEmoji, type SpaceSticker } from '../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis e figurinhas por espaço.
|
||||||
|
*
|
||||||
|
* Carregados uma vez por espaço e mantidos em memória: o render de mensagem
|
||||||
|
* consulta o mapa a cada `:nome:` encontrado, e uma busca por requisição
|
||||||
|
* tornaria cada mensagem uma cascata de chamadas.
|
||||||
|
*/
|
||||||
|
interface ExpressionState {
|
||||||
|
emojisBySpace: Map<string, SpaceEmoji[]>;
|
||||||
|
stickersBySpace: Map<string, SpaceSticker[]>;
|
||||||
|
loaded: Set<string>;
|
||||||
|
load: (spaceId: string) => Promise<void>;
|
||||||
|
emojiByName: (spaceId: string, name: string) => SpaceEmoji | undefined;
|
||||||
|
setEmojis: (spaceId: string, emojis: SpaceEmoji[]) => void;
|
||||||
|
setStickers: (spaceId: string, stickers: SpaceSticker[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExpressionStore = create<ExpressionState>((set, get) => ({
|
||||||
|
emojisBySpace: new Map(),
|
||||||
|
stickersBySpace: new Map(),
|
||||||
|
loaded: new Set(),
|
||||||
|
|
||||||
|
load: async (spaceId) => {
|
||||||
|
if (!spaceId || get().loaded.has(spaceId)) return;
|
||||||
|
// Marcado antes da resposta: entrar num espaço dispara vários renders, e
|
||||||
|
// sem isso a mesma busca sairia várias vezes em paralelo.
|
||||||
|
set((s) => ({ loaded: new Set(s.loaded).add(spaceId) }));
|
||||||
|
try {
|
||||||
|
const [{ emojis }, { stickers }] = await Promise.all([
|
||||||
|
api.expressions.emojis(spaceId),
|
||||||
|
api.expressions.stickers(spaceId),
|
||||||
|
]);
|
||||||
|
set((s) => ({
|
||||||
|
emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis),
|
||||||
|
stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Sem emojis próprios a conversa segue: `:nome:` fica como texto.
|
||||||
|
set((s) => {
|
||||||
|
const loaded = new Set(s.loaded); loaded.delete(spaceId);
|
||||||
|
return { loaded };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emojiByName: (spaceId, name) =>
|
||||||
|
get().emojisBySpace.get(spaceId)?.find((e) => e.name === name),
|
||||||
|
|
||||||
|
setEmojis: (spaceId, emojis) =>
|
||||||
|
set((s) => ({ emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis) })),
|
||||||
|
|
||||||
|
setStickers: (spaceId, stickers) =>
|
||||||
|
set((s) => ({ stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers) })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderCustomEmojis, isCustomEmojiAlt } from './customEmoji';
|
||||||
|
import type { SpaceEmoji } from '../api/client';
|
||||||
|
|
||||||
|
const emoji = (name: string): SpaceEmoji => ({
|
||||||
|
id: '1', spaceId: 's', name, filename: `${name}.png`, uploaderId: null, createdAt: 0,
|
||||||
|
});
|
||||||
|
const lookup = (n: string) => (n === 'trollface' ? emoji('trollface') : undefined);
|
||||||
|
const urlOf = (f: string) => `/api/uploads/${f}`;
|
||||||
|
|
||||||
|
describe('renderCustomEmojis', () => {
|
||||||
|
it('troca um emoji conhecido por imagem', () => {
|
||||||
|
expect(renderCustomEmojis('olha :trollface: isso', lookup, urlOf))
|
||||||
|
.toBe('olha  isso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deixa nome desconhecido como texto', () => {
|
||||||
|
expect(renderCustomEmojis('nada :inexistente: aqui', lookup, urlOf))
|
||||||
|
.toBe('nada :inexistente: aqui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não mexe em código em linha', () => {
|
||||||
|
// Quem escreveu entre crases quis mostrar o texto literal.
|
||||||
|
expect(renderCustomEmojis('use `:trollface:` assim', lookup, urlOf))
|
||||||
|
.toBe('use `:trollface:` assim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não mexe em bloco de código', () => {
|
||||||
|
const src = '```\n:trollface:\n```';
|
||||||
|
expect(renderCustomEmojis(src, lookup, urlOf)).toBe(src);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('devolve o texto intacto quando não há dois-pontos', () => {
|
||||||
|
expect(renderCustomEmojis('sem nada', lookup, urlOf)).toBe('sem nada');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCustomEmojiAlt', () => {
|
||||||
|
it('reconhece o alt de emoji e ignora imagem comum', () => {
|
||||||
|
expect(isCustomEmojiAlt(':trollface:')).toBe(true);
|
||||||
|
expect(isCustomEmojiAlt('foto de praia')).toBe(false);
|
||||||
|
expect(isCustomEmojiAlt(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SpaceEmoji } from '../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Troca `:nome:` pela sintaxe de imagem do markdown, para o renderizador
|
||||||
|
* existente desenhar o emoji próprio sem precisar de plugin.
|
||||||
|
*
|
||||||
|
* Trechos de código são preservados: `:nome:` dentro de crase é texto que a
|
||||||
|
* pessoa quis mostrar literalmente, e virar imagem ali seria destruir o que ela
|
||||||
|
* escreveu.
|
||||||
|
*/
|
||||||
|
export function renderCustomEmojis(
|
||||||
|
content: string,
|
||||||
|
lookup: (name: string) => SpaceEmoji | undefined,
|
||||||
|
urlOf: (filename: string) => string,
|
||||||
|
): string {
|
||||||
|
if (!content.includes(':')) return content;
|
||||||
|
|
||||||
|
// Divide preservando blocos cercados (```) e código em linha (`) —
|
||||||
|
// os delimitadores ficam nos pedaços ímpares e passam intactos.
|
||||||
|
const parts = content.split(/(```[\s\S]*?```|`[^`]*`)/g);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part, i) => {
|
||||||
|
if (i % 2 === 1) return part;
|
||||||
|
return part.replace(/:([a-z0-9_]{2,32}):/gi, (whole, name: string) => {
|
||||||
|
const emoji = lookup(name.toLowerCase());
|
||||||
|
if (!emoji) return whole;
|
||||||
|
// O alt conserva `:nome:` para que copiar a mensagem devolva o texto
|
||||||
|
// original, e é por ele que o renderizador reconhece o emoji.
|
||||||
|
return `})`;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O renderizador usa isto para dar tamanho de emoji em vez de imagem normal. */
|
||||||
|
export function isCustomEmojiAlt(alt: string | undefined): boolean {
|
||||||
|
return !!alt && /^:[a-z0-9_]{2,32}:$/i.test(alt);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user