feat(backend,frontend): search everywhere in buttons, links and message data, a jump from search or an alert lights up the found words and buttons
This commit is contained in:
1 parent
acfa726e61
commit
d660079c02
21 files changed
+840
-76
No files matched your search
@@ -8,14 +8,16 @@
|
||||
jumboEmojiCount,
|
||||
linkHref,
|
||||
} from "#lib/format/entities.js";
|
||||
import { markText } from "#lib/format/search.js";
|
||||
|
||||
interface Props {
|
||||
entities: EntityView[];
|
||||
marks?: string[];
|
||||
own?: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
let { text, entities, own = false }: Props = $props();
|
||||
let { text, entities, marks = [], own = false }: Props = $props();
|
||||
|
||||
const nodes = $derived(buildEntityTree(text, entities));
|
||||
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
|
||||
@@ -32,13 +34,29 @@
|
||||
)}
|
||||
{#each items as node, i (i)}
|
||||
{#if node.kind === "text"}
|
||||
{node.text}
|
||||
{@render plain(node.text)}
|
||||
{:else}
|
||||
{@render entity(node)}
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet plain(
|
||||
value: string
|
||||
)}
|
||||
{#if marks.length === 0}
|
||||
{value}
|
||||
{:else}
|
||||
{#each markText(value, marks) as segment, i (i)}
|
||||
{#if segment.hit}
|
||||
<mark class="found" class:own>{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet entity(
|
||||
node: EntityNode
|
||||
)}
|
||||
@@ -186,6 +204,35 @@
|
||||
background-color: var(--color-primary-tint);
|
||||
}
|
||||
|
||||
.found {
|
||||
padding: 0;
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
animation: found-flash 3s ease;
|
||||
|
||||
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
|
||||
&.own {
|
||||
--found-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-own-links) 35%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes found-flash {
|
||||
0%,
|
||||
70% {
|
||||
background-color: var(--found-color);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { InlineButton } from "#lib/api/types.js";
|
||||
import { buttonHasMark } from "#lib/format/search.js";
|
||||
import { safeHref } from "#lib/format/url.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
|
||||
interface Props {
|
||||
marks?: string[];
|
||||
rows: InlineButton[][];
|
||||
}
|
||||
|
||||
let { rows }: Props = $props();
|
||||
let { marks = [], rows }: Props = $props();
|
||||
|
||||
let copied = $state<string | null>(null);
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -27,12 +29,14 @@
|
||||
<div class="row">
|
||||
{#each row as button, colIndex (colIndex)}
|
||||
{@const key = `${rowIndex}:${colIndex}`}
|
||||
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
|
||||
{#if button.kind === "url" && button.url}
|
||||
<a
|
||||
class="button"
|
||||
href={safeHref(button.url)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class:found={lit}
|
||||
>
|
||||
<span class="label">{button.text}</span>
|
||||
<span class="corner">↗</span>
|
||||
@@ -42,13 +46,14 @@
|
||||
class="button"
|
||||
onclick={() => copy(key, button.data ?? "")}
|
||||
type="button"
|
||||
class:found={lit}
|
||||
>
|
||||
<span class="label"
|
||||
>{copied === key ? t("inline.copied") : button.text}</span
|
||||
>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="button static">
|
||||
<span class="button static" class:found={lit}>
|
||||
<span class="label">{button.text}</span>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -104,6 +109,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.found {
|
||||
animation: found-flash 3s ease;
|
||||
}
|
||||
|
||||
@keyframes found-flash {
|
||||
0%,
|
||||
70% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-primary) 30%,
|
||||
var(--color-message-reaction)
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: var(--color-message-reaction);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import WebPage from "#lib/components/WebPage.svelte";
|
||||
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||
import { buttonHasMark, hasMark } 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";
|
||||
@@ -31,7 +32,7 @@
|
||||
interface Props {
|
||||
animate: boolean;
|
||||
firstInGroup: boolean;
|
||||
highlighted: boolean;
|
||||
highlight: string[] | null;
|
||||
isGroupChat: boolean;
|
||||
lastInGroup: boolean;
|
||||
message: MessageView;
|
||||
@@ -47,7 +48,7 @@
|
||||
isGroupChat,
|
||||
firstInGroup,
|
||||
lastInGroup,
|
||||
highlighted,
|
||||
highlight,
|
||||
animate,
|
||||
onjump,
|
||||
onmedia,
|
||||
@@ -55,6 +56,14 @@
|
||||
}: Props = $props();
|
||||
|
||||
const deleted = $derived(message.deleted_at !== null);
|
||||
const marks = $derived(highlight ?? []);
|
||||
const markedInside = $derived(
|
||||
marks.length > 0 &&
|
||||
(hasMark(message.text, marks) ||
|
||||
message.inline_buttons.some((row) =>
|
||||
row.some((button) => buttonHasMark(button, marks))
|
||||
))
|
||||
);
|
||||
const hasText = $derived(Boolean(message.text));
|
||||
const special = $derived(
|
||||
Boolean(
|
||||
@@ -162,7 +171,7 @@
|
||||
data-message-id={message.message_id}
|
||||
class:deleted
|
||||
class:first-in-group={firstInGroup}
|
||||
class:highlighted
|
||||
class:highlighted={highlight !== null && !markedInside}
|
||||
class:last-in-group={lastInGroup}
|
||||
class:own
|
||||
class:with-avatar={isGroupChat}
|
||||
@@ -239,6 +248,7 @@
|
||||
<div class="text">
|
||||
<EntityText
|
||||
entities={message.entities}
|
||||
{marks}
|
||||
{own}
|
||||
text={message.text ?? ""}
|
||||
/>
|
||||
@@ -270,7 +280,7 @@
|
||||
{own}
|
||||
/>
|
||||
{#if message.inline_buttons.length}
|
||||
<InlineButtons rows={message.inline_buttons} />
|
||||
<InlineButtons {marks} rows={message.inline_buttons} />
|
||||
{/if}
|
||||
{#if hasCallbacks}
|
||||
<button class="detail-chip" onclick={openCallbacks} type="button">
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
const IDLE_DELAY = 1500;
|
||||
const MAX_MESSAGES = 360;
|
||||
const SETTLE_DELAY = 400;
|
||||
const HIGHLIGHT_MS = 3000;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -57,7 +58,7 @@
|
||||
let mediaIndex = $state(0);
|
||||
let versionsOpen = $state(false);
|
||||
let versionsMessageId = $state<number | null>(null);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let highlight = $state<{ id: number; marks: string[] } | null>(null);
|
||||
let calendarOpen = $state(false);
|
||||
let calendarAnchor = $state<HTMLElement | null>(null);
|
||||
let calendarMonth = $state<{ month: number; year: number } | null>(null);
|
||||
@@ -229,7 +230,11 @@
|
||||
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
||||
}
|
||||
|
||||
function jumpToMessage(messageId: number, smooth = true) {
|
||||
function jumpToMessage(
|
||||
messageId: number,
|
||||
smooth = true,
|
||||
marks: string[] = []
|
||||
) {
|
||||
const owner = ownerOf(messageId) ?? messageId;
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${owner}"]`
|
||||
@@ -242,13 +247,13 @@
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
block: "center",
|
||||
});
|
||||
highlightId = owner;
|
||||
highlight = { id: owner, marks };
|
||||
if (highlightTimer) {
|
||||
clearTimeout(highlightTimer);
|
||||
}
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightId = null;
|
||||
}, 1600);
|
||||
highlight = null;
|
||||
}, HIGHLIGHT_MS);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
@@ -283,7 +288,7 @@
|
||||
ui.clearJump();
|
||||
await loadAround(pending.messageId);
|
||||
await tick();
|
||||
jumpToMessage(pending.messageId, false);
|
||||
jumpToMessage(pending.messageId, false, pending.marks);
|
||||
return;
|
||||
}
|
||||
const page = await listMessages(chatId, {
|
||||
@@ -574,14 +579,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function goTo(messageId: number) {
|
||||
async function goTo(messageId: number, marks: string[] = []) {
|
||||
try {
|
||||
const loaded = ownerOf(messageId) !== null;
|
||||
if (!loaded) {
|
||||
await loadAround(messageId);
|
||||
}
|
||||
await tick();
|
||||
jumpToMessage(messageId, loaded);
|
||||
jumpToMessage(messageId, loaded, marks);
|
||||
} catch {
|
||||
toasts.error(t("thread.loadFailed"));
|
||||
}
|
||||
@@ -593,7 +598,7 @@
|
||||
return;
|
||||
}
|
||||
ui.clearJump();
|
||||
goTo(target.messageId);
|
||||
goTo(target.messageId, target.marks);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -663,7 +668,9 @@
|
||||
<MessageBubble
|
||||
animate={!suppressAppear}
|
||||
firstInGroup={row.firstInGroup}
|
||||
highlighted={highlightId === row.message.message_id}
|
||||
highlight={highlight?.id === row.message.message_id
|
||||
? highlight.marks
|
||||
: null}
|
||||
{isGroupChat}
|
||||
lastInGroup={row.lastInGroup}
|
||||
message={row.message}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||
import {
|
||||
alertAvatar,
|
||||
alertHref,
|
||||
alertLabel,
|
||||
alertPreview,
|
||||
alertTitle,
|
||||
@@ -131,7 +132,7 @@
|
||||
if (isMobile()) {
|
||||
ui.resetPanel();
|
||||
}
|
||||
await goto(alert.path);
|
||||
await goto(alertHref(alert));
|
||||
}
|
||||
|
||||
async function seeAll() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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";
|
||||
@@ -15,7 +16,9 @@
|
||||
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;
|
||||
|
||||
@@ -23,14 +26,30 @@
|
||||
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;
|
||||
const value = query.trim();
|
||||
deep = false;
|
||||
everywhere.run(null);
|
||||
pager.run(
|
||||
value && chatId !== null ? { chat_id: chatId, query: value } : null
|
||||
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);
|
||||
@@ -46,11 +65,11 @@
|
||||
timer = setTimeout(run, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function openHit(messageId: number) {
|
||||
function openHit(messageId: number, marks: string[]) {
|
||||
if (chatId === null) {
|
||||
return;
|
||||
}
|
||||
ui.requestJump(chatId, messageId);
|
||||
ui.requestJump(chatId, messageId, marks);
|
||||
if (isMobile()) {
|
||||
ui.closePanel();
|
||||
}
|
||||
@@ -73,9 +92,9 @@
|
||||
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id, pager.terms)}
|
||||
inChat
|
||||
onclick={() => openHit(hit.message_id)}
|
||||
onclick={() => openHit(hit.message_id, pager.terms)}
|
||||
terms={pager.terms}
|
||||
/>
|
||||
{/each}
|
||||
@@ -96,7 +115,19 @@
|
||||
>{t("search.retry")}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if query.trim() && !typing}
|
||||
{/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>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { formatDate } from "#lib/format/datetime.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import {
|
||||
EVERYWHERE_MIN,
|
||||
type HitPager,
|
||||
hitKey,
|
||||
} from "#lib/stores/search.svelte.js";
|
||||
|
||||
interface Props {
|
||||
href: (hit: SearchHit) => string;
|
||||
inChat?: boolean;
|
||||
needle: string;
|
||||
onopen?: (hit: SearchHit) => void;
|
||||
onstart: () => void;
|
||||
pager: HitPager;
|
||||
ready: boolean;
|
||||
shown: SearchHit[];
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
href,
|
||||
inChat = false,
|
||||
needle,
|
||||
onopen,
|
||||
onstart,
|
||||
pager,
|
||||
ready,
|
||||
shown,
|
||||
started,
|
||||
}: Props = $props();
|
||||
|
||||
const hits = $derived.by(() => {
|
||||
const seen = new Set(shown.map(hitKey));
|
||||
return pager.hits.filter((hit) => {
|
||||
const key = hitKey(hit);
|
||||
const fresh = !seen.has(key);
|
||||
seen.add(key);
|
||||
return fresh;
|
||||
});
|
||||
});
|
||||
const busy = $derived(pager.loading || pager.appending);
|
||||
|
||||
function retry() {
|
||||
if (pager.hasMore) {
|
||||
pager.more();
|
||||
} else {
|
||||
pager.retry();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !started}
|
||||
{#if ready && needle.length >= EVERYWHERE_MIN}
|
||||
<button
|
||||
class="start"
|
||||
onclick={onstart}
|
||||
type="button"
|
||||
class:in-chat={inChat}
|
||||
use:ripple
|
||||
>
|
||||
<span class="glyph"><Icon name="search" size="1.5rem" /></span>
|
||||
<span class="body">
|
||||
<span class="label">{t("search.everywhere")}</span>
|
||||
<span class="hint">{t("search.everywhereHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if hits.length > 0}
|
||||
<div class="section-label">{t("search.everywhereSection")}</div>
|
||||
{#each hits as hit (hitKey(hit))}
|
||||
<SearchMessageItem
|
||||
{hit}
|
||||
href={href(hit)}
|
||||
{inChat}
|
||||
onclick={() => onopen?.(hit)}
|
||||
terms={[needle]}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if busy}
|
||||
<div class="status">
|
||||
<Spinner size="1.25rem" />
|
||||
<span>
|
||||
{pager.scannedTo
|
||||
? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) })
|
||||
: t("search.everywhereScanning")}
|
||||
</span>
|
||||
</div>
|
||||
{:else if pager.failed}
|
||||
<div class="status failed">
|
||||
<span>{t("search.failed")}</span>
|
||||
<Button onclick={retry} variant="text">{t("search.retry")}</Button>
|
||||
</div>
|
||||
{:else if pager.hasMore}
|
||||
<div class="sentinel" use:visible={() => pager.more()}></div>
|
||||
{:else if hits.length === 0}
|
||||
<div class="status">{t("search.everywhereNothing")}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.start {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
--ripple-color: var(--color-interactive-element-hover);
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
|
||||
color: var(--color-primary);
|
||||
|
||||
.in-chat & {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 0.625rem 0.75rem 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding: 1rem 0.75rem 1.5rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.failed {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ripple } from "#lib/actions/ripple.js";
|
||||
import type { SearchHit } from "#lib/api/types.js";
|
||||
import type { FoundWhere, SearchHit } from "#lib/api/types.js";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
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 { highlight, highlightMarks } 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";
|
||||
@@ -22,6 +22,17 @@
|
||||
|
||||
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||
|
||||
const FOUND_ICONS: Record<FoundWhere, string> = {
|
||||
button: "bot-command",
|
||||
data: "info",
|
||||
file: "document",
|
||||
forward: "forward",
|
||||
link: "link",
|
||||
preview: "link",
|
||||
reply: "reply",
|
||||
text: "search",
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -81,7 +92,21 @@
|
||||
const kind = $derived(
|
||||
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||
);
|
||||
const segments = $derived(highlight(body, terms));
|
||||
const byData = $derived(hit.source === "data");
|
||||
const segments = $derived(
|
||||
byData ? highlightMarks(body, terms) : highlight(body, terms)
|
||||
);
|
||||
const found = $derived(
|
||||
hit.found && hit.found.where !== "text" ? hit.found : null
|
||||
);
|
||||
const foundLabel = $derived.by(() => {
|
||||
if (found === null) {
|
||||
return "";
|
||||
}
|
||||
return found.where === "button"
|
||||
? t("search.found.button", { text: found.button ?? "" })
|
||||
: t(`search.found.${found.where}`);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="hit" {href} {onclick} use:ripple>
|
||||
@@ -126,6 +151,21 @@
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{#if found}
|
||||
<div class="note found" title={found.path}>
|
||||
<Icon name={FOUND_ICONS[found.where]} size="0.875rem" />
|
||||
<span class="found-label">{foundLabel}:</span>
|
||||
<span class="found-value"
|
||||
>{#each highlightMarks(found.value, terms) as segment, index (index)}
|
||||
{#if segment.hit}
|
||||
<mark>{segment.text}</mark>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hit.source === "version"}
|
||||
<div
|
||||
class="note"
|
||||
@@ -244,6 +284,22 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.found {
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.found-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.found-value {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0 0.0625rem;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { visible } from "#lib/actions/visible.js";
|
||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
||||
import SearchEverywhere from "#lib/components/search/SearchEverywhere.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";
|
||||
@@ -14,7 +15,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const { messages } = search;
|
||||
const { everywhere, messages } = search;
|
||||
let scroller = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
@@ -25,12 +26,22 @@
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const key = search.everywhereRequest;
|
||||
untrack(() => search.syncEverywhere(key));
|
||||
});
|
||||
|
||||
const activeChatId = $derived(
|
||||
page.params.chatId ? Number(page.params.chatId) : null
|
||||
);
|
||||
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 needle = $derived(search.text.trim());
|
||||
const searchedAll = $derived(
|
||||
!(search.typing || messages.loading || messages.failed || messages.hasMore)
|
||||
);
|
||||
const deep = $derived(search.everywhereRequest !== "");
|
||||
const empty = $derived(
|
||||
!(
|
||||
search.typing ||
|
||||
@@ -38,7 +49,8 @@
|
||||
messages.failed ||
|
||||
showChats ||
|
||||
showPeers ||
|
||||
hasMessages
|
||||
hasMessages ||
|
||||
deep
|
||||
)
|
||||
);
|
||||
|
||||
@@ -74,8 +86,9 @@
|
||||
{#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)}
|
||||
href={search.hitHref(hit.chat_id, hit.message_id, messages.terms)}
|
||||
onclick={() =>
|
||||
ui.requestJump(hit.chat_id, hit.message_id, messages.terms)}
|
||||
terms={messages.terms}
|
||||
/>
|
||||
{/each}
|
||||
@@ -98,6 +111,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchEverywhere
|
||||
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
|
||||
{needle}
|
||||
onopen={(hit) => ui.requestJump(hit.chat_id, hit.message_id, [needle])}
|
||||
onstart={() => search.searchEverywhere()}
|
||||
pager={everywhere}
|
||||
ready={searchedAll}
|
||||
shown={messages.hits}
|
||||
started={deep}
|
||||
/>
|
||||
|
||||
{#if empty}
|
||||
<EmptyState title={t("search.nothing")} />
|
||||
{/if}
|
||||
|
||||
Reference in new issue
Block a user