feat(frontend): search results show deleted, edited and earlier version marks with highlighted words, filter chips live in the url, results load on scroll
This commit is contained in:
1 parent
d45c96c259
commit
9a6bd8a714
15 files changed
+1430
-228
No files matched your search
@@ -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<SearchHit[]> {
|
||||
return request<SearchHit[]>("/search", {
|
||||
account: true,
|
||||
query: { query, ...options },
|
||||
});
|
||||
export function searchMessages(query: SearchQuery): Promise<SearchPage> {
|
||||
return request<SearchPage>("/search", { account: true, query: { ...query } });
|
||||
}
|
||||
|
||||
export function fetchMedia(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,69 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { searchMessages } from "#lib/api/endpoints.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import { untrack } from "svelte";
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { createHitPager, search } from "#lib/stores/search.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { isMobile } from "#lib/viewport.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const pager = createHitPager();
|
||||
let query = $state("");
|
||||
let typing = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const chatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
let query = $state("");
|
||||
let hits = $state<SearchHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
async function run(value: string, id: number) {
|
||||
seq += 1;
|
||||
const current = seq;
|
||||
loading = true;
|
||||
try {
|
||||
const result = await searchMessages(value, { chat_id: id });
|
||||
if (current === seq) {
|
||||
hits = result;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
hits = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
function run() {
|
||||
typing = false;
|
||||
const value = query.trim();
|
||||
pager.run(
|
||||
value && chatId !== null ? { chat_id: chatId, query: value } : null
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chatId !== null) {
|
||||
untrack(run);
|
||||
}
|
||||
});
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.currentTarget as HTMLInputElement).value;
|
||||
const value = query.trim();
|
||||
if (timer) {
|
||||
typing = true;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (value.length === 0 || chatId === null) {
|
||||
seq += 1;
|
||||
hits = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const id = chatId;
|
||||
loading = true;
|
||||
timer = setTimeout(() => {
|
||||
run(value, id).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
timer = setTimeout(run, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function openHit(messageId: number) {
|
||||
if (chatId !== null) {
|
||||
ui.requestJump(chatId, messageId);
|
||||
goto(`/app/${chatId}`);
|
||||
if (chatId === null) {
|
||||
return;
|
||||
}
|
||||
ui.requestJump(chatId, messageId);
|
||||
if (isMobile()) {
|
||||
ui.closePanel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -72,21 +61,43 @@
|
||||
<div class="search-input">
|
||||
<Icon class="chat-search-icon" name="search" size="1.25rem" />
|
||||
<input
|
||||
aria-label={t("search.inChat")}
|
||||
oninput={onInput}
|
||||
placeholder="Поиск в чате"
|
||||
type="text"
|
||||
placeholder={t("search.inChat")}
|
||||
type="search"
|
||||
value={query}
|
||||
>
|
||||
</div>
|
||||
<div class="results custom-scroll">
|
||||
{#if loading && hits.length === 0}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if hits.length > 0}
|
||||
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} />
|
||||
{#if pager.hits.length > 0}
|
||||
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||
inChat
|
||||
onclick={() => openHit(hit.message_id)}
|
||||
terms={pager.terms}
|
||||
/>
|
||||
{/each}
|
||||
{:else if query.trim()}
|
||||
<EmptyState title="Ничего не найдено" />
|
||||
{#if pager.hasMore}
|
||||
{#key pager.hits.length}
|
||||
<div class="sentinel" use:visible={() => pager.more()}></div>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if pager.appending}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{/if}
|
||||
{:else if pager.loading}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if pager.failed}
|
||||
<div class="failed">
|
||||
<p>{t("search.failed")}</p>
|
||||
<Button onclick={() => pager.retry()} variant="text"
|
||||
>{t("search.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if query.trim() && !typing}
|
||||
<EmptyState title={t("search.nothing")} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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");
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { Popover } from "bits-ui";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatDay } from "#lib/format/datetime.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||
import { search } from "#lib/stores/search.svelte.js";
|
||||
|
||||
const PRESETS: { days: number; label: MessageKey }[] = [
|
||||
{ days: 0, label: "search.today" },
|
||||
{ days: 7, label: "search.week" },
|
||||
{ days: 30, label: "search.month" },
|
||||
{ days: 365, label: "search.year" },
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const since = $derived(search.filters.since);
|
||||
const until = $derived(search.filters.until);
|
||||
const today = $derived(localDay(new Date()));
|
||||
|
||||
function localDay(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dayOfMonth = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${dayOfMonth}`;
|
||||
}
|
||||
|
||||
function daysAgo(days: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
return localDay(date);
|
||||
}
|
||||
|
||||
function shown(value: string): string {
|
||||
return formatDay(`${value}T12:00:00`);
|
||||
}
|
||||
|
||||
const preset = $derived(
|
||||
until === null
|
||||
? PRESETS.find((item) => daysAgo(item.days) === since)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (preset) {
|
||||
return t(preset.label);
|
||||
}
|
||||
if (since && until) {
|
||||
return since === until
|
||||
? shown(since)
|
||||
: `${shown(since)} – ${shown(until)}`;
|
||||
}
|
||||
if (since) {
|
||||
return t("search.periodSince", { date: shown(since) });
|
||||
}
|
||||
if (until) {
|
||||
return t("search.periodUntil", { date: shown(until) });
|
||||
}
|
||||
return t("search.period");
|
||||
});
|
||||
|
||||
function apply(from: string | null, to: string | null) {
|
||||
search.setPeriod(from, to);
|
||||
}
|
||||
|
||||
function choose(days: number | null) {
|
||||
apply(days === null ? null : daysAgo(days), null);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function pickSince(event: Event) {
|
||||
apply((event.currentTarget as HTMLInputElement).value || null, until);
|
||||
}
|
||||
|
||||
function pickUntil(event: Event) {
|
||||
apply(since, (event.currentTarget as HTMLInputElement).value || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
class="search-chip"
|
||||
type="button"
|
||||
class:on={since !== null || until !== null}
|
||||
>
|
||||
<span class="chip-label">{label}</span>
|
||||
<Icon name="down" size="0.875rem" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="bg-menu-content search-popover"
|
||||
sideOffset={6}
|
||||
>
|
||||
{#if since !== null || until !== null}
|
||||
<button class="bg-menu-item" onclick={() => choose(null)} type="button">
|
||||
<span class="option">{t("search.anyTime")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#each PRESETS as item (item.days)}
|
||||
<button
|
||||
class="bg-menu-item"
|
||||
onclick={() => choose(item.days)}
|
||||
type="button"
|
||||
>
|
||||
<span class="option">{t(item.label)}</span>
|
||||
{#if preset === item}
|
||||
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="bg-menu-separator"></div>
|
||||
<div class="range">
|
||||
<label>
|
||||
<span>{t("search.periodFrom")}</span>
|
||||
<input
|
||||
max={until ?? today}
|
||||
onchange={pickSince}
|
||||
type="date"
|
||||
value={since ?? ""}
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("search.periodTo")}</span>
|
||||
<input
|
||||
max={today}
|
||||
min={since ?? undefined}
|
||||
onchange={pickUntil}
|
||||
type="date"
|
||||
value={until ?? ""}
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<style lang="scss">
|
||||
button.bg-menu-item {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
span {
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--color-borders-input);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text);
|
||||
color-scheme: light;
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import type { SearchChatKind } from "#lib/api/types.js";
|
||||
import PeriodFilter from "#lib/components/search/PeriodFilter.svelte";
|
||||
import SenderFilter from "#lib/components/search/SenderFilter.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||
import { type SearchFlag, search } from "#lib/stores/search.svelte.js";
|
||||
|
||||
const FLAGS: { flag: SearchFlag; label: MessageKey }[] = [
|
||||
{ flag: "deleted", label: "search.deleted" },
|
||||
{ flag: "edited", label: "search.edited" },
|
||||
{ flag: "media", label: "search.media" },
|
||||
];
|
||||
|
||||
const KINDS: { kind: SearchChatKind | null; label: MessageKey }[] = [
|
||||
{ kind: null, label: "search.kindAll" },
|
||||
{ kind: "private", label: "search.kindPrivate" },
|
||||
{ kind: "group", label: "search.kindGroup" },
|
||||
{ kind: "channel", label: "search.kindChannel" },
|
||||
];
|
||||
|
||||
const kind = $derived(search.filters.kind);
|
||||
const kindLabel = $derived(
|
||||
KINDS.find((item) => item.kind === kind)?.label ?? "search.kindAll"
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="search-filters">
|
||||
{#each FLAGS as item (item.flag)}
|
||||
<button
|
||||
aria-pressed={search.filters[item.flag]}
|
||||
class="search-chip"
|
||||
onclick={() => search.toggle(item.flag)}
|
||||
type="button"
|
||||
class:on={search.filters[item.flag]}
|
||||
>
|
||||
{t(item.label)}
|
||||
</button>
|
||||
{/each}
|
||||
<SenderFilter />
|
||||
<PeriodFilter />
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
class="search-chip"
|
||||
type="button"
|
||||
class:on={kind !== null}
|
||||
>
|
||||
{t(kindLabel)}
|
||||
<Icon name="down" size="0.875rem" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
align="start"
|
||||
class="bg-menu-content search-menu"
|
||||
sideOffset={6}
|
||||
>
|
||||
{#each KINDS as item (item.label)}
|
||||
<DropdownMenu.Item
|
||||
class="bg-menu-item"
|
||||
onSelect={() => search.setFilter("kind", item.kind)}
|
||||
>
|
||||
<span class="option">{t(item.label)}</span>
|
||||
{#if item.kind === kind}
|
||||
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.search-filters {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 0.25rem;
|
||||
|
||||
padding: 0.5rem 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
:global(.search-chip) {
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
max-width: 100%;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 0;
|
||||
border-radius: 0.875rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
|
||||
background-color: var(--color-chat-hover);
|
||||
|
||||
transition:
|
||||
background-color 0.15s ease-out,
|
||||
box-shadow 0.15s ease-out;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(:global(.on)):hover {
|
||||
background-color: var(--color-background-secondary-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 1.125rem;
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.search-chip.on) {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
:global(.search-chip .chip-label) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.search-menu) {
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
:global(.search-menu .search-menu-check) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { search } from "#lib/stores/search.svelte.js";
|
||||
|
||||
let input = $state<HTMLInputElement | null>(null);
|
||||
@@ -8,6 +9,13 @@
|
||||
search.setQuery((event.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
search.close();
|
||||
input?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
search.setQuery("");
|
||||
input?.focus();
|
||||
@@ -17,15 +25,22 @@
|
||||
<div class="search-input">
|
||||
<Icon class="search-icon" name="search" size="1.25rem" />
|
||||
<input
|
||||
aria-label={t("search.placeholder")}
|
||||
onfocus={() => search.open()}
|
||||
oninput={onInput}
|
||||
placeholder="Поиск"
|
||||
type="text"
|
||||
value={search.query}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t("search.placeholder")}
|
||||
type="search"
|
||||
value={search.text}
|
||||
bind:this={input}
|
||||
>
|
||||
{#if search.query}
|
||||
<button aria-label="Очистить" class="clear" onclick={clear} type="button">
|
||||
{#if search.text}
|
||||
<button
|
||||
aria-label={t("search.clear")}
|
||||
class="clear"
|
||||
onclick={clear}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="close" size="1.125rem" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -69,6 +84,10 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -91,5 +110,9 @@
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,81 +2,141 @@
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||
import { mediaKindLabel } from "#lib/format/media.js";
|
||||
import { peerName } from "#lib/format/peer.js";
|
||||
import { highlight } from "#lib/format/search.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { peers } from "#lib/stores/peers.svelte.js";
|
||||
|
||||
interface Props {
|
||||
hit: SearchHit;
|
||||
href: string;
|
||||
inChat?: boolean;
|
||||
onclick: () => void;
|
||||
terms: string[];
|
||||
}
|
||||
|
||||
let { hit, onclick }: Props = $props();
|
||||
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||
|
||||
const chat = $derived(chats.byId(hit.chat_id));
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
||||
|
||||
$effect(() => {
|
||||
chats.ensure(hit.chat_id);
|
||||
const ids: number[] = [];
|
||||
if (hit.chat_id > 0) {
|
||||
ids.push(hit.chat_id);
|
||||
}
|
||||
if (hit.sender_id !== null) {
|
||||
ids.push(hit.sender_id);
|
||||
}
|
||||
const ids = [hit.chat_id, hit.sender_id].filter(
|
||||
(id): id is number => id !== null && id > 0
|
||||
);
|
||||
if (ids.length > 0) {
|
||||
peers.ensure(ids);
|
||||
}
|
||||
});
|
||||
|
||||
const title = $derived.by(() => {
|
||||
const chatTitle = $derived.by(() => {
|
||||
if (chat?.title) {
|
||||
return chat.title;
|
||||
}
|
||||
if (hit.chat_id > 0) {
|
||||
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
|
||||
}
|
||||
return `Chat ${hit.chat_id}`;
|
||||
const peer = hit.chat_id > 0 ? peers.get(hit.chat_id) : undefined;
|
||||
return (
|
||||
(peer && peerName(peer)) || t("search.chatTitle", { id: hit.chat_id })
|
||||
);
|
||||
});
|
||||
|
||||
const sender = $derived.by(() => {
|
||||
const senderName = $derived.by(() => {
|
||||
if (hit.sender_id === null) {
|
||||
return "";
|
||||
}
|
||||
if (hit.sender_id === ownId) {
|
||||
return "Вы";
|
||||
if (own) {
|
||||
return t("search.you");
|
||||
}
|
||||
return peerName(peers.get(hit.sender_id) ?? null);
|
||||
});
|
||||
|
||||
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
|
||||
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
|
||||
const avatarId = $derived(
|
||||
inChat ? (hit.sender_id ?? hit.chat_id) : hit.chat_id
|
||||
);
|
||||
const title = $derived(inChat ? senderName || chatTitle : chatTitle);
|
||||
const prefix = $derived(
|
||||
inChat || !senderName || hit.chat_id === hit.sender_id ? "" : senderName
|
||||
);
|
||||
const avatarHas = $derived(
|
||||
avatarId === hit.chat_id
|
||||
? (chat?.has_avatar ?? false)
|
||||
: (peers.get(avatarId)?.has_avatar ?? false)
|
||||
);
|
||||
|
||||
const body = $derived.by(() => {
|
||||
if (hit.source === "version") {
|
||||
return hit.version_text ?? "";
|
||||
}
|
||||
if (hit.source === "stt") {
|
||||
return hit.extracted_text ?? "";
|
||||
}
|
||||
return hit.text ?? "";
|
||||
});
|
||||
const kind = $derived(
|
||||
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||
);
|
||||
const segments = $derived(highlight(body, terms));
|
||||
</script>
|
||||
|
||||
<button class="hit" {onclick} type="button" use:ripple>
|
||||
<a class="hit" {href} {onclick} use:ripple>
|
||||
<Avatar
|
||||
avatar={{ id: hit.chat_id, kind: avatarKind }}
|
||||
colorKey={hit.chat_id}
|
||||
hasAvatar={chat?.has_avatar ?? false}
|
||||
avatar={{ id: avatarId, kind: avatarId > 0 ? "peer" : "chat" }}
|
||||
colorKey={avatarId}
|
||||
hasAvatar={avatarHas}
|
||||
name={title}
|
||||
size={3}
|
||||
size={inChat ? 2.5 : 3}
|
||||
/>
|
||||
<div class="body">
|
||||
<div class="top">
|
||||
<span class="title">{title}</span>
|
||||
<span class="date">{formatListDate(hit.date)}</span>
|
||||
{#if hit.deleted_at}
|
||||
<span class="mark deleted" title={formatFull(hit.deleted_at)}>
|
||||
<Icon name="delete" size="0.875rem" />
|
||||
{t("search.markDeleted")}
|
||||
</span>
|
||||
{:else if hit.edited_at}
|
||||
<span class="mark" title={formatFull(hit.edited_at)}>
|
||||
{t("search.markEdited")}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="date" title={formatFull(hit.date)}>
|
||||
{formatListDate(hit.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="snippet">
|
||||
{#if sender}
|
||||
<span class="sender">{sender}: </span>
|
||||
{#if prefix}
|
||||
<span class="sender">{prefix}:</span>
|
||||
{/if}
|
||||
{snippet}
|
||||
{#if kind}
|
||||
<span class="kind">{kind}{body ? ":" : ""}</span>
|
||||
{/if}
|
||||
<span
|
||||
>{#each segments as segment, index (index)}
|
||||
{#if segment.hit}
|
||||
<mark>{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{#if hit.source === "version"}
|
||||
<div
|
||||
class="note"
|
||||
title={hit.version_at ? formatFull(hit.version_at) : undefined}
|
||||
>
|
||||
<Icon name="edit" size="0.875rem" />
|
||||
{t("search.version")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
.hit {
|
||||
@@ -89,18 +149,25 @@
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,24 +195,59 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.1875rem;
|
||||
align-self: center;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.deleted {
|
||||
font-style: normal;
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.snippet {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender {
|
||||
.sender,
|
||||
.kind {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0 0.0625rem;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-text);
|
||||
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
||||
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { search } from "#lib/stores/search.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const { messages } = search;
|
||||
let scroller = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const key = search.request;
|
||||
untrack(() => {
|
||||
search.sync(key);
|
||||
scroller?.scrollTo({ top: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
const activeChatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
|
||||
const hasChats = $derived(search.chatHits.length > 0);
|
||||
const hasPeers = $derived(search.peerHits.length > 0);
|
||||
const hasMessages = $derived(search.messageHits.length > 0);
|
||||
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
|
||||
const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
|
||||
const hasMessages = $derived(messages.hits.length > 0);
|
||||
const empty = $derived(
|
||||
!(search.loading || hasChats || hasPeers || hasMessages)
|
||||
!(
|
||||
search.typing ||
|
||||
messages.loading ||
|
||||
messages.failed ||
|
||||
showChats ||
|
||||
showPeers ||
|
||||
hasMessages
|
||||
)
|
||||
);
|
||||
|
||||
function openChat(chatId: number) {
|
||||
search.close();
|
||||
goto(`/app/${chatId}`);
|
||||
}
|
||||
|
||||
function openHit(chatId: number, messageId: number) {
|
||||
ui.requestJump(chatId, messageId);
|
||||
search.close();
|
||||
search.reset();
|
||||
goto(`/app/${chatId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search-results custom-scroll">
|
||||
{#if hasChats}
|
||||
<div class="section-label">Чаты</div>
|
||||
{#each search.chatHits as chat (chat.chat_id)}
|
||||
<ChatListItem
|
||||
{chat}
|
||||
onclick={() => openChat(chat.chat_id)}
|
||||
selected={chat.chat_id === activeChatId}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="search-view">
|
||||
<SearchFilters />
|
||||
<div class="search-results custom-scroll" bind:this={scroller}>
|
||||
{#if showChats}
|
||||
<div class="section-label">{t("search.chats")}</div>
|
||||
{#each search.chatHits as chat (chat.chat_id)}
|
||||
<ChatListItem
|
||||
{chat}
|
||||
onclick={() => openChat(chat.chat_id)}
|
||||
selected={chat.chat_id === activeChatId}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if hasPeers}
|
||||
<div class="section-label">Контакты и каналы</div>
|
||||
{#each search.peerHits as item (item.chat_id)}
|
||||
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if showPeers}
|
||||
<div class="section-label">{t("search.peers")}</div>
|
||||
{#each search.peerHits as item (item.chat_id)}
|
||||
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if search.loading && !hasMessages}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if hasMessages}
|
||||
<div class="section-label">Сообщения</div>
|
||||
{#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
onclick={() => openHit(hit.chat_id, hit.message_id)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if hasMessages}
|
||||
<div class="section-label">{t("search.messages")}</div>
|
||||
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||
onclick={() => ui.requestJump(hit.chat_id, hit.message_id)}
|
||||
terms={messages.terms}
|
||||
/>
|
||||
{/each}
|
||||
{#if messages.hasMore}
|
||||
{#key messages.hits.length}
|
||||
<div class="sentinel" use:visible={() => messages.more()}></div>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if messages.appending}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{/if}
|
||||
{:else if messages.loading}
|
||||
<div class="loading"><Spinner /></div>
|
||||
{:else if messages.failed}
|
||||
<div class="failed">
|
||||
<p>{t("search.failed")}</p>
|
||||
<Button onclick={() => messages.retry()} variant="text"
|
||||
>{t("search.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if empty}
|
||||
<EmptyState title="Ничего не найдено" />
|
||||
{/if}
|
||||
{#if empty}
|
||||
<EmptyState title={t("search.nothing")} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.search-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
@@ -82,9 +125,27 @@
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { Popover } from "bits-ui";
|
||||
import { discoverPeers } from "#lib/api/endpoints.js";
|
||||
import type { DiscoverItem } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { peerName } from "#lib/format/peer.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||
import { peers } from "#lib/stores/peers.svelte.js";
|
||||
import { search } from "#lib/stores/search.svelte.js";
|
||||
|
||||
interface Person {
|
||||
hasAvatar: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const recent = createChatPicker();
|
||||
let open = $state(false);
|
||||
let query = $state("");
|
||||
let found = $state<DiscoverItem[]>([]);
|
||||
let searching = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const selected = $derived(search.filters.from);
|
||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
const typed = $derived(query.trim());
|
||||
|
||||
$effect(() => {
|
||||
if (selected !== null && selected !== ownId) {
|
||||
peers.ensure([selected]);
|
||||
}
|
||||
});
|
||||
|
||||
const selectedName = $derived.by(() => {
|
||||
if (selected === null) {
|
||||
return t("search.from");
|
||||
}
|
||||
if (selected === ownId) {
|
||||
return t("search.you");
|
||||
}
|
||||
return peerName(peers.get(selected) ?? null) || String(selected);
|
||||
});
|
||||
|
||||
const people = $derived.by((): Person[] => {
|
||||
if (typed) {
|
||||
return found
|
||||
.filter((item) => item.chat_id > 0)
|
||||
.map((item) => ({
|
||||
hasAvatar: item.has_avatar,
|
||||
id: item.chat_id,
|
||||
name: item.title ?? (item.username ? `@${item.username}` : ""),
|
||||
}));
|
||||
}
|
||||
const own: Person[] =
|
||||
ownId === null
|
||||
? []
|
||||
: [{ hasAvatar: false, id: ownId, name: t("search.you") }];
|
||||
return [
|
||||
...own,
|
||||
...recent.results
|
||||
.filter((chat) => chat.chat_id > 0 && chat.chat_id !== ownId)
|
||||
.map((chat) => ({
|
||||
hasAvatar: chat.has_avatar,
|
||||
id: chat.chat_id,
|
||||
name: chat.title ?? String(chat.chat_id),
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
async function find(value: string, current: number) {
|
||||
try {
|
||||
const items = await discoverPeers(value);
|
||||
if (current === seq) {
|
||||
found = items;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
found = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
query = (event.currentTarget as HTMLInputElement).value;
|
||||
seq += 1;
|
||||
const current = seq;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const value = query.trim();
|
||||
searching = value !== "";
|
||||
if (!value) {
|
||||
found = [];
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
find(value, current).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (value) {
|
||||
query = "";
|
||||
found = [];
|
||||
recent.search("");
|
||||
}
|
||||
}
|
||||
|
||||
function pick(id: number | null) {
|
||||
search.setFilter("from", id === null ? null : String(id));
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root {onOpenChange} bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
class="search-chip"
|
||||
type="button"
|
||||
class:on={selected !== null}
|
||||
>
|
||||
<span class="chip-label">{selectedName}</span>
|
||||
<Icon name="down" size="0.875rem" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="bg-menu-content search-popover"
|
||||
sideOffset={6}
|
||||
>
|
||||
<input
|
||||
aria-label={t("search.fromName")}
|
||||
class="find"
|
||||
oninput={onInput}
|
||||
placeholder={t("search.fromName")}
|
||||
type="search"
|
||||
value={query}
|
||||
>
|
||||
<div class="people custom-scroll">
|
||||
{#if selected !== null && !typed}
|
||||
<button class="bg-menu-item" onclick={() => pick(null)} type="button">
|
||||
<Icon name="user" size="1.125rem" />
|
||||
<span class="name">{t("search.anyone")}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#each people as person (person.id)}
|
||||
<button
|
||||
class="bg-menu-item person"
|
||||
onclick={() => pick(person.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ id: person.id, kind: "peer" }}
|
||||
colorKey={person.id}
|
||||
hasAvatar={person.hasAvatar}
|
||||
name={person.name}
|
||||
size={1.75}
|
||||
/>
|
||||
<span class="name">{person.name}</span>
|
||||
{#if person.id === selected}
|
||||
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if typed && !searching && people.length === 0}
|
||||
<p class="empty">{t("search.nobody")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.search-popover) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
width: min(17rem, calc(100vw - 1.5rem));
|
||||
}
|
||||
|
||||
.find {
|
||||
height: 2.25rem;
|
||||
margin-bottom: 0.125rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--color-borders-input);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.people {
|
||||
overflow-y: auto;
|
||||
max-height: min(20rem, 50dvh);
|
||||
}
|
||||
|
||||
.person {
|
||||
padding-block: 0.3125rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.bg-menu-item {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string>(["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<SearchHit[]>([]);
|
||||
let chatHits = $state<Chat[]>([]);
|
||||
let peerResults = $state<DiscoverItem[]>([]);
|
||||
type Param = (typeof PARAMS)[number];
|
||||
|
||||
function day(value: string | null): string | null {
|
||||
return value && DAY.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function readFilters(
|
||||
params: Pick<URLSearchParams, "get" | "has">
|
||||
): 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<SearchHit[]>([]);
|
||||
let terms = $state<string[]>([]);
|
||||
let cursor = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let timers: ReturnType<typeof setTimeout>[] = [];
|
||||
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<Chat[]>([]);
|
||||
let peerResults = $state<DiscoverItem[]>([]);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let remoteTimer: ReturnType<typeof setTimeout> | 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<Record<Param, string | null>>) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
shownAccount = id;
|
||||
untrack(() => {
|
||||
ui.closePanel();
|
||||
search.close();
|
||||
search.reset();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
{/if}
|
||||
<SearchInput />
|
||||
</header>
|
||||
{#if search.active && search.trimmed}
|
||||
{#if search.showResults}
|
||||
<SearchResults />
|
||||
{:else}
|
||||
<div class="folder-tabs">
|
||||
|
||||
Reference in new issue
Block a user