diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 9a1d9ee..ce0858b 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -41,7 +41,8 @@ import type { ReadInfo, ResponseStats, ScopeTarget, - SearchHit, + SearchPage, + SearchQuery, StorageChat, StorageFile, StorageGroupName, @@ -555,14 +556,8 @@ export function getMessageMedia( }); } -export function searchMessages( - query: string, - options: Page & { chat_id?: number } = {} -): Promise { - return request("/search", { - account: true, - query: { query, ...options }, - }); +export function searchMessages(query: SearchQuery): Promise { + return request("/search", { account: true, query: { ...query } }); } export function fetchMedia( diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c29182d..5939f72 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -7,7 +7,9 @@ export type PolicyScopeType = | "default_channel" | "folder" | "chat"; -export type SearchSource = "text" | "stt"; +export type SearchSource = "text" | "stt" | "version"; + +export type SearchChatKind = "private" | "group" | "channel"; export type JobStatus = | "pending" | "running" @@ -281,11 +283,37 @@ export interface MediaVersion { export interface SearchHit { chat_id: number; date: string; + deleted_at: string | null; + edited_at: string | null; extracted_text: string | null; + has_media: boolean; + media_kind: string | null; message_id: number; sender_id: number | null; source: SearchSource; text: string | null; + version_at: string | null; + version_text: string | null; +} + +export interface SearchPage { + hits: SearchHit[]; + next_cursor: string | null; + terms: string[]; +} + +export interface SearchQuery { + chat_id?: number; + cursor?: string; + date_from?: string; + date_to?: string; + deleted?: boolean; + edited?: boolean; + has_media?: boolean; + kind?: SearchChatKind; + limit?: number; + query?: string; + sender_id?: number; } export interface MediaView { diff --git a/frontend/src/lib/components/search/ChatSearchPanel.svelte b/frontend/src/lib/components/search/ChatSearchPanel.svelte index ba0fe3e..b01f866 100644 --- a/frontend/src/lib/components/search/ChatSearchPanel.svelte +++ b/frontend/src/lib/components/search/ChatSearchPanel.svelte @@ -1,69 +1,58 @@ @@ -72,21 +61,43 @@
- {#if loading && hits.length === 0} -
- {:else if hits.length > 0} - {#each hits as hit (`${hit.chat_id}:${hit.message_id}`)} - openHit(hit.message_id)} /> + {#if pager.hits.length > 0} + {#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)} + openHit(hit.message_id)} + terms={pager.terms} + /> {/each} - {:else if query.trim()} - + {#if pager.hasMore} + {#key pager.hits.length} +
pager.more()}>
+ {/key} + {/if} + {#if pager.appending} +
+ {/if} + {:else if pager.loading} +
+ {:else if pager.failed} +
+

{t("search.failed")}

+ +
+ {:else if query.trim() && !typing} + {/if}
@@ -135,6 +146,10 @@ color: var(--color-text-secondary); } + &::-webkit-search-cancel-button { + display: none; + } + &:focus { outline: none; } @@ -146,9 +161,27 @@ padding: 0.25rem 0.4375rem; } + .sentinel { + height: 1px; + } + .loading { display: flex; justify-content: center; - padding: 2rem 0; + padding: 1.5rem 0; + } + + .failed { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2rem 1rem; + + p { + margin: 0; + font-size: 0.9375rem; + color: var(--color-text-secondary); + } } diff --git a/frontend/src/lib/components/search/DiscoverResultItem.svelte b/frontend/src/lib/components/search/DiscoverResultItem.svelte index 87aba8b..20d02ea 100644 --- a/frontend/src/lib/components/search/DiscoverResultItem.svelte +++ b/frontend/src/lib/components/search/DiscoverResultItem.svelte @@ -2,6 +2,7 @@ import { ripple } from "#lib/actions/ripple.js"; import type { DiscoverItem } from "#lib/api/types.js"; import Avatar from "#lib/components/ui/Avatar.svelte"; + import { t } from "#lib/i18n/locale.svelte.js"; interface Props { item: DiscoverItem; @@ -11,29 +12,29 @@ let { item, onclick }: Props = $props(); const title = $derived( - item.title ?? (item.username ? `@${item.username}` : `Чат ${item.chat_id}`) + item.title ?? + (item.username + ? `@${item.username}` + : t("search.chatTitle", { id: item.chat_id })) ); const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat"); const badge = $derived.by(() => { if (item.is_bot) { - return "Бот"; + return t("search.badgeBot"); } if (item.kind === "channel") { - return "Канал"; + return t("search.badgeChannel"); } if (item.kind === "group") { - return "Группа"; + return t("search.badgeGroup"); } - return item.is_contact ? "Контакт" : "Пользователь"; + return t(item.is_contact ? "search.badgeContact" : "search.badgeUser"); }); const status = $derived.by(() => { if (item.message_count > 0) { - return `${item.message_count} сообщений`; + return t("search.messageCount", { count: item.message_count }); } - if (item.tracked) { - return "Отслеживается"; - } - return "Нет в архиве"; + return t(item.tracked ? "search.tracked" : "search.notArchived"); }); diff --git a/frontend/src/lib/components/search/PeriodFilter.svelte b/frontend/src/lib/components/search/PeriodFilter.svelte new file mode 100644 index 0000000..0041ad6 --- /dev/null +++ b/frontend/src/lib/components/search/PeriodFilter.svelte @@ -0,0 +1,202 @@ + + + + + {#snippet child({ + props, + })} + + {/snippet} + + + + {#if since !== null || until !== null} + + {/if} + {#each PRESETS as item (item.days)} + + {/each} +
+
+ + +
+
+
+
+ + diff --git a/frontend/src/lib/components/search/SearchFilters.svelte b/frontend/src/lib/components/search/SearchFilters.svelte new file mode 100644 index 0000000..d6a4dfc --- /dev/null +++ b/frontend/src/lib/components/search/SearchFilters.svelte @@ -0,0 +1,161 @@ + + +
+ {#each FLAGS as item (item.flag)} + + {/each} + + + + + {#snippet child({ + props, + })} + + {/snippet} + + + + {#each KINDS as item (item.label)} + search.setFilter("kind", item.kind)} + > + {t(item.label)} + {#if item.kind === kind} + + {/if} + + {/each} + + + +
+ + diff --git a/frontend/src/lib/components/search/SearchInput.svelte b/frontend/src/lib/components/search/SearchInput.svelte index f3847f5..91de5fc 100644 --- a/frontend/src/lib/components/search/SearchInput.svelte +++ b/frontend/src/lib/components/search/SearchInput.svelte @@ -1,5 +1,6 @@ - + diff --git a/frontend/src/lib/components/search/SearchResults.svelte b/frontend/src/lib/components/search/SearchResults.svelte index 4b3da31..e0d8589 100644 --- a/frontend/src/lib/components/search/SearchResults.svelte +++ b/frontend/src/lib/components/search/SearchResults.svelte @@ -1,74 +1,117 @@ -
- {#if hasChats} - - {#each search.chatHits as chat (chat.chat_id)} - openChat(chat.chat_id)} - selected={chat.chat_id === activeChatId} - /> - {/each} - {/if} +
+ +
+ {#if showChats} + + {#each search.chatHits as chat (chat.chat_id)} + openChat(chat.chat_id)} + selected={chat.chat_id === activeChatId} + /> + {/each} + {/if} - {#if hasPeers} - - {#each search.peerHits as item (item.chat_id)} - openChat(item.chat_id)} /> - {/each} - {/if} + {#if showPeers} + + {#each search.peerHits as item (item.chat_id)} + openChat(item.chat_id)} /> + {/each} + {/if} - {#if search.loading && !hasMessages} -
- {:else if hasMessages} - - {#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)} - openHit(hit.chat_id, hit.message_id)} - /> - {/each} - {/if} + {#if hasMessages} + + {#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)} + ui.requestJump(hit.chat_id, hit.message_id)} + terms={messages.terms} + /> + {/each} + {#if messages.hasMore} + {#key messages.hits.length} +
messages.more()}>
+ {/key} + {/if} + {#if messages.appending} +
+ {/if} + {:else if messages.loading} +
+ {:else if messages.failed} +
+

{t("search.failed")}

+ +
+ {/if} - {#if empty} - - {/if} + {#if empty} + + {/if} +
diff --git a/frontend/src/lib/components/search/SenderFilter.svelte b/frontend/src/lib/components/search/SenderFilter.svelte new file mode 100644 index 0000000..9fa2e34 --- /dev/null +++ b/frontend/src/lib/components/search/SenderFilter.svelte @@ -0,0 +1,254 @@ + + + + + {#snippet child({ + props, + })} + + {/snippet} + + + + +
+ {#if selected !== null && !typed} + + {/if} + {#each people as person (person.id)} + + {/each} + {#if typed && !searching && people.length === 0} +

{t("search.nobody")}

+ {/if} +
+
+
+
+ + diff --git a/frontend/src/lib/format/search.ts b/frontend/src/lib/format/search.ts new file mode 100644 index 0000000..551c440 --- /dev/null +++ b/frontend/src/lib/format/search.ts @@ -0,0 +1,58 @@ +export interface Segment { + hit: boolean; + text: string; +} + +const WORD = /[\p{L}\p{N}_]+/gu; +const SPACE = /\s/; +const PREFIX_MIN = 3; +const REACH = 20; +const LEAD = 10; + +function fold(word: string): string { + return word.toLowerCase().replaceAll("ё", "е"); +} + +function matches(word: string, terms: string[]): boolean { + const folded = fold(word); + return terms.some((term) => + term.length >= PREFIX_MIN ? folded.startsWith(term) : folded === term + ); +} + +function windowed(text: string, terms: string[]): string { + for (const match of text.matchAll(WORD)) { + if (!matches(match[0], terms)) { + continue; + } + if (match.index <= REACH) { + return text; + } + let start = match.index - LEAD; + while (start > 0 && !SPACE.test(text[start - 1])) { + start -= 1; + } + return `…${text.slice(start)}`; + } + return text; +} + +export function highlight(text: string, terms: string[]): Segment[] { + const shown = windowed(text, terms); + const segments: Segment[] = []; + let last = 0; + for (const match of shown.matchAll(WORD)) { + if (!matches(match[0], terms)) { + continue; + } + if (match.index > last) { + segments.push({ hit: false, text: shown.slice(last, match.index) }); + } + segments.push({ hit: true, text: match[0] }); + last = match.index + match[0].length; + } + if (last < shown.length) { + segments.push({ hit: false, text: shown.slice(last) }); + } + return segments; +} diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index 8aa15bf..4ad0854 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -5,6 +5,7 @@ import { feed } from "#lib/i18n/messages/feed.js"; import { menu } from "#lib/i18n/messages/menu.js"; import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; +import { search } from "#lib/i18n/messages/search.js"; import { settings } from "#lib/i18n/messages/settings.js"; import { watches } from "#lib/i18n/messages/watches.js"; @@ -16,6 +17,7 @@ export const messages = { ...menu, ...profile, ...read, + ...search, ...settings, ...watches, }; diff --git a/frontend/src/lib/i18n/messages/search.ts b/frontend/src/lib/i18n/messages/search.ts new file mode 100644 index 0000000..2bad461 --- /dev/null +++ b/frontend/src/lib/i18n/messages/search.ts @@ -0,0 +1,61 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const search = { + "search.anyone": { en: "Anyone", ru: "Все" }, + "search.anyTime": { en: "Any time", ru: "За всё время" }, + "search.badgeBot": { en: "Bot", ru: "Бот" }, + "search.badgeChannel": { en: "Channel", ru: "Канал" }, + "search.badgeContact": { en: "Contact", ru: "Контакт" }, + "search.badgeGroup": { en: "Group", ru: "Группа" }, + "search.badgeUser": { en: "User", ru: "Пользователь" }, + "search.chats": { en: "Chats", ru: "Чаты" }, + "search.chatTitle": { en: "Chat {id}", ru: "Чат {id}" }, + "search.clear": { en: "Clear", ru: "Очистить" }, + "search.deleted": { en: "Deleted", ru: "Удалённые" }, + "search.edited": { en: "Edited", ru: "Изменённые" }, + "search.failed": { + en: "Search did not respond", + ru: "Поиск не ответил", + }, + "search.from": { en: "From", ru: "От кого" }, + "search.fromName": { en: "Name or @username", ru: "Имя или @username" }, + "search.inChat": { en: "Search this chat", ru: "Поиск в чате" }, + "search.kindAll": { en: "All chats", ru: "Все чаты" }, + "search.kindChannel": { en: "Channels", ru: "Каналы" }, + "search.kindGroup": { en: "Groups", ru: "Группы" }, + "search.kindPrivate": { en: "Private", ru: "Личные" }, + "search.markDeleted": { en: "deleted", ru: "удалено" }, + "search.markEdited": { en: "edited", ru: "изменено" }, + "search.media": { en: "Media", ru: "Медиа" }, + "search.messageCount": { + en: { one: "{count} message", other: "{count} messages" }, + ru: { + few: "{count} сообщения", + many: "{count} сообщений", + one: "{count} сообщение", + other: "{count} сообщения", + }, + }, + "search.messages": { en: "Messages", ru: "Сообщения" }, + "search.month": { en: "Past month", ru: "За месяц" }, + "search.nobody": { en: "Nobody found", ru: "Никого не нашлось" }, + "search.notArchived": { en: "Not in the archive", ru: "Нет в архиве" }, + "search.nothing": { en: "Nothing found", ru: "Ничего не найдено" }, + "search.peers": { en: "Contacts and channels", ru: "Контакты и каналы" }, + "search.period": { en: "Period", ru: "Период" }, + "search.periodFrom": { en: "From", ru: "С" }, + "search.periodSince": { en: "since {date}", ru: "с {date}" }, + "search.periodTo": { en: "To", ru: "По" }, + "search.periodUntil": { en: "until {date}", ru: "по {date}" }, + "search.placeholder": { en: "Search", ru: "Поиск" }, + "search.retry": { en: "Try again", ru: "Повторить" }, + "search.today": { en: "Today", ru: "Сегодня" }, + "search.tracked": { en: "Tracked", ru: "Отслеживается" }, + "search.version": { + en: "Found in an earlier version", + ru: "Найдено в прежней версии", + }, + "search.week": { en: "Past week", ru: "За неделю" }, + "search.year": { en: "Past year", ru: "За год" }, + "search.you": { en: "You", ru: "Вы" }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/search.svelte.ts b/frontend/src/lib/stores/search.svelte.ts index d32cb49..629a7d4 100644 --- a/frontend/src/lib/stores/search.svelte.ts +++ b/frontend/src/lib/stores/search.svelte.ts @@ -3,44 +3,214 @@ import { listChats, searchMessages, } from "#lib/api/endpoints.js"; -import type { Chat, DiscoverItem, SearchHit } from "#lib/api/types.js"; +import type { + Chat, + DiscoverItem, + SearchChatKind, + SearchHit, + SearchQuery, +} from "#lib/api/types.js"; +import { accounts } from "#lib/stores/accounts.svelte.js"; +import { goto } from "$app/navigation"; +import { page } from "$app/state"; + +export type SearchFlag = "deleted" | "edited" | "media"; + +export interface SearchFilters { + deleted: boolean; + edited: boolean; + from: number | null; + kind: SearchChatKind | null; + media: boolean; + since: string | null; + until: string | null; +} const DEBOUNCE_MS = 250; -const REMOTE_DEBOUNCE_MS = 700; -const MIN_LENGTH = 1; +const REMOTE_DELAY_MS = 450; const CHAT_LIMIT = 30; +const PAGE_SIZE = 30; +const KINDS = new Set(["private", "group", "channel"]); +const DAY = /^\d{4}-\d{2}-\d{2}$/; +const PARAMS = [ + "q", + "deleted", + "edited", + "media", + "kind", + "from", + "since", + "until", +] as const; -function createSearch() { - let active = $state(false); - let query = $state(""); - let messageHits = $state([]); - let chatHits = $state([]); - let peerResults = $state([]); +type Param = (typeof PARAMS)[number]; + +function day(value: string | null): string | null { + return value && DAY.test(value) ? value : null; +} + +function readFilters( + params: Pick +): SearchFilters { + const kind = params.get("kind"); + const from = Number(params.get("from")); + return { + deleted: params.has("deleted"), + edited: params.has("edited"), + from: Number.isSafeInteger(from) && from !== 0 ? from : null, + kind: kind && KINDS.has(kind) ? (kind as SearchChatKind) : null, + media: params.has("media"), + since: day(params.get("since")), + until: day(params.get("until")), + }; +} + +function isFiltered(filters: SearchFilters): boolean { + return Object.values(filters).some(Boolean); +} + +function toRequest(query: string, filters: SearchFilters): SearchQuery { + return { + date_from: filters.since + ? new Date(`${filters.since}T00:00:00`).toISOString() + : undefined, + date_to: filters.until + ? new Date(`${filters.until}T23:59:59.999`).toISOString() + : undefined, + deleted: filters.deleted || undefined, + edited: filters.edited || undefined, + has_media: filters.media || undefined, + kind: filters.kind ?? undefined, + query: query || undefined, + sender_id: filters.from ?? undefined, + }; +} + +export function createHitPager() { + let hits = $state([]); + let terms = $state([]); + let cursor = $state(null); let loading = $state(false); - let timers: ReturnType[] = []; + let appending = $state(false); + let failed = $state(false); + let base: SearchQuery | null = null; let seq = 0; - const trimmed = $derived(query.trim()); + async function load(current: number, request: SearchQuery) { + failed = false; + try { + const result = await searchMessages({ ...request, limit: PAGE_SIZE }); + if (current !== seq) { + return; + } + hits = request.cursor ? [...hits, ...result.hits] : result.hits; + ({ terms } = result); + cursor = result.next_cursor; + } catch { + if (current === seq) { + failed = true; + } + } finally { + if (current === seq) { + loading = false; + appending = false; + } + } + } + + function run(request: SearchQuery | null) { + seq += 1; + base = request; + cursor = null; + failed = false; + appending = false; + loading = request !== null; + if (request === null) { + hits = []; + terms = []; + return; + } + load(seq, request).catch(() => undefined); + } + + return { + get appending() { + return appending; + }, + get failed() { + return failed; + }, + get hasMore() { + return cursor !== null; + }, + get hits() { + return hits; + }, + get loading() { + return loading; + }, + more() { + if (base === null || cursor === null || loading || appending) { + return; + } + appending = true; + load(seq, { ...base, cursor }).catch(() => undefined); + }, + retry() { + run(base); + }, + run, + get terms() { + return terms; + }, + }; +} + +function createSearch() { + let open = $state(false); + let draft = $state(""); + let pending = $state(false); + let chatHits = $state([]); + let peerResults = $state([]); + let timer: ReturnType | null = null; + let remoteTimer: ReturnType | null = null; + let lastRequest = ""; + let lastText = ""; + let seq = 0; + const messages = createHitPager(); + + const params = $derived(page.url.searchParams); + const urlQuery = $derived(params.get("q") ?? ""); + const filters = $derived(readFilters(params)); + const filtered = $derived(isFiltered(filters)); + const text = $derived(pending ? draft : urlQuery); + const active = $derived(open || text !== "" || filtered); + const request = $derived.by(() => { + const query = urlQuery.trim(); + if (!(query || filtered)) { + return ""; + } + return JSON.stringify([accounts.selectedId, toRequest(query, filters)]); + }); const peerHits = $derived.by(() => { const shown = new Set(chatHits.map((chat) => chat.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id)); }); - async function runMessages(value: string, current: number) { - try { - const hits = await searchMessages(value); - if (current === seq) { - messageHits = hits; - } - } catch { - if (current === seq) { - messageHits = []; - } - } finally { - if (current === seq) { - loading = false; + function write(update: Partial>) { + const url = new URL(page.url.href); + url.searchParams.delete("message"); + for (const [key, value] of Object.entries(update)) { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); } } + if (url.search === page.url.search) { + return Promise.resolve(); + } + return goto(url, { replace: true, reset: false }); } async function runChats(value: string, current: number) { @@ -69,36 +239,41 @@ function createSearch() { } } - function clearTimers() { - for (const timer of timers) { - clearTimeout(timer); - } - timers = []; - } - - function schedule() { - clearTimers(); - const value = trimmed; + function runText(value: string) { seq += 1; const current = seq; - if (value.length < MIN_LENGTH) { - messageHits = []; + if (remoteTimer !== null) { + clearTimeout(remoteTimer); + remoteTimer = null; + } + if (!value) { chatHits = []; peerResults = []; - loading = false; return; } - loading = true; - timers.push( - setTimeout(() => { - runMessages(value, current).catch(() => undefined); - runChats(value, current).catch(() => undefined); - runPeers(value, current, false).catch(() => undefined); - }, DEBOUNCE_MS), - setTimeout(() => { - runPeers(value, current, true).catch(() => undefined); - }, REMOTE_DEBOUNCE_MS) - ); + runChats(value, current).catch(() => undefined); + runPeers(value, current, false).catch(() => undefined); + remoteTimer = setTimeout(() => { + runPeers(value, current, true).catch(() => undefined); + }, REMOTE_DELAY_MS); + } + + function stopTyping() { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + pending = false; + } + + function reset() { + open = false; + draft = ""; + stopTyping(); + lastRequest = ""; + lastText = ""; + messages.run(null); + runText(""); } return { @@ -109,36 +284,82 @@ function createSearch() { return chatHits; }, close() { - active = false; - query = ""; - messageHits = []; - chatHits = []; - peerResults = []; - loading = false; - seq += 1; - clearTimers(); + reset(); + const update = Object.fromEntries(PARAMS.map((key) => [key, null])); + write(update).catch(() => undefined); }, - get loading() { - return loading; + get filtered() { + return filtered; }, - get messageHits() { - return messageHits; + get filters() { + return filters; }, + hitHref(chatId: number, messageId: number): string { + const next = new URLSearchParams({ message: String(messageId) }); + for (const key of PARAMS) { + const value = params.get(key); + if (value) { + next.set(key, value); + } + } + return `/app/${chatId}?${next}`; + }, + messages, open() { - active = true; + open = true; }, get peerHits() { return peerHits; }, - get query() { - return query; + get request() { + return request; + }, + reset, + setFilter(key: "from" | "kind" | "since" | "until", value: string | null) { + write({ [key]: value }).catch(() => undefined); + }, + setPeriod(since: string | null, until: string | null) { + write({ since, until }).catch(() => undefined); }, setQuery(value: string) { - query = value; - schedule(); + draft = value; + pending = true; + if (timer !== null) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = null; + write({ q: draft.trim() ? draft : null }) + .catch(() => undefined) + .finally(() => { + if (timer === null) { + pending = false; + } + }); + }, DEBOUNCE_MS); }, - get trimmed() { - return trimmed; + get showResults() { + return active && (text.trim() !== "" || filtered); + }, + sync(key: string) { + if (key !== lastRequest) { + lastRequest = key; + messages.run(key ? (JSON.parse(key)[1] as SearchQuery) : null); + } + const value = urlQuery.trim(); + if (value !== lastText) { + lastText = value; + runText(value); + } + }, + get text() { + return text; + }, + toggle(flag: SearchFlag) { + write({ [flag]: filters[flag] ? null : "1" }).catch(() => undefined); + }, + get typing() { + return pending; }, }; } diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte index c4016bd..1136082 100644 --- a/frontend/src/routes/app/+layout.svelte +++ b/frontend/src/routes/app/+layout.svelte @@ -60,7 +60,7 @@ shownAccount = id; untrack(() => { ui.closePanel(); - search.close(); + search.reset(); }); }); @@ -112,7 +112,7 @@ {/if} - {#if search.active && search.trimmed} + {#if search.showResults} {:else}