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; +}