From 50a8f12c77a1287e1b56a105b92c188157714730 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Tue, 1 Sep 2026 17:49:05 -0300 Subject: [PATCH] feat(search): inline filters, and translate the search UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filters themselves already existed end to end — the server takes q, from, has, before and after, the API client passes them, and the popover has fields for each. What it lacked was discovery: the panel sits behind a button, so the capability was invisible. Typing 'de:fulano' or 'has:image' straight into the search box now applies the same filters. Keys are accepted in both languages, since the app is bilingual, and an unrecognised token falls back to being search text — otherwise a message containing a URL or 'algo:coisa' would become unfindable. Inline filters win over the panel's: whoever just typed one is expressing the more recent intent. Co-Authored-By: Claude Opus 5 --- docs/roadmap-resenha.md | 6 ++- .../web/src/components/chat/SearchPopover.tsx | 43 ++++++++++------ packages/web/src/i18n/locales/en.ts | 14 +++++ packages/web/src/i18n/locales/pt-BR.ts | 14 +++++ packages/web/src/utils/searchQuery.test.ts | 37 ++++++++++++++ packages/web/src/utils/searchQuery.ts | 51 +++++++++++++++++++ 6 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 packages/web/src/utils/searchQuery.test.ts create mode 100644 packages/web/src/utils/searchQuery.ts diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index ebb34bc8..7f13ac44 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -30,6 +30,10 @@ código e os commits seguem em inglês, como o resto do repositório. ## Já existia no código (verificado, não construir de novo) +- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`), + client de API e painel no `SearchPopover` já existiam completos. Só faltava + tradução, sintaxe embutida (`de:fulano`) e descoberta. + - **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce` escalonados em 0/150/300ms. - **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`: @@ -79,6 +83,7 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho. - Chat: composer e mensagens (placeholder, anexos, responder, reações) - Barra lateral: servidores, canais e menus de contexto - Lista de membros (grupos por cargo, carregamento) +- Busca (rótulos, filtros e sintaxe embutida) - Registro de auditoria e Estatísticas (nasceram bilíngues) ### Fila sugerida de tradução @@ -115,7 +120,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.** | Feature | Tamanho | Observação técnica | |---|---|---| | **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` | -| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer | | **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora | | **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros | diff --git a/packages/web/src/components/chat/SearchPopover.tsx b/packages/web/src/components/chat/SearchPopover.tsx index d6870b0b..c8bdbb0a 100644 --- a/packages/web/src/components/chat/SearchPopover.tsx +++ b/packages/web/src/components/chat/SearchPopover.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useT } from '../../i18n'; +import { parseSearchQuery } from '../../utils/searchQuery'; import { createPortal } from 'react-dom'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore'; @@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum }); const [query, setQuery] = useState(''); + const t = useT(); const [fromFilter, setFromFilter] = useState(''); const [hasFilter, setHasFilter] = useState(''); const [beforeFilter, setBeforeFilter] = useState(''); @@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum }, [open, onClose]); const doSearch = useCallback(async (searchOffset = 0) => { - const trimmed = query.trim(); - if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) { + // Filtros digitados na consulta (`de:fulano`) valem sobre os do painel: + // quem acabou de escrever está expressando a intenção mais recente. + const parsed = parseSearchQuery(query); + const trimmed = parsed.text; + const effFrom = parsed.from ?? fromFilter; + const effHas = parsed.has ?? hasFilter; + const effBefore = parsed.before ?? beforeFilter; + const effAfter = parsed.after ?? afterFilter; + + if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) { setResults([]); setTotalCount(0); return; @@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum const client = getApiForOrigin(origin); const params = { q: trimmed || undefined, - from: fromFilter || undefined, - has: hasFilter || undefined, - before: beforeFilter || undefined, - after: afterFilter || undefined, + from: effFrom || undefined, + has: effHas || undefined, + before: effBefore || undefined, + after: effAfter || undefined, offset: searchOffset, limit: 25, }; @@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum type="text" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search messages..." + placeholder={t('search.placeholder')} className="input-embedded flex-1 text-[14px]" /> {query && ( @@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum {showFilters && (
- + setFromFilter(e.target.value)} - placeholder="username" + placeholder={t('search.fromPlaceholder')} className="input-search w-full px-2 py-1 text-[13px]" />
- +
- +
- + = { 'accountMenu.copyId': 'Copiar ID do usuário', 'accountMenu.copied': 'Copiado', + // 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', diff --git a/packages/web/src/utils/searchQuery.test.ts b/packages/web/src/utils/searchQuery.test.ts new file mode 100644 index 00000000..e9dcb9bf --- /dev/null +++ b/packages/web/src/utils/searchQuery.test.ts @@ -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', + }); + }); +}); diff --git a/packages/web/src/utils/searchQuery.ts b/packages/web/src/utils/searchQuery.ts new file mode 100644 index 00000000..df1fbfdf --- /dev/null +++ b/packages/web/src/utils/searchQuery.ts @@ -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> = { + 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 = { + 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; +}