219 lines
5.1 KiB
Svelte
219 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from "svelte";
|
|
import { visible } from "#lib/actions/visible.js";
|
|
import SearchEverywhere from "#lib/components/search/SearchEverywhere.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 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 { isMobile } from "#lib/viewport.js";
|
|
import { page } from "$app/state";
|
|
|
|
const DEBOUNCE_MS = 250;
|
|
|
|
const pager = createHitPager();
|
|
const everywhere = createHitPager();
|
|
let query = $state("");
|
|
let deep = $state(false);
|
|
let typing = $state(false);
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const chatId = $derived(
|
|
page.params.chatId ? Number(page.params.chatId) : null
|
|
);
|
|
|
|
const needle = $derived(query.trim());
|
|
const empty = $derived(
|
|
needle !== "" &&
|
|
!(typing || deep || pager.loading || pager.failed) &&
|
|
pager.hits.length === 0
|
|
);
|
|
|
|
function run() {
|
|
typing = false;
|
|
deep = false;
|
|
everywhere.run(null);
|
|
pager.run(
|
|
needle && chatId !== null ? { chat_id: chatId, query: needle } : null
|
|
);
|
|
}
|
|
|
|
function searchEverywhere() {
|
|
if (chatId === null) {
|
|
return;
|
|
}
|
|
deep = true;
|
|
everywhere.run({ chat_id: chatId, everywhere: true, query: needle });
|
|
}
|
|
|
|
$effect(() => {
|
|
if (chatId !== null) {
|
|
untrack(run);
|
|
}
|
|
});
|
|
|
|
function onInput(event: Event) {
|
|
query = (event.currentTarget as HTMLInputElement).value;
|
|
typing = true;
|
|
if (timer !== null) {
|
|
clearTimeout(timer);
|
|
}
|
|
timer = setTimeout(run, DEBOUNCE_MS);
|
|
}
|
|
|
|
function openHit(messageId: number, marks: string[]) {
|
|
if (chatId === null) {
|
|
return;
|
|
}
|
|
ui.requestJump(chatId, messageId, marks);
|
|
if (isMobile()) {
|
|
ui.closePanel();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="chat-search">
|
|
<div class="search-input">
|
|
<Icon class="chat-search-icon" name="search" size="1.25rem" />
|
|
<input
|
|
aria-label={t("search.inChat")}
|
|
oninput={onInput}
|
|
placeholder={t("search.inChat")}
|
|
type="search"
|
|
value={query}
|
|
>
|
|
</div>
|
|
<div class="results custom-scroll">
|
|
{#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, pager.terms)}
|
|
inChat
|
|
onclick={() => openHit(hit.message_id, pager.terms)}
|
|
terms={pager.terms}
|
|
/>
|
|
{/each}
|
|
{#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>
|
|
{/if}
|
|
<SearchEverywhere
|
|
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
|
|
inChat
|
|
{needle}
|
|
onopen={(hit) => openHit(hit.message_id, [needle])}
|
|
onstart={searchEverywhere}
|
|
pager={everywhere}
|
|
ready={!(typing || pager.loading || pager.failed || pager.hasMore)}
|
|
shown={pager.hits}
|
|
started={deep}
|
|
/>
|
|
{#if empty}
|
|
<EmptyState title={t("search.nothing")} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.chat-search {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.search-input {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
|
|
height: 2.5rem;
|
|
margin: 0.625rem;
|
|
padding: 0 0.5rem 0 0.75rem;
|
|
border-radius: 1.25rem;
|
|
|
|
background-color: var(--color-background-secondary);
|
|
|
|
&:focus-within {
|
|
background-color: var(--color-background);
|
|
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
|
}
|
|
}
|
|
|
|
:global(.chat-search .chat-search-icon) {
|
|
flex-shrink: 0;
|
|
color: var(--color-icon-secondary);
|
|
}
|
|
|
|
input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 0;
|
|
|
|
font-size: 0.9375rem;
|
|
color: var(--color-text);
|
|
background: transparent;
|
|
|
|
&::placeholder {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
&::-webkit-search-cancel-button {
|
|
display: none;
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
}
|
|
}
|
|
|
|
.results {
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
padding: 0.25rem 0.4375rem;
|
|
}
|
|
|
|
.sentinel {
|
|
height: 1px;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
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>
|