feat(search): inline filters, and translate the search UI

The filters themselves already existed end to end — the server takes q, from,
has, before and after, the API client passes them, and the popover has fields
for each. What it lacked was discovery: the panel sits behind a button, so the
capability was invisible.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:49:05 -03:00
co-authored by Claude Opus 5
parent 4ceb5cd66a
commit 50a8f12c77
6 changed files with 148 additions and 17 deletions
@@ -1,4 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { parseSearchQuery } from '../../utils/searchQuery';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
});
const [query, setQuery] = useState('');
const t = useT();
const [fromFilter, setFromFilter] = useState('');
const [hasFilter, setHasFilter] = useState('');
const [beforeFilter, setBeforeFilter] = useState('');
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
}, [open, onClose]);
const doSearch = useCallback(async (searchOffset = 0) => {
const trimmed = query.trim();
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
// quem acabou de escrever está expressando a intenção mais recente.
const parsed = parseSearchQuery(query);
const trimmed = parsed.text;
const effFrom = parsed.from ?? fromFilter;
const effHas = parsed.has ?? hasFilter;
const effBefore = parsed.before ?? beforeFilter;
const effAfter = parsed.after ?? afterFilter;
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
setResults([]);
setTotalCount(0);
return;
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
const client = getApiForOrigin(origin);
const params = {
q: trimmed || undefined,
from: fromFilter || undefined,
has: hasFilter || undefined,
before: beforeFilter || undefined,
after: afterFilter || undefined,
from: effFrom || undefined,
has: effHas || undefined,
before: effBefore || undefined,
after: effAfter || undefined,
offset: searchOffset,
limit: 25,
};
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
placeholder={t('search.placeholder')}
className="input-embedded flex-1 text-[14px]"
/>
{query && (
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
{showFilters && (
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
<input
type="text"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
placeholder="username"
placeholder={t('search.fromPlaceholder')}
className="input-search w-full px-2 py-1 text-[13px]"
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
<select
value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)}
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
>
<option value="">Any</option>
<option value="file">File</option>
<option value="image">Image</option>
<option value="link">Link</option>
<option value="">{t('search.hasAny')}</option>
<option value="file">{t('search.hasFile')}</option>
<option value="image">{t('search.hasImage')}</option>
<option value="link">{t('search.hasLink')}</option>
</select>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
<input
type="date"
value={beforeFilter}
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
<input
type="date"
value={afterFilter}
+14
View File
@@ -56,6 +56,20 @@ export const en = {
'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied',
// 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',
+14
View File
@@ -55,6 +55,20 @@ export const ptBR: Partial<Dictionary> = {
'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',
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { parseSearchQuery } from './searchQuery';
describe('parseSearchQuery', () => {
it('separa filtro do texto', () => {
expect(parseSearchQuery('from:ana bolo')).toEqual({ text: 'bolo', from: 'ana' });
});
it('aceita as chaves em português', () => {
expect(parseSearchQuery('de:ana antes:2026-01-31')).toEqual({
text: '', from: 'ana', before: '2026-01-31',
});
});
it('traduz os valores de has para o que a API espera', () => {
expect(parseSearchQuery('contém:imagem').has).toBe('image');
expect(parseSearchQuery('has:file').has).toBe('file');
});
it('deixa token desconhecido virar texto de busca', () => {
// Uma URL não pode ser confundida com filtro, senão fica impossível
// procurar por um link que alguém mandou.
expect(parseSearchQuery('olha http://exemplo.com').text).toBe('olha http://exemplo.com');
expect(parseSearchQuery('coisa:aleatoria').text).toBe('coisa:aleatoria');
});
it('ignora filtro sem valor', () => {
expect(parseSearchQuery('from:').text).toBe('from:');
expect(parseSearchQuery('from:').from).toBeUndefined();
});
it('combina vários filtros com o texto', () => {
expect(parseSearchQuery('de:joao contém:link reunião')).toEqual({
text: 'reunião', from: 'joao', has: 'link',
});
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Filtros escritos direto no campo de busca, no estilo `from:fulano`.
*
* O painel de filtros já existia mas fica atrás de um botão, então quase
* ninguém descobria que a busca aceita filtro. Aceitar a mesma coisa digitada
* na consulta torna o recurso visível para quem já tem o hábito de outros apps.
*/
export interface ParsedQuery {
text: string;
from?: string;
has?: string;
before?: string;
after?: string;
}
/** Aceita as chaves em inglês e em português — o app é bilíngue. */
const KEYS: Record<string, keyof Omit<ParsedQuery, 'text'>> = {
from: 'from', de: 'from',
has: 'has', contem: 'has', contém: 'has',
before: 'before', antes: 'before',
after: 'after', depois: 'after',
};
/** Valores de `has:` aceitos em português, mapeados para o que a API espera. */
const HAS_VALUES: Record<string, string> = {
file: 'file', arquivo: 'file',
image: 'image', imagem: 'image',
link: 'link',
};
export function parseSearchQuery(raw: string): ParsedQuery {
const out: ParsedQuery = { text: '' };
const words: string[] = [];
for (const token of raw.split(/\s+/)) {
const match = /^([\p{L}]+):(.+)$/u.exec(token);
const key = match ? KEYS[match[1]!.toLowerCase()] : undefined;
if (!match || !key) {
// Não é filtro conhecido: volta a ser texto de busca, para que uma
// mensagem que contenha "http://" ou "algo:coisa" continue localizável.
if (token) words.push(token);
continue;
}
const value = match[2]!.trim();
if (!value) continue;
out[key] = key === 'has' ? (HAS_VALUES[value.toLowerCase()] ?? value) : value;
}
out.text = words.join(' ').trim();
return out;
}