5 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 71d2109b2f feat(desktop): in-app prompt to restart for an update
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The main process already showed a system notification when an update finished
downloading, and nothing in the renderer listened — so the only cue vanished on
its own and was missed by anyone not watching the desktop at that moment.

A prompt now stays in the window until it is acted on or dismissed, with the
restart wired to the install-update channel the preload already exposed.

Dismissing is 'not now', not 'never': a later version reopens the prompt, since
the version it refers to has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:31:59 -03:00
devsyncwrldandClaude Opus 5 7f08384372 feat(expressions): pickers and upload for emojis and stickers
Completes the feature: the tables existed but nothing could be put in them.

Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a
512KB ceiling — both are fetched on every message that uses them, so weight
matters more than fidelity. The suggested name is pre-normalised so the common
case needs no typing, and a name collision reports itself distinctly from an
upload failure: the corrective action is different.

Custom emojis join the emoji picker as their own category. They have no native
character, so selecting one inserts :name: — the same text the renderer
resolves back to an image, which also means copying a message yields something
that still reads.

Stickers get a picker tab that only appears inside a space, since that is where
they exist, and send immediately on click: a sticker is the whole message, so
parking it in the composer to await Enter would make no sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:25:10 -03:00
devsyncwrldandClaude Opus 5 7d003021d1 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>
2026-09-01 17:57:32 -03:00
devsyncwrldandClaude Opus 5 50a8f12c77 feat(search): inline filters, and translate the search UI
The filters themselves already existed end to end — the server takes q, from,
has, before and after, the API client passes them, and the popover has fields
for each. What it lacked was discovery: the panel sits behind a button, so the
capability was invisible.

Typing 'de:fulano' or 'has:image' straight into the search box now applies the
same filters. Keys are accepted in both languages, since the app is bilingual,
and an unrecognised token falls back to being search text — otherwise a message
containing a URL or 'algo:coisa' would become unfindable.

Inline filters win over the panel's: whoever just typed one is expressing the
more recent intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:49:05 -03:00
devsyncwrldandClaude Opus 5 4ceb5cd66a feat(pins): pin messages to a channel
Pinned state lives on the message rather than a join table: a message is
pinned in exactly one channel, its own, so a separate table would add a join to
every lookup and buy nothing.

Every message now carries its pin state, so the timeline can mark a pin without
a second request and the panel and the timeline cannot disagree. Toggling is
deliberately not optimistic — the server refuses past the channel's limit, and
showing it pinned before confirmation would lie in exactly that case.

The pins list reuses the same assembly the channel history uses, extracted into
one helper, so the two cannot drift apart in what they include.

The migration also adds the (channel, user, created) index the filtered search
will need, since both touch the same table and one migration is cheaper than
two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:45:33 -03:00
35 changed files with 10282 additions and 38 deletions
+8 -3
View File
@@ -25,11 +25,18 @@ código e os commits seguem em inglês, como o resto do repositório.
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar | | Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música | | Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada | | Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações | | Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade | | Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo) ## Já existia no código (verificado, não construir de novo)
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
client de API e painel no `SearchPopover` já existiam completos. Só faltava
tradução, sintaxe embutida (`de:fulano`) e descoberta.
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce` - **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
escalonados em 0/150/300ms. escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`: - **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
@@ -56,7 +63,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| Feature | Tamanho | Observação técnica | | Feature | Tamanho | Observação técnica |
|---|---|---| |---|---|---|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes | | Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
## Regra de idioma (a partir de 2026-08-31) ## Regra de idioma (a partir de 2026-08-31)
@@ -79,6 +85,7 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
- Chat: composer e mensagens (placeholder, anexos, responder, reações) - Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto - Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento) - Lista de membros (grupos por cargo, carregamento)
- Busca (rótulos, filtros e sintaxe embutida)
- Registro de auditoria e Estatísticas (nasceram bilíngues) - Registro de auditoria e Estatísticas (nasceram bilíngues)
### Fila sugerida de tradução ### Fila sugerida de tradução
@@ -114,8 +121,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica | | Feature | Tamanho | Observação técnica |
|---|---|---| |---|---|---|
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora | | **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros | | **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
@@ -0,0 +1,4 @@
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
@@ -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
File diff suppressed because it is too large Load Diff
@@ -113,6 +113,20 @@
"when": 1788194631751, "when": 1788194631751,
"tag": "0015_young_human_fly", "tag": "0015_young_human_fly",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1788295165743,
"tag": "0016_lyrical_freak",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1788295936825,
"tag": "0017_purple_wildside",
"breakpoints": true
} }
] ]
} }
+46
View File
@@ -81,6 +81,14 @@ export const messages = sqliteTable('messages', {
replyToId: text('reply_to_id'), replyToId: text('reply_to_id'),
content: text('content'), content: text('content'),
editedAt: integer('edited_at'), editedAt: integer('edited_at'),
// Pinned state lives on the message rather than in a join table: a message is
// pinned in exactly one channel — its own — so a separate table would only
// add a join to every pin lookup.
pinnedAt: integer('pinned_at'),
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({
@@ -89,6 +97,10 @@ export const messages = sqliteTable('messages', {
}).onDelete('set null'), }).onDelete('set null'),
channelIdx: index('idx_messages_channel_id').on(table.channelId), channelIdx: index('idx_messages_channel_id').on(table.channelId),
userIdx: index('idx_messages_user_id').on(table.userId), userIdx: index('idx_messages_user_id').on(table.userId),
// Listing a channel's pins, newest first, without scanning its history.
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
// Filtered search: author within a channel, ordered by recency.
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
})); }));
export const attachments = sqliteTable('attachments', { export const attachments = sqliteTable('attachments', {
@@ -645,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),
}));
+2
View File
@@ -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);
+153
View File
@@ -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();
});
}
+144 -5
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { eq, and, desc, lt, inArray } from 'drizzle-orm'; import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js'; import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js'; import { generateSnowflake } from '../utils/snowflake.js';
@@ -145,6 +145,11 @@ export function buildMessageWithUser(
content: message.content, content: message.content,
editedAt: message.editedAt, editedAt: message.editedAt,
createdAt: message.createdAt, createdAt: message.createdAt,
// Carried on every message so the client can show the pin marker without a
// second request, and so the pins panel and the timeline agree.
pinnedAt: message.pinnedAt,
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,
@@ -271,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) {
@@ -287,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();
@@ -323,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();
@@ -500,4 +523,120 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true }); return reply.code(200).send({ success: true });
}); });
// ─── Pins ────────────────────────────────────────────────────────────────
/**
* Assembles rows into the shape clients expect. Same batching the channel
* listing uses — kept in one place so pins and history cannot drift apart in
* what they include.
*/
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
if (rows.length === 0) return [];
const db = getDb();
const userIds = [...new Set(rows.map(m => m.userId))];
const userMap = new Map(
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
);
const messageIds = rows.map(m => m.id);
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of db.select().from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
attachmentMap.get(mid)!.push(att);
}
const reactionsMap = fetchReactionsForMessages(messageIds);
const embedMap = fetchEmbedsForMessages(messageIds);
const replyToMap = fetchReplyToMessages(rows);
return rows.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
}).filter((m): m is MessageWithUser => m !== null);
}
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
const MAX_PINS_PER_CHANNEL = 50;
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const spaceId = getChannelSpaceId(id);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
}
const db = getDb();
const rows = db.select()
.from(schema.messages)
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
.orderBy(desc(schema.messages.pinnedAt))
.all();
return reply.code(200).send({ messages: assembleMessages(rows) });
});
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
if (message.pinnedAt) return reply.code(200).send({ success: true });
const count = db.select({ id: schema.messages.id })
.from(schema.messages)
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
.all().length;
if (count >= MAX_PINS_PER_CHANNEL) {
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
}
db.update(schema.messages)
.set({ pinnedAt: Date.now(), pinnedBy: request.userId })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: true,
});
return reply.code(200).send({ success: true });
});
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
db.update(schema.messages)
.set({ pinnedAt: null, pinnedBy: null })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: false,
});
return reply.code(200).send({ success: true });
});
} }
+8
View File
@@ -212,6 +212,11 @@ export interface Message {
type?: 'user' | 'system'; type?: 'user' | 'system';
editedAt: number | null; editedAt: number | null;
createdAt: number; createdAt: number;
/** Epoch millis when pinned; null when not pinned. */
pinnedAt?: number | 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 {
@@ -441,6 +446,7 @@ export type ServerEvent =
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] } | { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number } | { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string } | { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser } | { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string } | { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser } | { type: 'dm_message_created'; message: DmMessageWithUser }
@@ -595,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 {
+41
View File
@@ -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,21 @@ 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: {
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
pin: (messageId: string) => Promise<{ success: boolean }>;
unpin: (messageId: string) => Promise<{ success: boolean }>;
};
readonly audit: { readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>; log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
}; };
@@ -751,6 +777,21 @@ 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 = {
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
};
this.audit = { this.audit = {
log: (spaceId: string, before?: string) => { log: (spaceId: string, before?: string) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -1,4 +1,7 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, useMemo } from 'react';
import { useSpaceStore } from '../../stores/spaceStore';
import { useExpressionStore } from '../../stores/expressionStore';
import { api } from '../../api/client';
import Picker from '@emoji-mart/react'; import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data'; import data from '@emoji-mart/data';
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) { export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
const customCategories = useMemo(() => {
if (!spaceEmojis?.length) return [];
return [{
id: 'space',
name: 'Servidor',
emojis: spaceEmojis.map((e) => ({
id: e.name,
name: e.name,
keywords: [e.name],
skins: [{ src: api.uploads.url(e.filename) }],
})),
}];
}, [spaceEmojis]);
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input) // Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
<div ref={containerRef} className={wrapperClass}> <div ref={containerRef} className={wrapperClass}>
<Picker <Picker
data={data} data={data}
custom={customCategories}
onEmojiSelect={onEmojiSelect} onEmojiSelect={onEmojiSelect}
theme="dark" theme="dark"
set="native" set="native"
@@ -1,11 +1,13 @@
import React, { useRef, useEffect, useCallback } from 'react'; import React, { useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker'; import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker'; import { GifPicker } from './GifPicker';
import { StickerPicker } from './StickerPicker';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useDragToClose } from '../../hooks/useDragToClose'; import { useDragToClose } from '../../hooks/useDragToClose';
export type InputPopoverTab = 'emoji' | 'gif'; export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
interface InputPopoverProps { interface InputPopoverProps {
activeTab: InputPopoverTab; activeTab: InputPopoverTab;
@@ -15,6 +17,8 @@ interface InputPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>; anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean; gifEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void; onTabChange: (tab: InputPopoverTab) => void;
onStickerSelect: (stickerId: string) => void;
hasStickers?: boolean;
} }
interface SharedTabProps { interface SharedTabProps {
@@ -53,6 +57,7 @@ function DesktopPopover({
onClose, onClose,
onEmojiSelect, onEmojiSelect,
onGifSelect, onGifSelect,
onStickerSelect,
anchorRef, anchorRef,
gifEnabled, gifEnabled,
onTabChange, onTabChange,
@@ -139,6 +144,7 @@ function DesktopPopover({
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />} {activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />} {activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
</div> </div>
</div> </div>
</div>, </div>,
@@ -155,6 +161,7 @@ function MobileSheet({
onClose, onClose,
onEmojiSelect, onEmojiSelect,
onGifSelect, onGifSelect,
onStickerSelect,
gifEnabled, gifEnabled,
onTabChange, onTabChange,
availableTabs, availableTabs,
@@ -221,6 +228,7 @@ function MobileSheet({
<div className="flex-1 min-h-0 overflow-hidden flex flex-col"> <div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />} {activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />} {activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
</div> </div>
</div> </div>
</>, </>,
@@ -231,12 +239,17 @@ function MobileSheet({
export function InputPopover(props: InputPopoverProps) { export function InputPopover(props: InputPopoverProps) {
const isMobile = useUIStore((s) => s.isMobile); const isMobile = useUIStore((s) => s.isMobile);
const t = useT();
const availableTabs: { key: InputPopoverTab; label: string }[] = [ const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' }, { key: 'emoji', label: 'Emoji' },
]; ];
if (props.gifEnabled) { if (props.gifEnabled) {
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' }); availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
} }
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
if (props.hasStickers) {
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
}
if (isMobile) { if (isMobile) {
return <MobileSheet {...props} availableTabs={availableTabs} />; return <MobileSheet {...props} availableTabs={availableTabs} />;
@@ -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 }) => (
+46 -2
View File
@@ -1,4 +1,7 @@
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 { useT } from '../../i18n'; import { useT } from '../../i18n';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared'; import type { MessageWithUser, Embed, User } from '@backspace/shared';
@@ -232,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
@@ -331,6 +354,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setEditContent(message.content ?? ''); setEditContent(message.content ?? '');
setIsEditing(true); setIsEditing(true);
}, },
isPinned: Boolean((message as MessageWithUser).pinnedAt),
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
onTogglePin: () => {
const pinned = Boolean((message as MessageWithUser).pinnedAt);
// Sem atualização otimista: o servidor recusa acima do limite do canal,
// e mostrar como fixada antes da confirmação mentiria nesse caso.
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
},
onDelete: () => deleteMessage(message.id, channelKey), onDelete: () => deleteMessage(message.id, channelKey),
onReaction: (emoji: string) => toggleReaction(emoji), onReaction: (emoji: string) => toggleReaction(emoji),
onOpenEmojiPicker: () => { onOpenEmojiPicker: () => {
@@ -518,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>
)} )}
@@ -96,6 +96,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
// Feature flags // Feature flags
const tr = useT(); const tr = useT();
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
const handleStickerSelect = (stickerId: string) => {
setActivePopover(null);
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
// sentido acumulá-la no campo de texto esperando um Enter.
void sendMessage(channelId, '', undefined, stickerId);
};
const gifEnabled = useSettingsStore((s) => s.gifEnabled); const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES // Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -531,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
}; };
const handleEmojiSelect = useCallback( const handleEmojiSelect = useCallback(
(emoji: { native: string }) => { (emoji: { native?: string; id?: string }) => {
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
// o que o render resolve depois para a imagem.
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
if (!inserted) return;
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) { if (!textarea) {
setDraft(channelId, draftText + emoji.native); setDraft(channelId, draftText + inserted);
return; return;
} }
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
const before = draftText.slice(0, start); const before = draftText.slice(0, start);
const after = draftText.slice(end); const after = draftText.slice(end);
const newContent = before + emoji.native + after; const newContent = before + inserted + after;
setDraft(channelId, newContent); setDraft(channelId, newContent);
// Restore cursor position after the emoji // Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length; const newCursorPos = start + inserted.length;
requestAnimationFrame(() => { requestAnimationFrame(() => {
textarea.focus(); textarea.focus();
textarea.selectionStart = newCursorPos; textarea.selectionStart = newCursorPos;
@@ -768,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClose={() => setActivePopover(null)} onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect} onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
hasStickers={Boolean(stickerSpaceId)}
anchorRef={popoverAnchorRef} anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled} gifEnabled={gifEnabled}
onTabChange={setActivePopover} onTabChange={setActivePopover}
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import type { MessageWithUser } from '@backspace/shared';
import { api } from '../../api/client';
import { Avatar } from '../ui/Avatar';
import { useT } from '../../i18n';
interface PinsPopoverProps {
channelId: string;
onClose: () => void;
onJumpToMessage: (messageId: string) => void;
}
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
const t = useT();
const [messages, setMessages] = useState<MessageWithUser[]>([]);
const [loading, setLoading] = useState(true);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.pins.list(channelId)
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
.catch(() => { if (!cancelled) setMessages([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [channelId]);
useEffect(() => {
const onPointer = (e: MouseEvent | TouchEvent) => {
if (!panelRef.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
// toque de forma confiável, como os outros popovers deste projeto tratam.
document.addEventListener('mousedown', onPointer);
document.addEventListener('touchstart', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('touchstart', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
>
<div className="px-4 py-3 border-b border-border-soft shrink-0">
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
{loading ? (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
) : messages.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
) : (
<ul className="space-y-1">
{messages.map((m) => (
<li key={m.id}>
<button
onClick={() => { onJumpToMessage(m.id); onClose(); }}
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
>
<Avatar
src={m.user.avatar}
name={m.user.displayName ?? m.user.username}
size={28}
userId={m.user.id}
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-txt-primary truncate">
{m.user.displayName ?? m.user.username}
</div>
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
{m.content || ''}
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -1,4 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { parseSearchQuery } from '../../utils/searchQuery';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore'; import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
}); });
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const t = useT();
const [fromFilter, setFromFilter] = useState(''); const [fromFilter, setFromFilter] = useState('');
const [hasFilter, setHasFilter] = useState(''); const [hasFilter, setHasFilter] = useState('');
const [beforeFilter, setBeforeFilter] = useState(''); const [beforeFilter, setBeforeFilter] = useState('');
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
}, [open, onClose]); }, [open, onClose]);
const doSearch = useCallback(async (searchOffset = 0) => { const doSearch = useCallback(async (searchOffset = 0) => {
const trimmed = query.trim(); // Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) { // quem acabou de escrever está expressando a intenção mais recente.
const parsed = parseSearchQuery(query);
const trimmed = parsed.text;
const effFrom = parsed.from ?? fromFilter;
const effHas = parsed.has ?? hasFilter;
const effBefore = parsed.before ?? beforeFilter;
const effAfter = parsed.after ?? afterFilter;
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
setResults([]); setResults([]);
setTotalCount(0); setTotalCount(0);
return; return;
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
const client = getApiForOrigin(origin); const client = getApiForOrigin(origin);
const params = { const params = {
q: trimmed || undefined, q: trimmed || undefined,
from: fromFilter || undefined, from: effFrom || undefined,
has: hasFilter || undefined, has: effHas || undefined,
before: beforeFilter || undefined, before: effBefore || undefined,
after: afterFilter || undefined, after: effAfter || undefined,
offset: searchOffset, offset: searchOffset,
limit: 25, limit: 25,
}; };
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..." placeholder={t('search.placeholder')}
className="input-embedded flex-1 text-[14px]" className="input-embedded flex-1 text-[14px]"
/> />
{query && ( {query && (
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
{showFilters && ( {showFilters && (
<div className="mt-2 grid grid-cols-2 gap-2"> <div className="mt-2 grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label> <label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
<input <input
type="text" type="text"
value={fromFilter} value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)} onChange={(e) => setFromFilter(e.target.value)}
placeholder="username" placeholder={t('search.fromPlaceholder')}
className="input-search w-full px-2 py-1 text-[13px]" className="input-search w-full px-2 py-1 text-[13px]"
/> />
</div> </div>
<div> <div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label> <label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
<select <select
value={hasFilter} value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)} onChange={(e) => setHasFilter(e.target.value)}
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer" className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
> >
<option value="">Any</option> <option value="">{t('search.hasAny')}</option>
<option value="file">File</option> <option value="file">{t('search.hasFile')}</option>
<option value="image">Image</option> <option value="image">{t('search.hasImage')}</option>
<option value="link">Link</option> <option value="link">{t('search.hasLink')}</option>
</select> </select>
</div> </div>
<div> <div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label> <label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
<input <input
type="date" type="date"
value={beforeFilter} value={beforeFilter}
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
/> />
</div> </div>
<div> <div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label> <label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
<input <input
type="date" type="date"
value={afterFilter} value={afterFilter}
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { api } from '../../api/client';
import { useExpressionStore } from '../../stores/expressionStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useT } from '../../i18n';
interface StickerPickerProps {
onStickerSelect: (stickerId: string) => void;
mobile?: boolean;
}
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
const t = useT();
const spaceId = useSpaceStore((s) => s.currentSpaceId);
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
const load = useExpressionStore((s) => s.load);
useEffect(() => {
if (spaceId) void load(spaceId);
}, [spaceId, load]);
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
// tamanho ao alternar.
const rootClass = mobile
? 'flex flex-col flex-1 min-h-0 w-full'
: 'flex flex-col h-[390px] w-[390px]';
return (
<div className={rootClass}>
<div className="px-3 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
{t('expressions.pickerTitle')}
</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
{stickers.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
) : (
<div className="grid grid-cols-3 gap-2">
{stickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => onStickerSelect(sticker.id)}
title={sticker.name}
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
>
<img
src={api.uploads.url(sticker.filename)}
alt={sticker.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -16,6 +16,10 @@ interface MessageMenuParams {
canAddReactions: boolean; canAddReactions: boolean;
canSendMessages: boolean; canSendMessages: boolean;
canManageMessages: boolean; canManageMessages: boolean;
isPinned: boolean;
onTogglePin: () => void;
/** Rótulos traduzidos: este módulo não é um componente e não pode usar o hook. */
labels: { pin: string; unpin: string };
onReply: () => void; onReply: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
canAddReactions, canAddReactions,
canSendMessages, canSendMessages,
canManageMessages, canManageMessages,
isPinned,
onTogglePin,
labels,
onReply, onReply,
onEdit, onEdit,
onDelete, onDelete,
@@ -306,6 +313,21 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
}); });
} }
// ── Pin / Unpin (moderator) ─────────────────────────────────────────────
if (canManageMessages) {
items.push({
key: 'pin',
type: 'action',
label: isPinned ? labels.unpin : labels.pin,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
</svg>
),
onClick: onTogglePin,
});
}
// ── Delete Message (author or moderator) ──────────────────────────────── // ── Delete Message (author or moderator) ────────────────────────────────
const canDelete = isAuthor || canManageMessages; const canDelete = isAuthor || canManageMessages;
if (canDelete) { if (canDelete) {
@@ -1,4 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { UpdateBanner } from '../ui/UpdateBanner';
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 +219,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);
@@ -418,6 +429,7 @@ export function AppLayout() {
(e.g. immediately after Join, or after popping voice-full back to (e.g. immediately after Join, or after popping voice-full back to
the root). */} the root). */}
<SoundController /> <SoundController />
<UpdateBanner />
<GlobalAudioRenderer /> <GlobalAudioRenderer />
<NotificationController /> <NotificationController />
<UpdateToast /> <UpdateToast />
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
@@ -25,6 +26,7 @@ import type { User } from '@backspace/shared';
import { Tooltip } from '../ui/Tooltip'; import { Tooltip } from '../ui/Tooltip';
import { joinVoiceChannel } from '../../utils/voice'; import { joinVoiceChannel } from '../../utils/voice';
import { SearchPopover } from '../chat/SearchPopover'; import { SearchPopover } from '../chat/SearchPopover';
import { PinsPopover } from '../chat/PinsPopover';
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore'; import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
export function MainContent() { export function MainContent() {
@@ -50,7 +52,9 @@ export function MainContent() {
const voiceContainerRef = useRef<HTMLDivElement>(null); const voiceContainerRef = useRef<HTMLDivElement>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null); const searchButtonRef = useRef<HTMLButtonElement>(null);
const t = useT();
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null); const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
// Resolve the DM header's "first other" member through the canonical view // Resolve the DM header's "first other" member through the canonical view
@@ -505,6 +509,24 @@ export function MainContent() {
</button> </button>
<TransferIndicator /> <TransferIndicator />
<div className="w-[1px] h-5 bg-border-soft mx-1" /> <div className="w-[1px] h-5 bg-border-soft mx-1" />
<div className="relative">
<button
onClick={() => setPinsOpen((v) => !v)}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${pinsOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
title={t('pins.title')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
</svg>
</button>
{pinsOpen && currentChannelId && (
<PinsPopover
channelId={currentChannelId}
onClose={() => setPinsOpen(false)}
onJumpToMessage={(id) => setJumpToMessageId(id)}
/>
)}
</div>
<MemberListToggleButton /> <MemberListToggleButton />
</div> </div>
</div> </div>
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel'; import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
import { StatsPanel } from './spaceSettingsPanels/StatsPanel'; import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
const spacePermissions = useSpaceStore((s) => s.spacePermissions); const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const t = useT(); const t = useT();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview'); const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats' | 'expressions'>('overview');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs'); const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
const isOpen = activeModal === 'spaceSettings'; const isOpen = activeModal === 'spaceSettings';
@@ -339,6 +340,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button> <button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)} )}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button> <button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
)}
</div> </div>
</div> </div>
@@ -378,6 +382,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button> <button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)} )}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button> <button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -406,6 +413,7 @@ export function SpaceSettingsModal() {
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />} {tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />} {tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />} {tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from 'react';
import { api, type SpaceEmoji } from '../../../api/client';
import { useExpressionStore } from '../../../stores/expressionStore';
import { useTransferStore } from '../../../stores/transferStore';
import { waitForTransferAttachment } from '../../../utils/waitForTransfer';
import { useT } from '../../../i18n';
interface ExpressionsPanelProps {
spaceId: string;
}
/** Emoji e figurinha são carregados a cada mensagem: têm de ser leves. */
const MAX_BYTES = 512 * 1024;
type Kind = 'emoji' | 'sticker';
export function ExpressionsPanel({ spaceId }: ExpressionsPanelProps) {
const t = useT();
const emojis = useExpressionStore((s) => s.emojisBySpace.get(spaceId)) ?? [];
const stickers = useExpressionStore((s) => s.stickersBySpace.get(spaceId)) ?? [];
const setEmojis = useExpressionStore((s) => s.setEmojis);
const setStickers = useExpressionStore((s) => s.setStickers);
const [kind, setKind] = useState<Kind>('emoji');
const [pending, setPending] = useState<File | null>(null);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
Promise.all([api.expressions.emojis(spaceId), api.expressions.stickers(spaceId)])
.then(([e, s]) => {
if (cancelled) return;
setEmojis(spaceId, e.emojis);
setStickers(spaceId, s.stickers);
})
.catch(() => { /* lista vazia é o fallback honesto */ });
return () => { cancelled = true; };
}, [spaceId, setEmojis, setStickers]);
const pickFile = (file: File) => {
setError('');
if (file.size > MAX_BYTES) {
setError(t('expressions.tooLarge'));
return;
}
setPending(file);
// Sugere o nome do arquivo já no formato aceito, para o caso comum não
// exigir digitação nenhuma.
const base = file.name.replace(/\.[^.]+$/, '');
setName(kind === 'emoji' ? base.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 32) : base.slice(0, 32));
};
const cancel = () => {
setPending(null);
setName('');
if (fileRef.current) fileRef.current.value = '';
};
const confirm = async () => {
const file = pending;
const trimmed = name.trim();
if (!file || !trimmed) return;
setBusy(true);
setError('');
try {
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
const { filename } = await waitForTransferAttachment(tid);
if (kind === 'emoji') {
const created = await api.expressions.addEmoji(spaceId, trimmed, filename);
setEmojis(spaceId, [...emojis, created]);
} else {
const created = await api.expressions.addSticker(spaceId, trimmed, filename);
setStickers(spaceId, [...stickers, created]);
}
cancel();
} catch (err) {
// 409 é nome repetido — mensagem específica, porque a ação corretiva é
// outra: mudar o nome, não trocar a imagem.
const conflict = (err as { statusCode?: number } | undefined)?.statusCode === 409;
setError(conflict ? t('expressions.nameTaken') : t('expressions.uploadFailed'));
} finally {
setBusy(false);
}
};
const remove = async (item: SpaceEmoji) => {
const isEmoji = kind === 'emoji';
const previous = isEmoji ? emojis : stickers;
const next = previous.filter((x) => x.id !== item.id);
if (isEmoji) setEmojis(spaceId, next); else setStickers(spaceId, next);
try {
await (isEmoji ? api.expressions.removeEmoji(item.id) : api.expressions.removeSticker(item.id));
} catch {
if (isEmoji) setEmojis(spaceId, previous); else setStickers(spaceId, previous);
}
};
const items = kind === 'emoji' ? emojis : stickers;
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('expressions.title')}</h2>
<div className="flex gap-1.5 mb-5">
{(['emoji', 'sticker'] as Kind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => { setKind(k); cancel(); }}
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
kind === k ? 'bg-accent-primary text-white' : 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
}`}
>
{k === 'emoji' ? t('expressions.emojis') : t('expressions.stickers')}
</button>
))}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="px-3 py-1.5 rounded-md text-[13px] font-medium bg-accent-primary text-white hover:brightness-110 disabled:opacity-50"
>
{kind === 'emoji' ? t('expressions.addEmoji') : t('expressions.addSticker')}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
/>
{error && <p className="text-[12px] text-txt-danger mt-2">{error}</p>}
{pending && (
<div className="mt-3 p-3 rounded-lg bg-surface-elevated/60">
<label className="block text-[11px] text-txt-tertiary mb-1">{t('expressions.namePrompt')}</label>
<input
type="text"
value={name}
maxLength={32}
autoFocus
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' && name.trim()) void confirm();
if (e.key === 'Escape') cancel();
}}
className="input-search w-full mb-1"
/>
{kind === 'emoji' && (
<p className="text-[11px] text-txt-tertiary mb-2">{t('expressions.nameHintEmoji')}</p>
)}
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => void confirm()}
disabled={busy || !name.trim()}
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-accent-primary text-white disabled:opacity-50"
>
{busy ? t('expressions.uploading') : t('expressions.confirm')}
</button>
<button
type="button"
onClick={cancel}
disabled={busy}
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
>
{t('expressions.cancel')}
</button>
</div>
</div>
)}
<div className="mt-5">
{items.length === 0 ? (
<p className="text-[13px] text-txt-tertiary">
{kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')}
</p>
) : (
<div className="grid grid-cols-6 gap-2">
{items.map((item) => (
<div key={item.id} className="relative group">
<div className="aspect-square rounded-lg bg-surface-elevated flex items-center justify-center p-1.5">
<img
src={api.uploads.url(item.filename)}
alt={item.name}
title={kind === 'emoji' ? `:${item.name}:` : item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
<div className="text-[10px] text-txt-tertiary truncate text-center mt-0.5">
{kind === 'emoji' ? `:${item.name}:` : item.name}
</div>
<button
type="button"
onClick={() => void remove(item)}
title={t('expressions.remove')}
aria-label={t('expressions.remove')}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { isElectron } from '../../platform/platform';
import { useT } from '../../i18n';
/**
* Aviso de que uma atualização já baixou e só falta reiniciar.
*
* O processo principal já mostrava uma notificação do sistema, que some sozinha
* e passa despercebida se a pessoa não estiver olhando. Este aviso fica na
* janela até ser atendido ou dispensado.
*/
export function UpdateBanner() {
const t = useT();
const [version, setVersion] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (!isElectron() || !window.backspace?.onUpdateDownloaded) return;
window.backspace.onUpdateDownloaded((info) => {
setVersion(info.version);
// Uma atualização nova reabre o aviso mesmo se a anterior foi dispensada:
// dispensar significa "agora não", não "nunca mais".
setDismissed(false);
});
}, []);
if (!version || dismissed) return null;
return (
<div className="fixed bottom-4 right-4 z-[400] max-w-[320px] glass rounded-xl shadow-xl p-3.5 animate-slide-up">
<div className="text-[13px] font-semibold text-txt-primary">{t('update.ready')}</div>
<p className="text-[12px] text-txt-secondary mt-0.5">
{t('update.readyVersion', { version })}
</p>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => window.backspace?.installUpdate?.()}
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-accent-primary text-white hover:brightness-110"
>
{t('update.restart')}
</button>
<button
type="button"
onClick={() => setDismissed(true)}
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary hover:text-txt-primary"
>
{t('update.later')}
</button>
</div>
</div>
);
}
+17
View File
@@ -519,6 +519,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
} }
break; break;
case 'message_pinned': {
// Atualiza a mensagem já carregada em vez de recarregar o canal: fixar é
// uma mudança de um campo, e recarregar jogaria fora a posição de leitura.
// updateMessage chaveia por message.channelId, então basta achar a
// mensagem na lista daquele canal.
const cs = useChatStore.getState();
const list = cs.messages.get(event.channelId);
const found = list?.find((m) => m.id === event.messageId);
if (found) {
cs.updateMessage({
...found,
pinnedAt: event.pinned ? Date.now() : null,
});
}
break;
}
case 'message_updated': case 'message_updated':
if (!isHome) { if (!isHome) {
normalizeMessageAssets(event.message, origin); normalizeMessageAssets(event.message, origin);
+47
View File
@@ -56,6 +56,53 @@ export const en = {
'accountMenu.copyId': 'Copy User ID', 'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied', 'accountMenu.copied': 'Copied',
// Desktop update
'update.ready': 'Update ready',
'update.readyVersion': 'Version {version} is ready to install.',
'update.restart': 'Restart to update',
'update.later': 'Later',
// Custom emojis and stickers
'expressions.title': 'Emojis & Stickers',
'expressions.emojis': 'Emojis',
'expressions.stickers': 'Stickers',
'expressions.addEmoji': 'Add emoji',
'expressions.addSticker': 'Add sticker',
'expressions.emptyEmojis': 'No custom emojis yet.',
'expressions.emptyStickers': 'No stickers yet.',
'expressions.namePrompt': 'Name',
'expressions.nameHintEmoji': 'Letters, numbers and underscore. Used as :name: in messages.',
'expressions.confirm': 'Add',
'expressions.cancel': 'Cancel',
'expressions.remove': 'Remove',
'expressions.tooLarge': 'Image must be under 512 KB.',
'expressions.uploadFailed': 'Could not add that image. Try another one.',
'expressions.nameTaken': 'That name is already in use in this server.',
'expressions.uploading': 'Uploading…',
'expressions.pickerTitle': 'Stickers',
'expressions.pickerEmpty': 'This server has no stickers yet.',
// Search
'search.placeholder': 'Search messages…',
'search.filters': 'Filters',
'search.from': 'From',
'search.fromPlaceholder': 'username',
'search.has': 'Has',
'search.hasAny': 'Any',
'search.hasFile': 'File',
'search.hasImage': 'Image',
'search.hasLink': 'Link',
'search.before': 'Before',
'search.after': 'After',
'search.hint': 'Tip: type from:name, has:image, before:2026-01-31 straight into the search box.',
// Pinned messages
'pins.pin': 'Pin Message',
'pins.unpin': 'Unpin Message',
'pins.title': 'Pinned Messages',
'pins.empty': 'No pinned messages in this channel yet.',
'pins.limit': 'This channel has reached the pin limit.',
// Sidebar — spaces, channels and members // Sidebar — spaces, channels and members
'sidebar.friends': 'Friends', 'sidebar.friends': 'Friends',
'sidebar.directMessages': 'Direct Messages', 'sidebar.directMessages': 'Direct Messages',
+47
View File
@@ -55,6 +55,53 @@ export const ptBR: Partial<Dictionary> = {
'accountMenu.copyId': 'Copiar ID do usuário', 'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado', 'accountMenu.copied': 'Copiado',
// Atualização do app
'update.ready': 'Atualização pronta',
'update.readyVersion': 'A versão {version} está pronta para instalar.',
'update.restart': 'Reiniciar para atualizar',
'update.later': 'Depois',
// Emojis e figurinhas
'expressions.title': 'Emojis e figurinhas',
'expressions.emojis': 'Emojis',
'expressions.stickers': 'Figurinhas',
'expressions.addEmoji': 'Adicionar emoji',
'expressions.addSticker': 'Adicionar figurinha',
'expressions.emptyEmojis': 'Nenhum emoji próprio ainda.',
'expressions.emptyStickers': 'Nenhuma figurinha ainda.',
'expressions.namePrompt': 'Nome',
'expressions.nameHintEmoji': 'Letras, números e sublinhado. Usado como :nome: nas mensagens.',
'expressions.confirm': 'Adicionar',
'expressions.cancel': 'Cancelar',
'expressions.remove': 'Remover',
'expressions.tooLarge': 'A imagem precisa ter menos de 512 KB.',
'expressions.uploadFailed': 'Não foi possível adicionar essa imagem. Tente outra.',
'expressions.nameTaken': 'Esse nome já está em uso neste servidor.',
'expressions.uploading': 'Enviando…',
'expressions.pickerTitle': 'Figurinhas',
'expressions.pickerEmpty': 'Este servidor ainda não tem figurinhas.',
// Busca
'search.placeholder': 'Buscar mensagens…',
'search.filters': 'Filtros',
'search.from': 'De',
'search.fromPlaceholder': 'nome de usuário',
'search.has': 'Contém',
'search.hasAny': 'Qualquer',
'search.hasFile': 'Arquivo',
'search.hasImage': 'Imagem',
'search.hasLink': 'Link',
'search.before': 'Antes de',
'search.after': 'Depois de',
'search.hint': 'Dica: digite de:nome, contém:imagem, antes:2026-01-31 direto no campo de busca.',
// Mensagens fixadas
'pins.pin': 'Fixar mensagem',
'pins.unpin': 'Desafixar mensagem',
'pins.title': 'Mensagens fixadas',
'pins.empty': 'Nenhuma mensagem fixada neste canal ainda.',
'pins.limit': 'Este canal atingiu o limite de mensagens fixadas.',
// Barra lateral — servidores, canais e membros // Barra lateral — servidores, canais e membros
'sidebar.friends': 'Amigos', 'sidebar.friends': 'Amigos',
'sidebar.directMessages': 'Mensagens diretas', 'sidebar.directMessages': 'Mensagens diretas',
+3 -3
View File
@@ -58,7 +58,7 @@ interface ChatState {
loadMessages: (channelId: string, force?: boolean) => Promise<void>; loadMessages: (channelId: string, force?: boolean) => Promise<void>;
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[], 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;
@@ -319,7 +319,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
}, },
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => { sendMessage: async (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => {
const replyToId = get().replyTo?.id; const replyToId = get().replyTo?.id;
const isDm = isDmChannel(channelId); const isDm = isDmChannel(channelId);
const currentUser = useAuthStore.getState().user; const currentUser = useAuthStore.getState().user;
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (isDm) { if (isDm) {
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
} else { } else {
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId, stickerId });
} }
// Real message will arrive via WebSocket and replace the temp one // Real message will arrive via WebSocket and replace the temp one
} catch { } catch {
@@ -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 ![:trollface:](/api/uploads/trollface.png) 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);
});
});
+39
View File
@@ -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 `![:${emoji.name}:](${urlOf(emoji.filename)})`;
});
})
.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);
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { parseSearchQuery } from './searchQuery';
describe('parseSearchQuery', () => {
it('separa filtro do texto', () => {
expect(parseSearchQuery('from:ana bolo')).toEqual({ text: 'bolo', from: 'ana' });
});
it('aceita as chaves em português', () => {
expect(parseSearchQuery('de:ana antes:2026-01-31')).toEqual({
text: '', from: 'ana', before: '2026-01-31',
});
});
it('traduz os valores de has para o que a API espera', () => {
expect(parseSearchQuery('contém:imagem').has).toBe('image');
expect(parseSearchQuery('has:file').has).toBe('file');
});
it('deixa token desconhecido virar texto de busca', () => {
// Uma URL não pode ser confundida com filtro, senão fica impossível
// procurar por um link que alguém mandou.
expect(parseSearchQuery('olha http://exemplo.com').text).toBe('olha http://exemplo.com');
expect(parseSearchQuery('coisa:aleatoria').text).toBe('coisa:aleatoria');
});
it('ignora filtro sem valor', () => {
expect(parseSearchQuery('from:').text).toBe('from:');
expect(parseSearchQuery('from:').from).toBeUndefined();
});
it('combina vários filtros com o texto', () => {
expect(parseSearchQuery('de:joao contém:link reunião')).toEqual({
text: 'reunião', from: 'joao', has: 'link',
});
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Filtros escritos direto no campo de busca, no estilo `from:fulano`.
*
* O painel de filtros já existia mas fica atrás de um botão, então quase
* ninguém descobria que a busca aceita filtro. Aceitar a mesma coisa digitada
* na consulta torna o recurso visível para quem já tem o hábito de outros apps.
*/
export interface ParsedQuery {
text: string;
from?: string;
has?: string;
before?: string;
after?: string;
}
/** Aceita as chaves em inglês e em português — o app é bilíngue. */
const KEYS: Record<string, keyof Omit<ParsedQuery, 'text'>> = {
from: 'from', de: 'from',
has: 'has', contem: 'has', contém: 'has',
before: 'before', antes: 'before',
after: 'after', depois: 'after',
};
/** Valores de `has:` aceitos em português, mapeados para o que a API espera. */
const HAS_VALUES: Record<string, string> = {
file: 'file', arquivo: 'file',
image: 'image', imagem: 'image',
link: 'link',
};
export function parseSearchQuery(raw: string): ParsedQuery {
const out: ParsedQuery = { text: '' };
const words: string[] = [];
for (const token of raw.split(/\s+/)) {
const match = /^([\p{L}]+):(.+)$/u.exec(token);
const key = match ? KEYS[match[1]!.toLowerCase()] : undefined;
if (!match || !key) {
// Não é filtro conhecido: volta a ser texto de busca, para que uma
// mensagem que contenha "http://" ou "algo:coisa" continue localizável.
if (token) words.push(token);
continue;
}
const value = match[2]!.trim();
if (!value) continue;
out[key] = key === 'has' ? (HAS_VALUES[value.toLowerCase()] ?? value) : value;
}
out.text = words.join(' ').trim();
return out;
}