12 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 293378293d fix(ci): match release assets by name, and close the guard block
CI / Build & test (Node 20) (push) Waiting to run
CI / Build & test (Node 24) (push) Waiting to run
CI / Build & test (push) Blocked by required conditions
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
Security / Secret scan (gitleaks) (push) Waiting to run
Security / Dependency scan (OSV-Scanner) (push) Waiting to run
Security / IaC/config scan (Trivy) (push) Waiting to run
Security / License compliance scan (Trivy) (push) Waiting to run
Two defects, both mine, and the second hid the first.

The cleanup built a regex and matched it against the jq line '<id> <name>', so
the anchor in latest.yml could never match — it sits after the id. Only the
.exe was removed, and each publish left another latest.yml behind. Gitea then
served the older one, so the updater kept reporting the previous version as
current: an update that exists and is never offered, with nothing logged
anywhere. Filtering now happens inside jq, comparing the name directly.

The guard meant to catch exactly that was missing its closing fi, so the step
died on a syntax error before reaching it. Verified with bash -n this time,
which is what should have happened before it ever ran on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:14:59 -03:00
devsyncwrldandClaude Opus 5 c5abb6fd64 chore: version 1.3.0
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Carries the silent native notification, which lives in the Electron main
process and therefore only takes effect from a new installer — the reason this
release is needed beyond exercising the restart prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:07:26 -03:00
devsyncwrldandClaude Opus 5 1fb61377b9 feat(notify): in-app notifications with the app's own sound
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
The system balloon carries the OS notification sound, which does not belong to
this app, and it only fired while the window was out of focus — with the app
focused nothing appeared at all.

Notifications now surface inside the window, carry the same synthesised timbre
as the rest of the app's sounds, and clicking one opens the channel. The native
balloon is kept for when the window is not visible, since an in-app card
nobody can see is no notification, but it is now silent: the app plays its own
effect instead.

A focused window is notified only about other channels — announcing the
conversation someone is already reading is noise.

Also fixes the Gitea publish cleanup, which silently deleted nothing: it
interpolated an Actions expression inside a bash , and when the pattern
did not match, the loop passed over every asset. The release ended with two
latest.yml files and the updater served the older one, reporting 1.1.0 as
current — an update that exists but is never offered, with no error anywhere.
The filter is plain bash now, logs what it found, and the job fails if more
than one latest.yml survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:57:02 -03:00
devsyncwrldandClaude Opus 5 1a1067bbb6 chore: version 1.2.0
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Pins, filtered search, custom emojis and stickers, and the in-app restart
prompt. The prompt itself is only visible once an update is offered, so this
release is what makes it observable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:49:23 -03:00
devsyncwrldandClaude Opus 5 71d2109b2f feat(desktop): in-app prompt to restart for an update
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
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
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Clears the two remaining rounds owed under the fork's per-update translation
rule.

Covers channels, direct messages, the context menus, the inline voice settings
and the member list's loading and grouping labels.

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

The composer's placeholder takes the channel as a parameter rather than being
concatenated, since Portuguese puts the channel elsewhere in the sentence than
English does.

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
46 changed files with 10644 additions and 98 deletions
+45 -7
View File
@@ -153,14 +153,38 @@ jobs:
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
#
# Padrão montado em bash puro: a versão anterior interpolava uma
# expressão do Actions dentro de um `case`, e quando ela não casou o
# laço passou em silêncio — a release ficou com dois latest.yml e o
# updater serviu o antigo, dizendo que a versão nova não existia.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" = "200" ]; then
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
case "$aname" in
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
echo "removendo anexo antigo: $aname"
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
esac
if [ "$(code "$R")" != "200" ]; then
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
# Filtro dentro do jq, comparando o nome direto. A versão anterior
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
# podia casar. O laço então só removia os .exe e deixava um latest.yml
# duplicado, que é o arquivo que decide se há atualização.
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
.[]
| select(
if $os == "Windows"
then (.name | endswith(".exe")) or .name == "latest.yml"
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
end
)
| "\(.id) \(.name)"')
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
if [ -n "$OLD" ]; then
printf '%s\n' "$OLD" | while read -r aid aname; do
[ -n "$aid" ] || continue
echo "removendo anexo antigo: $aname"
D=$(api DELETE "/releases/$ID/assets/$aid")
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
done
fi
@@ -181,3 +205,17 @@ jobs:
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
# uma release que parece boa e não atualiza.
if [ "${RUNNER_OS:-}" = "Windows" ]; then
R=$(api GET "/releases/$ID/assets")
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
if [ "$DUP" != "1" ]; then
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
exit 1
fi
echo "latest.yml: 1 cópia, como esperado"
fi
+15 -8
View File
@@ -21,11 +21,22 @@ código e os commits seguem em inglês, como o resto do repositório.
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| 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 |
| 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 |
| 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)
- **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`
escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
@@ -52,7 +63,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| 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 |
| 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)
@@ -72,6 +82,10 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
- Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Busca (rótulos, filtros e sintaxe embutida)
- Registro de auditoria e Estatísticas (nasceram bilíngues)
### Fila sugerida de tradução
@@ -83,11 +97,6 @@ modais de convite · configurações restantes · telas de erro
| Problema | Causa provável | Correção |
|---|---|---|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
## App desktop — já existia (verificado 2026-08-31)
@@ -112,8 +121,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| 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 |
| **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 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backspace",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backspace/desktop",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Backspace",
+3 -1
View File
@@ -588,7 +588,9 @@ function createTray(): void {
function showNotification(title: string, body: string, onClick?: () => void): void {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body, silent: false });
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
// o app toca o seu próprio efeito — que combina com os demais sons dele.
const notification = new Notification({ title, body, silent: true });
notification.on('click', onClick ?? (() => {
mainWindow?.show();
mainWindow?.focus();
@@ -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,
"tag": "0015_young_human_fly",
"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'),
content: text('content'),
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(),
}, (table) => ({
replyToFk: foreignKey({
@@ -89,6 +97,10 @@ export const messages = sqliteTable('messages', {
}).onDelete('set null'),
channelIdx: index('idx_messages_channel_id').on(table.channelId),
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', {
@@ -645,3 +657,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
}, (table) => ({
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 { statsRoutes } from './routes/stats.js';
import { soundboardRoutes } from './routes/soundboard.js';
import { expressionRoutes } from './routes/expressions.js';
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js';
@@ -139,6 +140,7 @@ async function main(): Promise<void> {
await app.register(auditRoutes);
await app.register(statsRoutes);
await app.register(soundboardRoutes);
await app.register(expressionRoutes);
await app.register(socialRoutes);
await app.register(settingsRoutes);
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 { 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 { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -145,6 +145,11 @@ export function buildMessageWithUser(
content: message.content,
editedAt: message.editedAt,
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),
attachments: attachmentRows.map(a => ({
id: a.id,
@@ -271,7 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
const spaceId = getChannelSpaceId(id);
if (!spaceId) {
@@ -287,18 +292,35 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
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 hasAttachments = attachmentIds && attachmentIds.length > 0;
if (!hasContent && !hasAttachments) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
// Figurinha vale como conteúdo: a mensagem é a figurinha.
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) {
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 now = Date.now();
@@ -323,6 +345,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
channelId: id,
userId: request.userId,
replyToId: replyToId || null,
stickerId: sticker ? sticker.id : null,
content: content?.trim() || null,
createdAt: now,
}).run();
@@ -500,4 +523,120 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
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';
editedAt: number | null;
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 {
@@ -441,6 +446,7 @@ export type ServerEvent =
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { 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: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser }
@@ -595,6 +601,8 @@ export interface CreateMessageRequest {
content: string;
attachments?: string[];
replyToId?: string;
/** Envio de figurinha; nesse caso `content` fica vazio. */
stickerId?: string;
}
export interface UpdateMessageRequest {
Binary file not shown.
+41
View File
@@ -75,6 +75,17 @@ import type {
} from '@backspace/shared';
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 {
id: string;
spaceId: string;
@@ -321,6 +332,21 @@ export class BackspaceApiClient {
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: {
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}`),
};
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 = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
@@ -1,4 +1,8 @@
import { useEffect, useRef } from 'react';
import { useInAppNotificationStore } from '../stores/inAppNotificationStore';
import { AudioManager } from '../audio/AudioManager';
import { getSfxVolume } from '../utils/sfx';
import { translate, useLocaleStore } from '../i18n';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore';
@@ -51,19 +55,39 @@ export function NotificationController() {
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return;
if (windowFocused.current) return;
// Antes daqui só havia aviso com a janela fora de foco. Agora a janela em
// foco também avisa, mas dentro do app e apenas para outro canal — avisar
// sobre a conversa que a pessoa está lendo seria ruído.
const focused = windowFocused.current;
const currentChannel = state.currentChannelId;
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) {
if (message.userId !== currentUser?.id) {
if (focused && message.channelId === currentChannel) break;
const displayName = message.user?.displayName || message.user?.username || 'Someone';
const body = message.content
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
: 'Sent an attachment';
sendNotification(displayName, body, {
: translate(useLocaleStore.getState().locale, 'notify.attachment');
useInAppNotificationStore.getState().push({
title: displayName,
body,
avatar: message.user?.avatar ?? null,
userId: message.user?.id,
channelId: message.channelId,
});
// Efeito próprio, no lugar do som do sistema. O balão nativo agora
// é silencioso, então este é o único som que toca.
void AudioManager.getInstance().playSound('notification', { volume: getSfxVolume() });
// O balão do sistema só faz sentido quando a janela não está à
// vista: com ela em foco, o aviso dentro do app já cumpre o papel.
if (!focused) {
sendNotification(displayName, body, { channelId: message.channelId });
}
break; // one notification per batch
}
}
@@ -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 data from '@emoji-mart/data';
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
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)
useEffect(() => {
const el = containerRef.current;
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
<div ref={containerRef} className={wrapperClass}>
<Picker
data={data}
custom={customCategories}
onEmojiSelect={onEmojiSelect}
theme="dark"
set="native"
@@ -1,11 +1,13 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { StickerPicker } from './StickerPicker';
import { useUIStore } from '../../stores/uiStore';
import { useDragToClose } from '../../hooks/useDragToClose';
export type InputPopoverTab = 'emoji' | 'gif';
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
interface InputPopoverProps {
activeTab: InputPopoverTab;
@@ -15,6 +17,8 @@ interface InputPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
gifEnabled: boolean;
onTabChange: (tab: InputPopoverTab) => void;
onStickerSelect: (stickerId: string) => void;
hasStickers?: boolean;
}
interface SharedTabProps {
@@ -53,6 +57,7 @@ function DesktopPopover({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
anchorRef,
gifEnabled,
onTabChange,
@@ -139,6 +144,7 @@ function DesktopPopover({
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
</div>
</div>
</div>,
@@ -155,6 +161,7 @@ function MobileSheet({
onClose,
onEmojiSelect,
onGifSelect,
onStickerSelect,
gifEnabled,
onTabChange,
availableTabs,
@@ -221,6 +228,7 @@ function MobileSheet({
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
</div>
</div>
</>,
@@ -231,12 +239,17 @@ function MobileSheet({
export function InputPopover(props: InputPopoverProps) {
const isMobile = useUIStore((s) => s.isMobile);
const t = useT();
const availableTabs: { key: InputPopoverTab; label: string }[] = [
{ key: 'emoji', label: 'Emoji' },
];
if (props.gifEnabled) {
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) {
return <MobileSheet {...props} availableTabs={availableTabs} />;
@@ -1,4 +1,5 @@
import React from 'react';
import { isCustomEmojiAlt } from '../../utils/customEmoji';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Highlight, themes } from 'prism-react-renderer';
@@ -186,7 +187,22 @@ function buildComponents(): Components {
hr: () => <hr className="border-border-soft my-2" />,
// 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]">
<img
src={src}
@@ -197,7 +213,8 @@ function buildComponents(): Components {
crossOrigin="anonymous"
/>
</div>
),
);
},
// Tables (GFM)
table: ({ children }) => (
+51 -5
View File
@@ -1,4 +1,8 @@
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 { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer';
@@ -131,6 +135,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useSpaceStore((s) => s.members);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile);
const pending = isPendingMessage(message) ? message.__pending : null;
@@ -230,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
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 imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
// sourceUrl: the original URL for context menu Copy/Open Link actions
@@ -329,6 +354,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setEditContent(message.content ?? '');
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),
onReaction: (emoji: string) => toggleReaction(emoji),
onOpenEmojiPicker: () => {
@@ -516,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<>
{message.content && (
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
<MarkdownRenderer content={message.content} />
{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 && (
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
)}
@@ -676,7 +722,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Add reaction"
title={tr('chat.message.addReaction')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
@@ -687,7 +733,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<button
onClick={() => setReplyTo(message)}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Reply"
title={tr('chat.message.reply')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
@@ -700,7 +746,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setIsEditing(true);
}}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Edit"
title={tr('chat.message.edit')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useT } from '../../i18n';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
@@ -94,6 +95,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags
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);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -529,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
};
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;
if (!textarea) {
setDraft(channelId, draftText + emoji.native);
setDraft(channelId, draftText + inserted);
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const before = draftText.slice(0, start);
const after = draftText.slice(end);
const newContent = before + emoji.native + after;
const newContent = before + inserted + after;
setDraft(channelId, newContent);
// Restore cursor position after the emoji
const newCursorPos = start + emoji.native.length;
const newCursorPos = start + inserted.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = newCursorPos;
@@ -766,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClose={() => setActivePopover(null)}
onEmojiSelect={handleEmojiSelect}
onGifSelect={handleGifSelect}
onStickerSelect={handleStickerSelect}
hasStickers={Boolean(stickerSpaceId)}
anchorRef={popoverAnchorRef}
gifEnabled={gifEnabled}
onTabChange={setActivePopover}
@@ -775,7 +793,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{chatReplyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
<span className="opacity-60">Replying to</span>
<span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
<span className="font-bold">
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
</span>
@@ -783,7 +801,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => chatSetReplyTo(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
aria-label="Cancel reply"
aria-label={tr('chat.composer.cancelReply')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
@@ -867,7 +885,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => removeStagedTransfer(t.id)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
aria-label="Remove attachment"
aria-label={tr('chat.composer.removeAttachment')}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
@@ -886,8 +904,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button
onClick={() => fileInputRef.current?.click()}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file"
aria-label="Attach file"
title={tr('chat.composer.attach')}
aria-label={tr('chat.composer.attach')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
@@ -915,14 +933,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
placeholder={placeholder ?? tr('chat.composer.placeholder', {
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
})}
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
/>
{/* Active-upload indicator */}
{anyActiveOrQueued && (
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
<div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
@@ -934,7 +954,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{failedCount > 0 && (
<span
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
title="Remove or retry the failed attachment to send"
title={tr('chat.composer.failedAttachment')}
>
{failedCount} failed
</span>
@@ -956,8 +976,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="GIF"
aria-label="GIF picker"
title={tr('chat.composer.gif')}
aria-label={tr('chat.composer.gifPicker')}
>
{/* Outlined badge, not a filled block: the solid rectangle read as
a plain square rather than a GIF picker. Letters reuse the
@@ -977,8 +997,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`}
title="Emoji"
aria-label="Emoji picker"
title={tr('chat.composer.emoji')}
aria-label={tr('chat.composer.emojiPicker')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
@@ -991,8 +1011,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClick={() => void handleSubmit()}
disabled={anyUnshippable}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message"
title="Send"
aria-label={tr('chat.composer.sendMessage')}
title={tr('chat.composer.send')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
@@ -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 { useT } from '../../i18n';
import { parseSearchQuery } from '../../utils/searchQuery';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
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 t = useT();
const [fromFilter, setFromFilter] = useState('');
const [hasFilter, setHasFilter] = useState('');
const [beforeFilter, setBeforeFilter] = useState('');
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
}, [open, onClose]);
const doSearch = useCallback(async (searchOffset = 0) => {
const trimmed = query.trim();
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
// 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([]);
setTotalCount(0);
return;
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
const client = getApiForOrigin(origin);
const params = {
q: trimmed || undefined,
from: fromFilter || undefined,
has: hasFilter || undefined,
before: beforeFilter || undefined,
after: afterFilter || undefined,
from: effFrom || undefined,
has: effHas || undefined,
before: effBefore || undefined,
after: effAfter || undefined,
offset: searchOffset,
limit: 25,
};
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
placeholder={t('search.placeholder')}
className="input-embedded flex-1 text-[14px]"
/>
{query && (
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
{showFilters && (
<div className="mt-2 grid grid-cols-2 gap-2">
<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
type="text"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
placeholder="username"
placeholder={t('search.fromPlaceholder')}
className="input-search w-full px-2 py-1 text-[13px]"
/>
</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
value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)}
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
>
<option value="">Any</option>
<option value="file">File</option>
<option value="image">Image</option>
<option value="link">Link</option>
<option value="">{t('search.hasAny')}</option>
<option value="file">{t('search.hasFile')}</option>
<option value="image">{t('search.hasImage')}</option>
<option value="link">{t('search.hasLink')}</option>
</select>
</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
type="date"
value={beforeFilter}
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
/>
</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
type="date"
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;
canSendMessages: 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;
onEdit: () => void;
onDelete: () => void;
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
canAddReactions,
canSendMessages,
canManageMessages,
isPinned,
onTogglePin,
labels,
onReply,
onEdit,
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) ────────────────────────────────
const canDelete = isAuthor || canManageMessages;
if (canDelete) {
@@ -1,4 +1,7 @@
import React, { useEffect } from 'react';
import { InAppNotifications } from '../ui/InAppNotifications';
import { UpdateBanner } from '../ui/UpdateBanner';
import { useExpressionStore } from '../../stores/expressionStore';
import { useParams, useNavigate } from 'react-router-dom';
import { SpaceSidebar } from './SpaceSidebar';
import { ChannelSidebar } from './ChannelSidebar';
@@ -217,6 +220,15 @@ export function AppLayout() {
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
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 loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
@@ -418,6 +430,8 @@ export function AppLayout() {
(e.g. immediately after Join, or after popping voice-full back to
the root). */}
<SoundController />
<UpdateBanner />
<InAppNotifications />
<GlobalAudioRenderer />
<NotificationController />
<UpdateToast />
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useT } from '../../i18n';
import { useNavigate, useLocation } from 'react-router-dom';
import type { Channel } from '@backspace/shared';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
@@ -26,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
export function ChannelSidebar() {
const tr = useT();
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
@@ -318,7 +320,7 @@ export function ChannelSidebar() {
items.push({
key: 'create-channel',
type: 'action',
label: 'Create Channel',
label: tr('sidebar.createChannel'),
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
@@ -330,7 +332,7 @@ export function ChannelSidebar() {
items.push({
key: 'create-category',
type: 'action',
label: 'Create Category',
label: tr('sidebar.createCategory'),
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
@@ -343,7 +345,7 @@ export function ChannelSidebar() {
items.push({
key: 'invite',
type: 'action',
label: 'Invite People',
label: tr('sidebar.invitePeople'),
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
@@ -355,7 +357,7 @@ export function ChannelSidebar() {
items.push({
key: 'settings',
type: 'action',
label: 'Space Settings',
label: tr('sidebar.spaceSettings'),
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
@@ -373,7 +375,7 @@ export function ChannelSidebar() {
{
key: 'leave-group',
type: 'action',
label: 'Leave Group',
label: tr('sidebar.leaveGroup'),
danger: true,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -453,7 +455,7 @@ export function ChannelSidebar() {
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
</svg>
<span className="font-medium text-[16px]">Friends</span>
<span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
</div>
{/* Placeholder nav items */}
@@ -463,7 +465,7 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
<span className="font-medium text-[16px]">Coming Soon</span>
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div>
<div
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
@@ -471,11 +473,11 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
</svg>
<span className="font-medium text-[16px]">Coming Soon</span>
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">Direct Messages</span>
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">{tr('sidebar.directMessages')}</span>
<button
onClick={() => openModal('newDm')}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
@@ -584,7 +586,7 @@ export function ChannelSidebar() {
{/* Channels — dynamic category layout */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
{showChannelSkeleton ? (
<div className="px-2 pt-3" role="status" aria-label="Loading channels">
<div className="px-2 pt-3" role="status" aria-label={tr('sidebar.loadingChannels')}>
{/* Category group 1 */}
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
{Array.from({ length: 3 }, (_, i) => (
@@ -706,7 +708,7 @@ export function ChannelSidebar() {
{
key: 'category-settings',
type: 'action',
label: 'Category Settings',
label: tr('sidebar.categorySettings'),
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
@@ -717,7 +719,7 @@ export function ChannelSidebar() {
{
key: 'delete-category',
type: 'action',
label: 'Delete Category',
label: tr('sidebar.deleteCategory'),
danger: true,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -758,7 +760,7 @@ export function ChannelSidebar() {
/>
))}
{catChannels.length === 0 && (
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div>
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">{tr('sidebar.noChannels')}</div>
)}
</div>
)}
@@ -777,7 +779,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
<span className="text-[12px]">Create Channel</span>
<span className="text-[12px]">{tr('sidebar.createChannel')}</span>
</button>
)}
<button
@@ -787,7 +789,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
<span className="text-[12px]">Create Category</span>
<span className="text-[12px]">{tr('sidebar.createCategory')}</span>
</button>
</div>
)}
@@ -845,6 +847,7 @@ function UserAreaPanel({
onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void;
}) {
const tr = useT();
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
@@ -959,7 +962,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Input Device</div>
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.inputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1004,7 +1007,7 @@ function UserAreaPanel({
{/* Input Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Input Volume</div>
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.inputVolume')}</div>
<input
type="range"
min={0}
@@ -1039,7 +1042,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
>
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
@@ -1057,7 +1060,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Output Device</div>
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.outputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1102,7 +1105,7 @@ function UserAreaPanel({
{/* Output Volume */}
<div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Output Volume</div>
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.outputVolume')}</div>
<input
type="range"
min={0}
@@ -1125,7 +1128,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
>
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { useLocation } from 'react-router-dom';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
@@ -25,6 +26,7 @@ import type { User } from '@backspace/shared';
import { Tooltip } from '../ui/Tooltip';
import { joinVoiceChannel } from '../../utils/voice';
import { SearchPopover } from '../chat/SearchPopover';
import { PinsPopover } from '../chat/PinsPopover';
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
export function MainContent() {
@@ -50,7 +52,9 @@ export function MainContent() {
const voiceContainerRef = useRef<HTMLDivElement>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const t = useT();
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
// Resolve the DM header's "first other" member through the canonical view
@@ -505,6 +509,24 @@ export function MainContent() {
</button>
<TransferIndicator />
<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 />
</div>
</div>
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useT } from '../../i18n';
import type { MemberWithUser, Activity } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
@@ -21,7 +22,7 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
const ownerRole = member.roles?.find(r => r.position > 0);
return {
key: '__owner__',
label: 'OWNER',
label: '__OWNER__',
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
position: Infinity,
};
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
// No explicit roles — just @everyone
return {
key: '__online__',
label: 'ONLINE',
// Marcado em vez de traduzido aqui: esta funcao nao e um componente e nao
// pode usar o hook. Nomes de cargo passam adiante sem traducao — sao dados
// do usuario, nao interface.
label: '__ONLINE__',
color: undefined,
position: -1,
};
@@ -111,6 +115,7 @@ export function MemberSidebar() {
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile);
const userActivities = useActivityStore((s) => s.userActivities);
@@ -183,7 +188,7 @@ export function MemberSidebar() {
return (
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
{showMemberSkeleton ? (
<div className="px-3 pt-4" role="status" aria-label="Loading members">
<div className="px-3 pt-4" role="status" aria-label={tr('sidebar.loadingMembers')}>
{/* Role group 1 */}
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
{Array.from({ length: 2 }, (_, i) => (
@@ -207,7 +212,11 @@ export function MemberSidebar() {
{roleGroups.map(([key, group]) => (
<div key={key} className="mb-4">
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
{group.label} {group.members.length}
{group.label === '__ONLINE__'
? tr('sidebar.groupOnline')
: group.label === '__OWNER__'
? tr('sidebar.groupOwner')
: group.label} {group.members.length}
</h3>
{group.members.map((m) => renderMember(m))}
</div>
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
import { useT } from '../../i18n';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
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 isOpen = activeModal === 'spaceSettings';
@@ -339,6 +340,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.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>
@@ -378,6 +382,9 @@ export function SpaceSettingsModal() {
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.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>
)}
@@ -406,6 +413,7 @@ export function SpaceSettingsModal() {
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
</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,61 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useInAppNotificationStore, type InAppNotification } from '../../stores/inAppNotificationStore';
import { useChatStore } from '../../stores/chatStore';
import { Avatar } from './Avatar';
/** Tempo na tela antes de sumir sozinha. */
const AUTO_DISMISS_MS = 6000;
function NotificationCard({ item }: { item: InAppNotification }) {
const dismiss = useInAppNotificationStore((s) => s.dismiss);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => dismiss(item.id), AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [item.id, dismiss]);
const open = () => {
if (item.channelId) {
setCurrentChannel(item.channelId);
navigate(`/channels/${item.spaceId || '@me'}/${item.channelId}`);
}
dismiss(item.id);
};
return (
<div className="glass rounded-xl shadow-xl w-[300px] overflow-hidden animate-slide-up">
<button onClick={open} className="w-full text-left flex gap-2.5 p-3 hover:bg-interactive-hover transition-colors">
<Avatar src={item.avatar} name={item.title} size={32} userId={item.userId} />
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-txt-primary truncate">{item.title}</div>
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">{item.body}</div>
</div>
</button>
</div>
);
}
/**
* Avisos dentro da própria janela, no lugar do balão do sistema.
*
* O balão do Windows traz o som do sistema junto e não combina com o resto do
* app. Aqui o efeito sonoro é o mesmo dos outros sons do Backspace, e clicar
* leva direto ao canal.
*/
export function InAppNotifications() {
const items = useInAppNotificationStore((s) => s.items);
if (items.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-[400] flex flex-col gap-2 pointer-events-none">
{items.map((item) => (
<div key={item.id} className="pointer-events-auto">
<NotificationCard item={item} />
</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 baixou e falta reiniciar.
*
* O processo principal 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;
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':
if (!isHome) {
normalizeMessageAssets(event.message, origin);
+92
View File
@@ -56,6 +56,98 @@ export const en = {
'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied',
// In-app notifications
'notify.attachment': 'Sent an attachment',
'notify.jump': 'Open',
// 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.friends': 'Friends',
'sidebar.directMessages': 'Direct Messages',
'sidebar.noChannels': 'No channels',
'sidebar.comingSoon': 'Coming Soon',
'sidebar.loadingChannels': 'Loading channels',
'sidebar.loadingMembers': 'Loading members',
'sidebar.groupOnline': 'ONLINE',
'sidebar.groupOwner': 'OWNER',
'sidebar.createChannel': 'Create Channel',
'sidebar.createCategory': 'Create Category',
'sidebar.categorySettings': 'Category Settings',
'sidebar.deleteCategory': 'Delete Category',
'sidebar.invitePeople': 'Invite People',
'sidebar.spaceSettings': 'Space Settings',
'sidebar.leaveGroup': 'Leave Group',
'sidebar.voiceSettings': 'Voice Settings',
'sidebar.inputDevice': 'Input Device',
'sidebar.inputVolume': 'Input Volume',
'sidebar.outputDevice': 'Output Device',
'sidebar.outputVolume': 'Output Volume',
// Chat — composer and messages
'chat.composer.placeholder': 'Message {channel}',
'chat.composer.attach': 'Attach file',
'chat.composer.emoji': 'Emoji',
'chat.composer.gif': 'GIF',
'chat.composer.gifPicker': 'GIF picker',
'chat.composer.emojiPicker': 'Emoji picker',
'chat.composer.send': 'Send',
'chat.composer.sendMessage': 'Send message',
'chat.composer.uploading': 'Uploading',
'chat.composer.uploadingEllipsis': 'Uploading…',
'chat.composer.removeAttachment': 'Remove attachment',
'chat.composer.failedAttachment': 'Remove or retry the failed attachment to send',
'chat.composer.replyingTo': 'Replying to',
'chat.composer.cancelReply': 'Cancel reply',
'chat.message.addReaction': 'Add reaction',
'chat.message.edit': 'Edit',
'chat.message.reply': 'Reply',
// Statistics
'stats.title': 'Statistics',
'stats.range.7': 'Last 7 days',
+92
View File
@@ -55,6 +55,98 @@ export const ptBR: Partial<Dictionary> = {
'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado',
// Notificações no app
'notify.attachment': 'Enviou um anexo',
'notify.jump': 'Abrir',
// 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
'sidebar.friends': 'Amigos',
'sidebar.directMessages': 'Mensagens diretas',
'sidebar.noChannels': 'Nenhum canal',
'sidebar.comingSoon': 'Em breve',
'sidebar.loadingChannels': 'Carregando canais',
'sidebar.loadingMembers': 'Carregando membros',
'sidebar.groupOnline': 'DISPONÍVEIS',
'sidebar.groupOwner': 'DONO',
'sidebar.createChannel': 'Criar canal',
'sidebar.createCategory': 'Criar categoria',
'sidebar.categorySettings': 'Configurações da categoria',
'sidebar.deleteCategory': 'Excluir categoria',
'sidebar.invitePeople': 'Convidar pessoas',
'sidebar.spaceSettings': 'Configurações do servidor',
'sidebar.leaveGroup': 'Sair do grupo',
'sidebar.voiceSettings': 'Configurações de voz',
'sidebar.inputDevice': 'Dispositivo de entrada',
'sidebar.inputVolume': 'Volume de entrada',
'sidebar.outputDevice': 'Dispositivo de saída',
'sidebar.outputVolume': 'Volume de saída',
// Chat — composer e mensagens
'chat.composer.placeholder': 'Conversar em {channel}',
'chat.composer.attach': 'Anexar arquivo',
'chat.composer.emoji': 'Emoji',
'chat.composer.gif': 'GIF',
'chat.composer.gifPicker': 'Seletor de GIF',
'chat.composer.emojiPicker': 'Seletor de emoji',
'chat.composer.send': 'Enviar',
'chat.composer.sendMessage': 'Enviar mensagem',
'chat.composer.uploading': 'Enviando',
'chat.composer.uploadingEllipsis': 'Enviando…',
'chat.composer.removeAttachment': 'Remover anexo',
'chat.composer.failedAttachment': 'Remova ou tente de novo o anexo que falhou para poder enviar',
'chat.composer.replyingTo': 'Respondendo a',
'chat.composer.cancelReply': 'Cancelar resposta',
'chat.message.addReaction': 'Adicionar reação',
'chat.message.edit': 'Editar',
'chat.message.reply': 'Responder',
// Estatísticas
'stats.title': 'Estatísticas',
'stats.range.7': 'Últimos 7 dias',
+3 -3
View File
@@ -58,7 +58,7 @@ interface ChatState {
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
clearAllMessages: () => void;
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>;
deleteMessage: (messageId: string, channelId: string) => Promise<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 isDm = isDmChannel(channelId);
const currentUser = useAuthStore.getState().user;
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (isDm) {
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
} 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
} 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,30 @@
import { create } from 'zustand';
export interface InAppNotification {
id: string;
title: string;
body: string;
avatar: string | null;
userId?: string;
channelId?: string;
spaceId?: string;
}
interface State {
items: InAppNotification[];
push: (n: Omit<InAppNotification, 'id'>) => void;
dismiss: (id: string) => void;
}
/** Poucas de cada vez: uma pilha longa cobre a conversa em vez de avisar. */
const MAX_VISIBLE = 3;
export const useInAppNotificationStore = create<State>((set) => ({
items: [],
push: (n) =>
set((s) => ({
items: [...s.items, { ...n, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }]
.slice(-MAX_VISIBLE),
})),
dismiss: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}));
@@ -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 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 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;
}
+35
View File
@@ -0,0 +1,35 @@
import math, wave, struct
SR=48000
HARM=(1.00, 0.85, 0.10, 0.02) # timbre medido nas referencias aprovadas
def note(f, dur, tau, amp=1.0):
n=int(SR*dur); o=[]
for i in range(n):
t=i/SR
s=sum(a*math.sin(2*math.pi*f*(k+1)*t) for k,a in enumerate(HARM))
o.append(s*amp*min(1.0,t/0.004)*math.exp(-t/tau))
return o
def mix(layers,total):
b=[0.0]*total
for off,s in layers:
for i,v in enumerate(s):
if off+i<total: b[off+i]+=v
return b
def peak_rms(x, win=int(SR*0.01)):
return max(math.sqrt(sum(v*v for v in x[i:i+win])/win) for i in range(0,len(x)-win,win))
def write(name, buf, target):
g=target/max(1e-9,peak_rms(buf))
pk=max(abs(s*g) for s in buf)
if pk>0.97: g*=0.97/pk
with wave.open(name,'w') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(b''.join(struct.pack('<h',int(max(-1,min(1,s*g))*32767)) for s in buf))
print(f"{name}: pico RMS {peak_rms([s*g for s in buf]):.3f}, {len(buf)/SR:.2f}s")
G4, C5 = 392.00, 523.25
# Duas notas subindo, curtas e discretas: mensagem chega o tempo todo, entao
# tem de ser mais leve que entrar numa call.
write('notification.wav', mix([(0, note(G4,0.34,0.13)), (int(SR*0.065), note(C5,0.36,0.12))], int(SR*0.46)), 0.22)