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:
hh committed 2026-10-19 03:41:44 +00:00
1 parent d45c96c259
commit 9a6bd8a714
15 files changed
+1430 -228

No files matched your search

@@ -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>