Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293378293d | ||
|
|
c5abb6fd64 | ||
|
|
1fb61377b9 | ||
|
|
1a1067bbb6 | ||
|
|
71d2109b2f | ||
|
|
7f08384372 | ||
|
|
7d003021d1 | ||
|
|
50a8f12c77 | ||
|
|
4ceb5cd66a | ||
|
|
c4158ee245 | ||
|
|
61c68761df | ||
|
|
c04b8b47eb |
@@ -153,14 +153,38 @@ jobs:
|
|||||||
|
|
||||||
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||||
# não apagarem o trabalho um do outro.
|
# 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")
|
R=$(api GET "/releases/$ID/assets")
|
||||||
if [ "$(code "$R")" = "200" ]; then
|
if [ "$(code "$R")" != "200" ]; then
|
||||||
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
|
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
|
||||||
case "$aname" in
|
exit 1
|
||||||
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
|
fi
|
||||||
echo "removendo anexo antigo: $aname"
|
|
||||||
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
|
# Filtro dentro do jq, comparando o nome direto. A versão anterior
|
||||||
esac
|
# 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
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -181,3 +205,17 @@ jobs:
|
|||||||
done
|
done
|
||||||
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||||
echo "$sent arquivo(s) publicados"
|
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
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
|
||||||
|
client de API e painel no `SearchPopover` já existiam completos. Só faltava
|
||||||
|
tradução, sintaxe embutida (`de:fulano`) e descoberta.
|
||||||
|
|
||||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
escalonados em 0/150/300ms.
|
escalonados em 0/150/300ms.
|
||||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
@@ -52,7 +63,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
|
||||||
|
|
||||||
## Regra de idioma (a partir de 2026-08-31)
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
@@ -72,6 +82,10 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
|||||||
- Configurações → Privacidade
|
- Configurações → Privacidade
|
||||||
- Configurações → Conexões (nasceu bilíngue)
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
- Seletor de GIF
|
- 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)
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
|
||||||
### Fila sugerida de tradução
|
### Fila sugerida de tradução
|
||||||
@@ -83,11 +97,6 @@ modais de convite · configurações restantes · telas de erro
|
|||||||
|
|
||||||
| Problema | Causa provável | Correção |
|
| 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)
|
## 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 |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
|
|
||||||
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
|
|
||||||
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||||
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backspace",
|
"name": "backspace",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@backspace/desktop",
|
"name": "@backspace/desktop",
|
||||||
"version": "1.1.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"description": "Backspace",
|
"description": "Backspace",
|
||||||
|
|||||||
@@ -588,7 +588,9 @@ function createTray(): void {
|
|||||||
|
|
||||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||||
if (!Notification.isSupported()) return;
|
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 ?? (() => {
|
notification.on('click', onClick ?? (() => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
mainWindow?.focus();
|
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,
|
"when": 1788194631751,
|
||||||
"tag": "0015_young_human_fly",
|
"tag": "0015_young_human_fly",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295165743,
|
||||||
|
"tag": "0016_lyrical_freak",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295936825,
|
||||||
|
"tag": "0017_purple_wildside",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,14 @@ export const messages = sqliteTable('messages', {
|
|||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
|
// Pinned state lives on the message rather than in a join table: a message is
|
||||||
|
// pinned in exactly one channel — its own — so a separate table would only
|
||||||
|
// add a join to every pin lookup.
|
||||||
|
pinnedAt: integer('pinned_at'),
|
||||||
|
pinnedBy: text('pinned_by'),
|
||||||
|
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||||
|
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||||
|
stickerId: text('sticker_id'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
replyToFk: foreignKey({
|
replyToFk: foreignKey({
|
||||||
@@ -89,6 +97,10 @@ export const messages = sqliteTable('messages', {
|
|||||||
}).onDelete('set null'),
|
}).onDelete('set null'),
|
||||||
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
||||||
userIdx: index('idx_messages_user_id').on(table.userId),
|
userIdx: index('idx_messages_user_id').on(table.userId),
|
||||||
|
// Listing a channel's pins, newest first, without scanning its history.
|
||||||
|
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
|
||||||
|
// Filtered search: author within a channel, ordered by recency.
|
||||||
|
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
@@ -645,3 +657,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
|||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||||
|
*
|
||||||
|
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||||
|
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||||
|
*/
|
||||||
|
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||||
|
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||||
|
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||||
|
* caminho de render são diferentes.
|
||||||
|
*/
|
||||||
|
export const spaceStickers = sqliteTable('space_stickers', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { spotifyRoutes } from './routes/spotify.js';
|
|||||||
import { auditRoutes } from './routes/audit.js';
|
import { auditRoutes } from './routes/audit.js';
|
||||||
import { statsRoutes } from './routes/stats.js';
|
import { statsRoutes } from './routes/stats.js';
|
||||||
import { soundboardRoutes } from './routes/soundboard.js';
|
import { soundboardRoutes } from './routes/soundboard.js';
|
||||||
|
import { expressionRoutes } from './routes/expressions.js';
|
||||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
@@ -139,6 +140,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(auditRoutes);
|
await app.register(auditRoutes);
|
||||||
await app.register(statsRoutes);
|
await app.register(statsRoutes);
|
||||||
await app.register(soundboardRoutes);
|
await app.register(soundboardRoutes);
|
||||||
|
await app.register(expressionRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||||
|
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
const MAX_EMOJIS_PER_SPACE = 100;
|
||||||
|
const MAX_STICKERS_PER_SPACE = 50;
|
||||||
|
const MAX_NAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||||
|
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||||
|
* delimitar.
|
||||||
|
*/
|
||||||
|
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||||
|
|
||||||
|
function normalizeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||||
|
return NAME_PATTERN.test(name) ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||||
|
function validFilename(raw: unknown): raw is string {
|
||||||
|
return typeof raw === 'string' && raw.length > 0
|
||||||
|
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ emojis: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const name = normalizeName(request.body?.name);
|
||||||
|
if (!name) {
|
||||||
|
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceEmojis).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const emoji = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||||
|
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ stickers: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||||
|
// e aceita acento e espaço — ao contrário do emoji.
|
||||||
|
const name = typeof request.body?.name === 'string'
|
||||||
|
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||||
|
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceStickers).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sticker = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||||
|
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
@@ -145,6 +145,11 @@ export function buildMessageWithUser(
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
editedAt: message.editedAt,
|
editedAt: message.editedAt,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
|
// Carried on every message so the client can show the pin marker without a
|
||||||
|
// second request, and so the pins panel and the timeline agree.
|
||||||
|
pinnedAt: message.pinnedAt,
|
||||||
|
pinnedBy: message.pinnedBy,
|
||||||
|
stickerId: message.stickerId,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
attachments: attachmentRows.map(a => ({
|
attachments: attachmentRows.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -271,7 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds, replyToId } = request.body;
|
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||||
|
|
||||||
const spaceId = getChannelSpaceId(id);
|
const spaceId = getChannelSpaceId(id);
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
@@ -287,18 +292,35 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const db0 = getDb();
|
||||||
|
const db = db0;
|
||||||
|
|
||||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments) {
|
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
let sticker = null;
|
||||||
|
if (stickerId) {
|
||||||
|
sticker = db0.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||||
|
if (!sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||||
|
// servidores que não têm relação nenhuma.
|
||||||
|
if (sticker.spaceId !== spaceId) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContent && !hasAttachments && !sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||||
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -323,6 +345,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
|
stickerId: sticker ? sticker.id : null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -500,4 +523,120 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Pins ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles rows into the shape clients expect. Same batching the channel
|
||||||
|
* listing uses — kept in one place so pins and history cannot drift apart in
|
||||||
|
* what they include.
|
||||||
|
*/
|
||||||
|
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const db = getDb();
|
||||||
|
const userIds = [...new Set(rows.map(m => m.userId))];
|
||||||
|
const userMap = new Map(
|
||||||
|
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageIds = rows.map(m => m.id);
|
||||||
|
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||||
|
for (const att of db.select().from(schema.attachments)
|
||||||
|
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
|
||||||
|
const mid = att.messageId ?? '';
|
||||||
|
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||||
|
attachmentMap.get(mid)!.push(att);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionsMap = fetchReactionsForMessages(messageIds);
|
||||||
|
const embedMap = fetchEmbedsForMessages(messageIds);
|
||||||
|
const replyToMap = fetchReplyToMessages(rows);
|
||||||
|
|
||||||
|
return rows.map(m => {
|
||||||
|
const user = userMap.get(m.userId);
|
||||||
|
if (!user) return null;
|
||||||
|
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||||
|
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
|
||||||
|
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
|
||||||
|
}).filter((m): m is MessageWithUser => m !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
|
||||||
|
const MAX_PINS_PER_CHANNEL = 50;
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const spaceId = getChannelSpaceId(id);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
|
||||||
|
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.select()
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.orderBy(desc(schema.messages.pinnedAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.code(200).send({ messages: assembleMessages(rows) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
if (message.pinnedAt) return reply.code(200).send({ success: true });
|
||||||
|
|
||||||
|
const count = db.select({ id: schema.messages.id })
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.all().length;
|
||||||
|
if (count >= MAX_PINS_PER_CHANNEL) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: Date.now(), pinnedBy: request.userId })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: true,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: null, pinnedBy: null })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: false,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ export interface Message {
|
|||||||
type?: 'user' | 'system';
|
type?: 'user' | 'system';
|
||||||
editedAt: number | null;
|
editedAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
/** Epoch millis when pinned; null when not pinned. */
|
||||||
|
pinnedAt?: number | null;
|
||||||
|
pinnedBy?: string | null;
|
||||||
|
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
|
||||||
|
stickerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithUser extends Message {
|
export interface MessageWithUser extends Message {
|
||||||
@@ -441,6 +446,7 @@ export type ServerEvent =
|
|||||||
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
||||||
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
|
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
|
||||||
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
|
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
|
||||||
|
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
|
||||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||||
| { type: 'member_left'; spaceId: string; userId: string }
|
| { type: 'member_left'; spaceId: string; userId: string }
|
||||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||||
@@ -595,6 +601,8 @@ export interface CreateMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
/** Envio de figurinha; nesse caso `content` fica vazio. */
|
||||||
|
stickerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
|
|||||||
Binary file not shown.
@@ -75,6 +75,17 @@ import type {
|
|||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
|
|
||||||
|
export interface SpaceEmoji {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpaceSticker = SpaceEmoji;
|
||||||
|
|
||||||
export interface SoundboardSound {
|
export interface SoundboardSound {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -321,6 +332,21 @@ export class BackspaceApiClient {
|
|||||||
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly expressions: {
|
||||||
|
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
|
||||||
|
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
|
||||||
|
removeEmoji: (id: string) => Promise<void>;
|
||||||
|
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
|
||||||
|
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
|
||||||
|
removeSticker: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly pins: {
|
||||||
|
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
||||||
|
pin: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
|
unpin: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly audit: {
|
readonly audit: {
|
||||||
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
|
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -751,6 +777,21 @@ export class BackspaceApiClient {
|
|||||||
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.expressions = {
|
||||||
|
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
|
||||||
|
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
|
||||||
|
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
|
||||||
|
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
|
||||||
|
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
|
||||||
|
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pins = {
|
||||||
|
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
||||||
|
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
||||||
|
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
|
||||||
|
};
|
||||||
|
|
||||||
this.audit = {
|
this.audit = {
|
||||||
log: (spaceId: string, before?: string) => {
|
log: (spaceId: string, before?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
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 { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
@@ -51,19 +55,39 @@ export function NotificationController() {
|
|||||||
|
|
||||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||||
if (isInitialMount.current) return;
|
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) {
|
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||||
for (const { message } of newEvents) {
|
for (const { message } of newEvents) {
|
||||||
if (message.userId !== currentUser?.id) {
|
if (message.userId !== currentUser?.id) {
|
||||||
|
if (focused && message.channelId === currentChannel) break;
|
||||||
|
|
||||||
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
||||||
const body = message.content
|
const body = message.content
|
||||||
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
||||||
: 'Sent an attachment';
|
: translate(useLocaleStore.getState().locale, 'notify.attachment');
|
||||||
sendNotification(displayName, body, {
|
|
||||||
|
useInAppNotificationStore.getState().push({
|
||||||
|
title: displayName,
|
||||||
|
body,
|
||||||
|
avatar: message.user?.avatar ?? null,
|
||||||
|
userId: message.user?.id,
|
||||||
channelId: message.channelId,
|
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
|
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 Picker from '@emoji-mart/react';
|
||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
|
|
||||||
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
|
|||||||
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
|
||||||
|
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
|
||||||
|
const customCategories = useMemo(() => {
|
||||||
|
if (!spaceEmojis?.length) return [];
|
||||||
|
return [{
|
||||||
|
id: 'space',
|
||||||
|
name: 'Servidor',
|
||||||
|
emojis: spaceEmojis.map((e) => ({
|
||||||
|
id: e.name,
|
||||||
|
name: e.name,
|
||||||
|
keywords: [e.name],
|
||||||
|
skins: [{ src: api.uploads.url(e.filename) }],
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
}, [spaceEmojis]);
|
||||||
|
|
||||||
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
|
|||||||
<div ref={containerRef} className={wrapperClass}>
|
<div ref={containerRef} className={wrapperClass}>
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
|
custom={customCategories}
|
||||||
onEmojiSelect={onEmojiSelect}
|
onEmojiSelect={onEmojiSelect}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
set="native"
|
set="native"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import { GifPicker } from './GifPicker';
|
import { GifPicker } from './GifPicker';
|
||||||
|
import { StickerPicker } from './StickerPicker';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useDragToClose } from '../../hooks/useDragToClose';
|
import { useDragToClose } from '../../hooks/useDragToClose';
|
||||||
|
|
||||||
export type InputPopoverTab = 'emoji' | 'gif';
|
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
|
||||||
|
|
||||||
interface InputPopoverProps {
|
interface InputPopoverProps {
|
||||||
activeTab: InputPopoverTab;
|
activeTab: InputPopoverTab;
|
||||||
@@ -15,6 +17,8 @@ interface InputPopoverProps {
|
|||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
gifEnabled: boolean;
|
gifEnabled: boolean;
|
||||||
onTabChange: (tab: InputPopoverTab) => void;
|
onTabChange: (tab: InputPopoverTab) => void;
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
hasStickers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SharedTabProps {
|
interface SharedTabProps {
|
||||||
@@ -53,6 +57,7 @@ function DesktopPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -139,6 +144,7 @@ function DesktopPopover({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -155,6 +161,7 @@ function MobileSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
availableTabs,
|
availableTabs,
|
||||||
@@ -221,6 +228,7 @@ function MobileSheet({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
@@ -231,12 +239,17 @@ function MobileSheet({
|
|||||||
export function InputPopover(props: InputPopoverProps) {
|
export function InputPopover(props: InputPopoverProps) {
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
|
|
||||||
|
const t = useT();
|
||||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||||
{ key: 'emoji', label: 'Emoji' },
|
{ key: 'emoji', label: 'Emoji' },
|
||||||
];
|
];
|
||||||
if (props.gifEnabled) {
|
if (props.gifEnabled) {
|
||||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||||
}
|
}
|
||||||
|
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
|
||||||
|
if (props.hasStickers) {
|
||||||
|
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { isCustomEmojiAlt } from '../../utils/customEmoji';
|
||||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
@@ -186,7 +187,22 @@ function buildComponents(): Components {
|
|||||||
hr: () => <hr className="border-border-soft my-2" />,
|
hr: () => <hr className="border-border-soft my-2" />,
|
||||||
|
|
||||||
// Images (in markdown content — not attachments)
|
// Images (in markdown content — not attachments)
|
||||||
img: ({ src, alt }) => (
|
img: ({ src, alt }) => {
|
||||||
|
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
|
||||||
|
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
|
||||||
|
// vez de virar um bloco como uma imagem comum.
|
||||||
|
if (isCustomEmojiAlt(alt ?? undefined)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt ?? ''}
|
||||||
|
title={alt ?? ''}
|
||||||
|
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="mt-1 max-w-[400px]">
|
<div className="mt-1 max-w-[400px]">
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -197,7 +213,8 @@ function buildComponents(): Components {
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Tables (GFM)
|
// Tables (GFM)
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom';
|
||||||
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
@@ -131,6 +135,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const editMessage = useChatStore((s) => s.editMessage);
|
const editMessage = useChatStore((s) => s.editMessage);
|
||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const pending = isPendingMessage(message) ? message.__pending : null;
|
const pending = isPendingMessage(message) ? message.__pending : null;
|
||||||
@@ -230,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
return () => clearTimeout(confirmDeleteTimeout.current);
|
return () => clearTimeout(confirmDeleteTimeout.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
|
||||||
|
// (DM, por exemplo) o texto passa intacto.
|
||||||
|
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const emojiLookup = useExpressionStore((s) => s.emojiByName);
|
||||||
|
const contentWithEmojis = useMemo(() => {
|
||||||
|
if (!message.content || !emojiSpaceId) return message.content ?? '';
|
||||||
|
return renderCustomEmojis(
|
||||||
|
message.content,
|
||||||
|
(name) => emojiLookup(emojiSpaceId, name),
|
||||||
|
(filename) => api.uploads.url(filename),
|
||||||
|
);
|
||||||
|
}, [message.content, emojiSpaceId, emojiLookup]);
|
||||||
|
|
||||||
|
const stickerId = (message as MessageWithUser).stickerId;
|
||||||
|
const sticker = useExpressionStore((s) =>
|
||||||
|
stickerId && emojiSpaceId
|
||||||
|
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const isGifOnly = isGifOnlyMessage(message.content);
|
const isGifOnly = isGifOnlyMessage(message.content);
|
||||||
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
||||||
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
||||||
@@ -329,6 +354,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
setEditContent(message.content ?? '');
|
setEditContent(message.content ?? '');
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
},
|
},
|
||||||
|
isPinned: Boolean((message as MessageWithUser).pinnedAt),
|
||||||
|
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
|
||||||
|
onTogglePin: () => {
|
||||||
|
const pinned = Boolean((message as MessageWithUser).pinnedAt);
|
||||||
|
// Sem atualização otimista: o servidor recusa acima do limite do canal,
|
||||||
|
// e mostrar como fixada antes da confirmação mentiria nesse caso.
|
||||||
|
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
|
||||||
|
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
|
||||||
|
},
|
||||||
onDelete: () => deleteMessage(message.id, channelKey),
|
onDelete: () => deleteMessage(message.id, channelKey),
|
||||||
onReaction: (emoji: string) => toggleReaction(emoji),
|
onReaction: (emoji: string) => toggleReaction(emoji),
|
||||||
onOpenEmojiPicker: () => {
|
onOpenEmojiPicker: () => {
|
||||||
@@ -516,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<>
|
<>
|
||||||
{message.content && (
|
{message.content && (
|
||||||
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
||||||
<MarkdownRenderer content={message.content} />
|
{sticker ? (
|
||||||
|
// Figurinha ocupa a mensagem inteira, sem moldura de
|
||||||
|
// anexo: é o conteúdo, não um arquivo acompanhando texto.
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
title={sticker.name}
|
||||||
|
className="w-[160px] h-[160px] object-contain rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer content={contentWithEmojis} />
|
||||||
|
)}
|
||||||
{message.editedAt && (
|
{message.editedAt && (
|
||||||
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
||||||
)}
|
)}
|
||||||
@@ -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 ${
|
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'
|
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">
|
<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" />
|
<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
|
<button
|
||||||
onClick={() => setReplyTo(message)}
|
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"
|
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">
|
<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" />
|
<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);
|
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"
|
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">
|
<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" />
|
<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 React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
@@ -94,6 +95,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Feature flags
|
// 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);
|
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||||
|
|
||||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||||
@@ -529,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback(
|
const handleEmojiSelect = useCallback(
|
||||||
(emoji: { native: string }) => {
|
(emoji: { native?: string; id?: string }) => {
|
||||||
|
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
|
||||||
|
// o que o render resolve depois para a imagem.
|
||||||
|
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
|
||||||
|
if (!inserted) return;
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
setDraft(channelId, draftText + emoji.native);
|
setDraft(channelId, draftText + inserted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const before = draftText.slice(0, start);
|
const before = draftText.slice(0, start);
|
||||||
const after = draftText.slice(end);
|
const after = draftText.slice(end);
|
||||||
const newContent = before + emoji.native + after;
|
const newContent = before + inserted + after;
|
||||||
setDraft(channelId, newContent);
|
setDraft(channelId, newContent);
|
||||||
|
|
||||||
// Restore cursor position after the emoji
|
// Restore cursor position after the emoji
|
||||||
const newCursorPos = start + emoji.native.length;
|
const newCursorPos = start + inserted.length;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionStart = newCursorPos;
|
textarea.selectionStart = newCursorPos;
|
||||||
@@ -766,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onClose={() => setActivePopover(null)}
|
onClose={() => setActivePopover(null)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
onGifSelect={handleGifSelect}
|
onGifSelect={handleGifSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
hasStickers={Boolean(stickerSpaceId)}
|
||||||
anchorRef={popoverAnchorRef}
|
anchorRef={popoverAnchorRef}
|
||||||
gifEnabled={gifEnabled}
|
gifEnabled={gifEnabled}
|
||||||
onTabChange={setActivePopover}
|
onTabChange={setActivePopover}
|
||||||
@@ -775,7 +793,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
{chatReplyTo && (
|
{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="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">
|
<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">
|
<span className="font-bold">
|
||||||
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -783,7 +801,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => chatSetReplyTo(null)}
|
onClick={() => chatSetReplyTo(null)}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
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">
|
<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" />
|
<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
|
<button
|
||||||
onClick={() => removeStagedTransfer(t.id)}
|
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"
|
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">
|
<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" />
|
<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
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
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"
|
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"
|
title={tr('chat.composer.attach')}
|
||||||
aria-label="Attach file"
|
aria-label={tr('chat.composer.attach')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
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"
|
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Active-upload indicator */}
|
{/* Active-upload indicator */}
|
||||||
{anyActiveOrQueued && (
|
{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">
|
<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" />
|
<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" />
|
<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 && (
|
{failedCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
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
|
{failedCount} failed
|
||||||
</span>
|
</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 ${
|
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'
|
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="GIF"
|
title={tr('chat.composer.gif')}
|
||||||
aria-label="GIF picker"
|
aria-label={tr('chat.composer.gifPicker')}
|
||||||
>
|
>
|
||||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||||
a plain square rather than a GIF picker. Letters reuse the
|
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 ${
|
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'
|
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Emoji"
|
title={tr('chat.composer.emoji')}
|
||||||
aria-label="Emoji picker"
|
aria-label={tr('chat.composer.emojiPicker')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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()}
|
onClick={() => void handleSubmit()}
|
||||||
disabled={anyUnshippable}
|
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"
|
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"
|
aria-label={tr('chat.composer.sendMessage')}
|
||||||
title="Send"
|
title={tr('chat.composer.send')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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 React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { parseSearchQuery } from '../../utils/searchQuery';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
|
||||||
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const t = useT();
|
||||||
const [fromFilter, setFromFilter] = useState('');
|
const [fromFilter, setFromFilter] = useState('');
|
||||||
const [hasFilter, setHasFilter] = useState('');
|
const [hasFilter, setHasFilter] = useState('');
|
||||||
const [beforeFilter, setBeforeFilter] = useState('');
|
const [beforeFilter, setBeforeFilter] = useState('');
|
||||||
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
const doSearch = useCallback(async (searchOffset = 0) => {
|
const doSearch = useCallback(async (searchOffset = 0) => {
|
||||||
const trimmed = query.trim();
|
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
|
||||||
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
|
// quem acabou de escrever está expressando a intenção mais recente.
|
||||||
|
const parsed = parseSearchQuery(query);
|
||||||
|
const trimmed = parsed.text;
|
||||||
|
const effFrom = parsed.from ?? fromFilter;
|
||||||
|
const effHas = parsed.has ?? hasFilter;
|
||||||
|
const effBefore = parsed.before ?? beforeFilter;
|
||||||
|
const effAfter = parsed.after ?? afterFilter;
|
||||||
|
|
||||||
|
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setTotalCount(0);
|
setTotalCount(0);
|
||||||
return;
|
return;
|
||||||
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
const client = getApiForOrigin(origin);
|
const client = getApiForOrigin(origin);
|
||||||
const params = {
|
const params = {
|
||||||
q: trimmed || undefined,
|
q: trimmed || undefined,
|
||||||
from: fromFilter || undefined,
|
from: effFrom || undefined,
|
||||||
has: hasFilter || undefined,
|
has: effHas || undefined,
|
||||||
before: beforeFilter || undefined,
|
before: effBefore || undefined,
|
||||||
after: afterFilter || undefined,
|
after: effAfter || undefined,
|
||||||
offset: searchOffset,
|
offset: searchOffset,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
};
|
};
|
||||||
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search messages..."
|
placeholder={t('search.placeholder')}
|
||||||
className="input-embedded flex-1 text-[14px]"
|
className="input-embedded flex-1 text-[14px]"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fromFilter}
|
value={fromFilter}
|
||||||
onChange={(e) => setFromFilter(e.target.value)}
|
onChange={(e) => setFromFilter(e.target.value)}
|
||||||
placeholder="username"
|
placeholder={t('search.fromPlaceholder')}
|
||||||
className="input-search w-full px-2 py-1 text-[13px]"
|
className="input-search w-full px-2 py-1 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
|
||||||
<select
|
<select
|
||||||
value={hasFilter}
|
value={hasFilter}
|
||||||
onChange={(e) => setHasFilter(e.target.value)}
|
onChange={(e) => setHasFilter(e.target.value)}
|
||||||
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
|
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">{t('search.hasAny')}</option>
|
||||||
<option value="file">File</option>
|
<option value="file">{t('search.hasFile')}</option>
|
||||||
<option value="image">Image</option>
|
<option value="image">{t('search.hasImage')}</option>
|
||||||
<option value="link">Link</option>
|
<option value="link">{t('search.hasLink')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={beforeFilter}
|
value={beforeFilter}
|
||||||
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={afterFilter}
|
value={afterFilter}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
|
interface StickerPickerProps {
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
|
||||||
|
const t = useT();
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
|
||||||
|
const load = useExpressionStore((s) => s.load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spaceId) void load(spaceId);
|
||||||
|
}, [spaceId, load]);
|
||||||
|
|
||||||
|
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
|
||||||
|
// tamanho ao alternar.
|
||||||
|
const rootClass = mobile
|
||||||
|
? 'flex flex-col flex-1 min-h-0 w-full'
|
||||||
|
: 'flex flex-col h-[390px] w-[390px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClass}>
|
||||||
|
<div className="px-3 pt-3 pb-2 shrink-0">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||||
|
{t('expressions.pickerTitle')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
|
||||||
|
{stickers.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{stickers.map((sticker) => (
|
||||||
|
<button
|
||||||
|
key={sticker.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStickerSelect(sticker.id)}
|
||||||
|
title={sticker.name}
|
||||||
|
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ interface MessageMenuParams {
|
|||||||
canAddReactions: boolean;
|
canAddReactions: boolean;
|
||||||
canSendMessages: boolean;
|
canSendMessages: boolean;
|
||||||
canManageMessages: boolean;
|
canManageMessages: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
/** Rótulos traduzidos: este módulo não é um componente e não pode usar o hook. */
|
||||||
|
labels: { pin: string; unpin: string };
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
canAddReactions,
|
canAddReactions,
|
||||||
canSendMessages,
|
canSendMessages,
|
||||||
canManageMessages,
|
canManageMessages,
|
||||||
|
isPinned,
|
||||||
|
onTogglePin,
|
||||||
|
labels,
|
||||||
onReply,
|
onReply,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -306,6 +313,21 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pin / Unpin (moderator) ─────────────────────────────────────────────
|
||||||
|
if (canManageMessages) {
|
||||||
|
items.push({
|
||||||
|
key: 'pin',
|
||||||
|
type: 'action',
|
||||||
|
label: isPinned ? labels.unpin : labels.pin,
|
||||||
|
icon: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: onTogglePin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete Message (author or moderator) ────────────────────────────────
|
// ── Delete Message (author or moderator) ────────────────────────────────
|
||||||
const canDelete = isAuthor || canManageMessages;
|
const canDelete = isAuthor || canManageMessages;
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { SpaceSidebar } from './SpaceSidebar';
|
import { SpaceSidebar } from './SpaceSidebar';
|
||||||
import { ChannelSidebar } from './ChannelSidebar';
|
import { ChannelSidebar } from './ChannelSidebar';
|
||||||
@@ -217,6 +220,15 @@ export function AppLayout() {
|
|||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
useSpotifyActivity();
|
useSpotifyActivity();
|
||||||
|
|
||||||
|
// Emojis e figurinhas do espaço atual, carregados uma vez por espaço: o
|
||||||
|
// render de mensagem consulta o mapa a cada `:nome:`, e buscar por mensagem
|
||||||
|
// viraria uma cascata de requisições.
|
||||||
|
const expressionSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const loadExpressions = useExpressionStore((s) => s.load);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expressionSpaceId) void loadExpressions(expressionSpaceId);
|
||||||
|
}, [expressionSpaceId, loadExpressions]);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
@@ -418,6 +430,8 @@ export function AppLayout() {
|
|||||||
(e.g. immediately after Join, or after popping voice-full back to
|
(e.g. immediately after Join, or after popping voice-full back to
|
||||||
the root). */}
|
the root). */}
|
||||||
<SoundController />
|
<SoundController />
|
||||||
|
<UpdateBanner />
|
||||||
|
<InAppNotifications />
|
||||||
<GlobalAudioRenderer />
|
<GlobalAudioRenderer />
|
||||||
<NotificationController />
|
<NotificationController />
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import type { Channel } from '@backspace/shared';
|
import type { Channel } from '@backspace/shared';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
@@ -26,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
|
|||||||
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
|
const tr = useT();
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
@@ -318,7 +320,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'create-channel',
|
key: 'create-channel',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Channel',
|
label: tr('sidebar.createChannel'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<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" />
|
<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({
|
items.push({
|
||||||
key: 'create-category',
|
key: 'create-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Category',
|
label: tr('sidebar.createCategory'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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" />
|
<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({
|
items.push({
|
||||||
key: 'invite',
|
key: 'invite',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Invite People',
|
label: tr('sidebar.invitePeople'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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({
|
items.push({
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Space Settings',
|
label: tr('sidebar.spaceSettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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',
|
key: 'leave-group',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Leave Group',
|
label: tr('sidebar.leaveGroup'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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 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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Friends</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder nav items */}
|
{/* 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
|
<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
|
<button
|
||||||
onClick={() => openModal('newDm')}
|
onClick={() => openModal('newDm')}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
@@ -584,7 +586,7 @@ export function ChannelSidebar() {
|
|||||||
{/* Channels — dynamic category layout */}
|
{/* 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}>
|
<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 ? (
|
{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 */}
|
{/* Category group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
||||||
{Array.from({ length: 3 }, (_, i) => (
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
@@ -706,7 +708,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'category-settings',
|
key: 'category-settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Category Settings',
|
label: tr('sidebar.categorySettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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',
|
key: 'delete-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Delete Category',
|
label: tr('sidebar.deleteCategory'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -758,7 +760,7 @@ export function ChannelSidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{catChannels.length === 0 && (
|
{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>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-[12px]">Create Channel</span>
|
<span className="text-[12px]">{tr('sidebar.createChannel')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span className="text-[12px]">Create Category</span>
|
<span className="text-[12px]">{tr('sidebar.createCategory')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -845,6 +847,7 @@ function UserAreaPanel({
|
|||||||
onDeafenToggle: () => void;
|
onDeafenToggle: () => void;
|
||||||
onSettingsClick: (tab?: string) => void;
|
onSettingsClick: (tab?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const tr = useT();
|
||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
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"
|
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="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 className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<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 */}
|
{/* Input Volume */}
|
||||||
<div className="px-4 py-3">
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1039,7 +1042,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
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">
|
<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" />
|
<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>
|
</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"
|
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="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 className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<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 */}
|
{/* Output Volume */}
|
||||||
<div className="px-4 py-3">
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1125,7 +1128,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
@@ -25,6 +26,7 @@ import type { User } from '@backspace/shared';
|
|||||||
import { Tooltip } from '../ui/Tooltip';
|
import { Tooltip } from '../ui/Tooltip';
|
||||||
import { joinVoiceChannel } from '../../utils/voice';
|
import { joinVoiceChannel } from '../../utils/voice';
|
||||||
import { SearchPopover } from '../chat/SearchPopover';
|
import { SearchPopover } from '../chat/SearchPopover';
|
||||||
|
import { PinsPopover } from '../chat/PinsPopover';
|
||||||
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
|
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
@@ -50,7 +52,9 @@ export function MainContent() {
|
|||||||
|
|
||||||
const voiceContainerRef = useRef<HTMLDivElement>(null);
|
const voiceContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const searchButtonRef = useRef<HTMLButtonElement>(null);
|
const searchButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const t = useT();
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [pinsOpen, setPinsOpen] = useState(false);
|
||||||
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Resolve the DM header's "first other" member through the canonical view
|
// Resolve the DM header's "first other" member through the canonical view
|
||||||
@@ -505,6 +509,24 @@ export function MainContent() {
|
|||||||
</button>
|
</button>
|
||||||
<TransferIndicator />
|
<TransferIndicator />
|
||||||
<div className="w-[1px] h-5 bg-border-soft mx-1" />
|
<div className="w-[1px] h-5 bg-border-soft mx-1" />
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setPinsOpen((v) => !v)}
|
||||||
|
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${pinsOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
|
||||||
|
title={t('pins.title')}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{pinsOpen && currentChannelId && (
|
||||||
|
<PinsPopover
|
||||||
|
channelId={currentChannelId}
|
||||||
|
onClose={() => setPinsOpen(false)}
|
||||||
|
onJumpToMessage={(id) => setJumpToMessageId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<MemberListToggleButton />
|
<MemberListToggleButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import type { MemberWithUser, Activity } from '@backspace/shared';
|
import type { MemberWithUser, Activity } from '@backspace/shared';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
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);
|
const ownerRole = member.roles?.find(r => r.position > 0);
|
||||||
return {
|
return {
|
||||||
key: '__owner__',
|
key: '__owner__',
|
||||||
label: 'OWNER',
|
label: '__OWNER__',
|
||||||
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
||||||
position: Infinity,
|
position: Infinity,
|
||||||
};
|
};
|
||||||
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
|
|||||||
// No explicit roles — just @everyone
|
// No explicit roles — just @everyone
|
||||||
return {
|
return {
|
||||||
key: '__online__',
|
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,
|
color: undefined,
|
||||||
position: -1,
|
position: -1,
|
||||||
};
|
};
|
||||||
@@ -111,6 +115,7 @@ export function MemberSidebar() {
|
|||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const userActivities = useActivityStore((s) => s.userActivities);
|
const userActivities = useActivityStore((s) => s.userActivities);
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ export function MemberSidebar() {
|
|||||||
return (
|
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">
|
<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 ? (
|
{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 */}
|
{/* Role group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
||||||
{Array.from({ length: 2 }, (_, i) => (
|
{Array.from({ length: 2 }, (_, i) => (
|
||||||
@@ -207,7 +212,11 @@ export function MemberSidebar() {
|
|||||||
{roleGroups.map(([key, group]) => (
|
{roleGroups.map(([key, group]) => (
|
||||||
<div key={key} className="mb-4">
|
<div key={key} className="mb-4">
|
||||||
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
<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>
|
</h3>
|
||||||
{group.members.map((m) => renderMember(m))}
|
{group.members.map((m) => renderMember(m))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
|||||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||||
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||||
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
||||||
|
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||||
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
|
|||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
|
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats' | 'expressions'>('overview');
|
||||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
@@ -339,6 +340,9 @@ export function SpaceSettingsModal() {
|
|||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,6 +382,9 @@ export function SpaceSettingsModal() {
|
|||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -406,6 +413,7 @@ export function SpaceSettingsModal() {
|
|||||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { api, type SpaceEmoji } from '../../../api/client';
|
||||||
|
import { useExpressionStore } from '../../../stores/expressionStore';
|
||||||
|
import { useTransferStore } from '../../../stores/transferStore';
|
||||||
|
import { waitForTransferAttachment } from '../../../utils/waitForTransfer';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
|
interface ExpressionsPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emoji e figurinha são carregados a cada mensagem: têm de ser leves. */
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
type Kind = 'emoji' | 'sticker';
|
||||||
|
|
||||||
|
export function ExpressionsPanel({ spaceId }: ExpressionsPanelProps) {
|
||||||
|
const t = useT();
|
||||||
|
const emojis = useExpressionStore((s) => s.emojisBySpace.get(spaceId)) ?? [];
|
||||||
|
const stickers = useExpressionStore((s) => s.stickersBySpace.get(spaceId)) ?? [];
|
||||||
|
const setEmojis = useExpressionStore((s) => s.setEmojis);
|
||||||
|
const setStickers = useExpressionStore((s) => s.setStickers);
|
||||||
|
|
||||||
|
const [kind, setKind] = useState<Kind>('emoji');
|
||||||
|
const [pending, setPending] = useState<File | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([api.expressions.emojis(spaceId), api.expressions.stickers(spaceId)])
|
||||||
|
.then(([e, s]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmojis(spaceId, e.emojis);
|
||||||
|
setStickers(spaceId, s.stickers);
|
||||||
|
})
|
||||||
|
.catch(() => { /* lista vazia é o fallback honesto */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [spaceId, setEmojis, setStickers]);
|
||||||
|
|
||||||
|
const pickFile = (file: File) => {
|
||||||
|
setError('');
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
setError(t('expressions.tooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(file);
|
||||||
|
// Sugere o nome do arquivo já no formato aceito, para o caso comum não
|
||||||
|
// exigir digitação nenhuma.
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
setName(kind === 'emoji' ? base.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 32) : base.slice(0, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setPending(null);
|
||||||
|
setName('');
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
const file = pending;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!file || !trimmed) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
if (kind === 'emoji') {
|
||||||
|
const created = await api.expressions.addEmoji(spaceId, trimmed, filename);
|
||||||
|
setEmojis(spaceId, [...emojis, created]);
|
||||||
|
} else {
|
||||||
|
const created = await api.expressions.addSticker(spaceId, trimmed, filename);
|
||||||
|
setStickers(spaceId, [...stickers, created]);
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
// 409 é nome repetido — mensagem específica, porque a ação corretiva é
|
||||||
|
// outra: mudar o nome, não trocar a imagem.
|
||||||
|
const conflict = (err as { statusCode?: number } | undefined)?.statusCode === 409;
|
||||||
|
setError(conflict ? t('expressions.nameTaken') : t('expressions.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: SpaceEmoji) => {
|
||||||
|
const isEmoji = kind === 'emoji';
|
||||||
|
const previous = isEmoji ? emojis : stickers;
|
||||||
|
const next = previous.filter((x) => x.id !== item.id);
|
||||||
|
if (isEmoji) setEmojis(spaceId, next); else setStickers(spaceId, next);
|
||||||
|
try {
|
||||||
|
await (isEmoji ? api.expressions.removeEmoji(item.id) : api.expressions.removeSticker(item.id));
|
||||||
|
} catch {
|
||||||
|
if (isEmoji) setEmojis(spaceId, previous); else setStickers(spaceId, previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = kind === 'emoji' ? emojis : stickers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('expressions.title')}</h2>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-5">
|
||||||
|
{(['emoji', 'sticker'] as Kind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setKind(k); cancel(); }}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||||
|
kind === k ? 'bg-accent-primary text-white' : 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k === 'emoji' ? t('expressions.emojis') : t('expressions.stickers')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[13px] font-medium bg-accent-primary text-white hover:brightness-110 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{kind === 'emoji' ? t('expressions.addEmoji') : t('expressions.addSticker')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-[12px] text-txt-danger mt-2">{error}</p>}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-surface-elevated/60">
|
||||||
|
<label className="block text-[11px] text-txt-tertiary mb-1">{t('expressions.namePrompt')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' && name.trim()) void confirm();
|
||||||
|
if (e.key === 'Escape') cancel();
|
||||||
|
}}
|
||||||
|
className="input-search w-full mb-1"
|
||||||
|
/>
|
||||||
|
{kind === 'emoji' && (
|
||||||
|
<p className="text-[11px] text-txt-tertiary mb-2">{t('expressions.nameHintEmoji')}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t('expressions.uploading') : t('expressions.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('expressions.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
|
{kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="relative group">
|
||||||
|
<div className="aspect-square rounded-lg bg-surface-elevated flex items-center justify-center p-1.5">
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(item.filename)}
|
||||||
|
alt={item.name}
|
||||||
|
title={kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-txt-tertiary truncate text-center mt-0.5">
|
||||||
|
{kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void remove(item)}
|
||||||
|
title={t('expressions.remove')}
|
||||||
|
aria-label={t('expressions.remove')}
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 já baixou e só falta reiniciar.
|
||||||
|
*
|
||||||
|
* O processo principal já mostrava uma notificação do sistema, que some sozinha
|
||||||
|
* e passa despercebida se a pessoa não estiver olhando. Este aviso fica na
|
||||||
|
* janela até ser atendido ou dispensado.
|
||||||
|
*/
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const t = useT();
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron() || !window.backspace?.onUpdateDownloaded) return;
|
||||||
|
window.backspace.onUpdateDownloaded((info) => {
|
||||||
|
setVersion(info.version);
|
||||||
|
// Uma atualização nova reabre o aviso mesmo se a anterior foi dispensada:
|
||||||
|
// dispensar significa "agora não", não "nunca mais".
|
||||||
|
setDismissed(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!version || dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[400] max-w-[320px] glass rounded-xl shadow-xl p-3.5 animate-slide-up">
|
||||||
|
<div className="text-[13px] font-semibold text-txt-primary">{t('update.ready')}</div>
|
||||||
|
<p className="text-[12px] text-txt-secondary mt-0.5">
|
||||||
|
{t('update.readyVersion', { version })}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.backspace?.installUpdate?.()}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-accent-primary text-white hover:brightness-110"
|
||||||
|
>
|
||||||
|
{t('update.restart')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary hover:text-txt-primary"
|
||||||
|
>
|
||||||
|
{t('update.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -519,6 +519,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'message_pinned': {
|
||||||
|
// Atualiza a mensagem já carregada em vez de recarregar o canal: fixar é
|
||||||
|
// uma mudança de um campo, e recarregar jogaria fora a posição de leitura.
|
||||||
|
// updateMessage chaveia por message.channelId, então basta achar a
|
||||||
|
// mensagem na lista daquele canal.
|
||||||
|
const cs = useChatStore.getState();
|
||||||
|
const list = cs.messages.get(event.channelId);
|
||||||
|
const found = list?.find((m) => m.id === event.messageId);
|
||||||
|
if (found) {
|
||||||
|
cs.updateMessage({
|
||||||
|
...found,
|
||||||
|
pinnedAt: event.pinned ? Date.now() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'message_updated':
|
case 'message_updated':
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
normalizeMessageAssets(event.message, origin);
|
normalizeMessageAssets(event.message, origin);
|
||||||
|
|||||||
@@ -56,6 +56,98 @@ export const en = {
|
|||||||
'accountMenu.copyId': 'Copy User ID',
|
'accountMenu.copyId': 'Copy User ID',
|
||||||
'accountMenu.copied': 'Copied',
|
'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
|
// Statistics
|
||||||
'stats.title': 'Statistics',
|
'stats.title': 'Statistics',
|
||||||
'stats.range.7': 'Last 7 days',
|
'stats.range.7': 'Last 7 days',
|
||||||
|
|||||||
@@ -55,6 +55,98 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'accountMenu.copyId': 'Copiar ID do usuário',
|
'accountMenu.copyId': 'Copiar ID do usuário',
|
||||||
'accountMenu.copied': 'Copiado',
|
'accountMenu.copied': 'Copiado',
|
||||||
|
|
||||||
|
// 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
|
// Estatísticas
|
||||||
'stats.title': 'Estatísticas',
|
'stats.title': 'Estatísticas',
|
||||||
'stats.range.7': 'Últimos 7 dias',
|
'stats.range.7': 'Últimos 7 dias',
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ interface ChatState {
|
|||||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||||
clearAllMessages: () => void;
|
clearAllMessages: () => void;
|
||||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
sendMessage: (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => Promise<void>;
|
||||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
@@ -319,7 +319,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
sendMessage: async (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => {
|
||||||
const replyToId = get().replyTo?.id;
|
const replyToId = get().replyTo?.id;
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (isDm) {
|
if (isDm) {
|
||||||
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||||
} else {
|
} else {
|
||||||
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId, stickerId });
|
||||||
}
|
}
|
||||||
// Real message will arrive via WebSocket and replace the temp one
|
// Real message will arrive via WebSocket and replace the temp one
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api, type SpaceEmoji, type SpaceSticker } from '../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis e figurinhas por espaço.
|
||||||
|
*
|
||||||
|
* Carregados uma vez por espaço e mantidos em memória: o render de mensagem
|
||||||
|
* consulta o mapa a cada `:nome:` encontrado, e uma busca por requisição
|
||||||
|
* tornaria cada mensagem uma cascata de chamadas.
|
||||||
|
*/
|
||||||
|
interface ExpressionState {
|
||||||
|
emojisBySpace: Map<string, SpaceEmoji[]>;
|
||||||
|
stickersBySpace: Map<string, SpaceSticker[]>;
|
||||||
|
loaded: Set<string>;
|
||||||
|
load: (spaceId: string) => Promise<void>;
|
||||||
|
emojiByName: (spaceId: string, name: string) => SpaceEmoji | undefined;
|
||||||
|
setEmojis: (spaceId: string, emojis: SpaceEmoji[]) => void;
|
||||||
|
setStickers: (spaceId: string, stickers: SpaceSticker[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExpressionStore = create<ExpressionState>((set, get) => ({
|
||||||
|
emojisBySpace: new Map(),
|
||||||
|
stickersBySpace: new Map(),
|
||||||
|
loaded: new Set(),
|
||||||
|
|
||||||
|
load: async (spaceId) => {
|
||||||
|
if (!spaceId || get().loaded.has(spaceId)) return;
|
||||||
|
// Marcado antes da resposta: entrar num espaço dispara vários renders, e
|
||||||
|
// sem isso a mesma busca sairia várias vezes em paralelo.
|
||||||
|
set((s) => ({ loaded: new Set(s.loaded).add(spaceId) }));
|
||||||
|
try {
|
||||||
|
const [{ emojis }, { stickers }] = await Promise.all([
|
||||||
|
api.expressions.emojis(spaceId),
|
||||||
|
api.expressions.stickers(spaceId),
|
||||||
|
]);
|
||||||
|
set((s) => ({
|
||||||
|
emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis),
|
||||||
|
stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Sem emojis próprios a conversa segue: `:nome:` fica como texto.
|
||||||
|
set((s) => {
|
||||||
|
const loaded = new Set(s.loaded); loaded.delete(spaceId);
|
||||||
|
return { loaded };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emojiByName: (spaceId, name) =>
|
||||||
|
get().emojisBySpace.get(spaceId)?.find((e) => e.name === name),
|
||||||
|
|
||||||
|
setEmojis: (spaceId, emojis) =>
|
||||||
|
set((s) => ({ emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis) })),
|
||||||
|
|
||||||
|
setStickers: (spaceId, stickers) =>
|
||||||
|
set((s) => ({ stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers) })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,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  isso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deixa nome desconhecido como texto', () => {
|
||||||
|
expect(renderCustomEmojis('nada :inexistente: aqui', lookup, urlOf))
|
||||||
|
.toBe('nada :inexistente: aqui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não mexe em código em linha', () => {
|
||||||
|
// Quem escreveu entre crases quis mostrar o texto literal.
|
||||||
|
expect(renderCustomEmojis('use `:trollface:` assim', lookup, urlOf))
|
||||||
|
.toBe('use `:trollface:` assim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('não mexe em bloco de código', () => {
|
||||||
|
const src = '```\n:trollface:\n```';
|
||||||
|
expect(renderCustomEmojis(src, lookup, urlOf)).toBe(src);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('devolve o texto intacto quando não há dois-pontos', () => {
|
||||||
|
expect(renderCustomEmojis('sem nada', lookup, urlOf)).toBe('sem nada');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCustomEmojiAlt', () => {
|
||||||
|
it('reconhece o alt de emoji e ignora imagem comum', () => {
|
||||||
|
expect(isCustomEmojiAlt(':trollface:')).toBe(true);
|
||||||
|
expect(isCustomEmojiAlt('foto de praia')).toBe(false);
|
||||||
|
expect(isCustomEmojiAlt(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SpaceEmoji } from '../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Troca `:nome:` pela sintaxe de imagem do markdown, para o renderizador
|
||||||
|
* existente desenhar o emoji próprio sem precisar de plugin.
|
||||||
|
*
|
||||||
|
* Trechos de código são preservados: `:nome:` dentro de crase é texto que a
|
||||||
|
* pessoa quis mostrar literalmente, e virar imagem ali seria destruir o que ela
|
||||||
|
* escreveu.
|
||||||
|
*/
|
||||||
|
export function renderCustomEmojis(
|
||||||
|
content: string,
|
||||||
|
lookup: (name: string) => SpaceEmoji | undefined,
|
||||||
|
urlOf: (filename: string) => string,
|
||||||
|
): string {
|
||||||
|
if (!content.includes(':')) return content;
|
||||||
|
|
||||||
|
// Divide preservando blocos cercados (```) e código em linha (`) —
|
||||||
|
// os delimitadores ficam nos pedaços ímpares e passam intactos.
|
||||||
|
const parts = content.split(/(```[\s\S]*?```|`[^`]*`)/g);
|
||||||
|
|
||||||
|
return parts
|
||||||
|
.map((part, i) => {
|
||||||
|
if (i % 2 === 1) return part;
|
||||||
|
return part.replace(/:([a-z0-9_]{2,32}):/gi, (whole, name: string) => {
|
||||||
|
const emoji = lookup(name.toLowerCase());
|
||||||
|
if (!emoji) return whole;
|
||||||
|
// O alt conserva `:nome:` para que copiar a mensagem devolva o texto
|
||||||
|
// original, e é por ele que o renderizador reconhece o emoji.
|
||||||
|
return `})`;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O renderizador usa isto para dar tamanho de emoji em vez de imagem normal. */
|
||||||
|
export function isCustomEmojiAlt(alt: string | undefined): boolean {
|
||||||
|
return !!alt && /^:[a-z0-9_]{2,32}:$/i.test(alt);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Filtros escritos direto no campo de busca, no estilo `from:fulano`.
|
||||||
|
*
|
||||||
|
* O painel de filtros já existia mas fica atrás de um botão, então quase
|
||||||
|
* ninguém descobria que a busca aceita filtro. Aceitar a mesma coisa digitada
|
||||||
|
* na consulta torna o recurso visível para quem já tem o hábito de outros apps.
|
||||||
|
*/
|
||||||
|
export interface ParsedQuery {
|
||||||
|
text: string;
|
||||||
|
from?: string;
|
||||||
|
has?: string;
|
||||||
|
before?: string;
|
||||||
|
after?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aceita as chaves em inglês e em português — o app é bilíngue. */
|
||||||
|
const KEYS: Record<string, keyof Omit<ParsedQuery, 'text'>> = {
|
||||||
|
from: 'from', de: 'from',
|
||||||
|
has: 'has', contem: 'has', contém: 'has',
|
||||||
|
before: 'before', antes: 'before',
|
||||||
|
after: 'after', depois: 'after',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Valores de `has:` aceitos em português, mapeados para o que a API espera. */
|
||||||
|
const HAS_VALUES: Record<string, string> = {
|
||||||
|
file: 'file', arquivo: 'file',
|
||||||
|
image: 'image', imagem: 'image',
|
||||||
|
link: 'link',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSearchQuery(raw: string): ParsedQuery {
|
||||||
|
const out: ParsedQuery = { text: '' };
|
||||||
|
const words: string[] = [];
|
||||||
|
|
||||||
|
for (const token of raw.split(/\s+/)) {
|
||||||
|
const match = /^([\p{L}]+):(.+)$/u.exec(token);
|
||||||
|
const key = match ? KEYS[match[1]!.toLowerCase()] : undefined;
|
||||||
|
if (!match || !key) {
|
||||||
|
// Não é filtro conhecido: volta a ser texto de busca, para que uma
|
||||||
|
// mensagem que contenha "http://" ou "algo:coisa" continue localizável.
|
||||||
|
if (token) words.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = match[2]!.trim();
|
||||||
|
if (!value) continue;
|
||||||
|
out[key] = key === 'has' ? (HAS_VALUES[value.toLowerCase()] ?? value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.text = words.join(' ').trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user