Compare commits

..
3 Commits
28 changed files with 909 additions and 85 deletions

No files matched your search

+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name
from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories")
async def list_stories(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StoryView]:
return await peers.get_stories(
stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
)
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media")
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date,
expire_date = EXCLUDED.expire_date,
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views,
pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted,
deleted = false,
raw = EXCLUDED.raw
"""
_MARK_DELETED = """
INSERT INTO stories
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
"""
async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str,
*,
pinned: bool,
deleted: bool,
downloaded: bool,
) -> None:
await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded,
views,
pinned,
deleted,
raw,
)
async def mark_deleted(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
) -> None:
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story)
if story.deleted:
await repository.mark_deleted(
capture.pool, capture.account_id, peer_id, story.id
)
return
storage_key: str | None = None
file_size: int | None = None
downloaded = False
stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id
)
if not (stored or story.deleted or story.media is None):
if not (stored or story.media is None):
data = await download_bytes(client, story)
if data is not None:
storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views,
raw_json(story.raw),
pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded,
)
+5
View File
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def sniff_media_kind(head: bytes) -> str:
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
return "video" if is_video else "photo"
def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None
) -> str:
+14 -1
View File
@@ -76,7 +76,7 @@ async def get_stories(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} "
f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}",
*params,
)
@@ -95,3 +95,16 @@ async def get_story(
story_id,
)
return StoryView(**dict(row)) if row else None
async def set_story_kind(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
) -> None:
await pool.execute(
"UPDATE stories SET media_kind = $4 "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
kind,
)
+65
View File
@@ -0,0 +1,65 @@
import re
from collections.abc import Iterator
from typing import Any
from utils.search.models import Found, FoundWhere
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
_HEADS: dict[str, FoundWhere] = {
"text": "text",
"caption": "text",
"web_page": "preview",
}
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
type Path = tuple[str | int, ...]
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
if isinstance(node, dict):
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
elif isinstance(node, list):
items = enumerate(node)
else:
return
for key, value in items:
if isinstance(value, dict | list):
yield from _scalars(value, (*path, key))
elif isinstance(value, str | int | float) and not isinstance(value, bool):
yield (*path, key), str(value), node
def _where(path: Path) -> FoundWhere:
head, leaf = str(path[0]), str(path[-1])
if head == "reply_markup":
return "button"
if leaf in _LEAVES:
return _LEAVES[leaf]
if head.startswith("forward"):
return "forward"
if head.startswith("reply_to"):
return "reply"
return _HEADS.get(head, "data")
def find(raw: dict[str, Any], needle: str) -> Found | None:
folded = needle.casefold()
fallback = None
for path, value, owner in _scalars(raw):
if folded not in value.casefold() or ENUM.match(value):
continue
where = _where(path)
button = owner.get("text") if isinstance(owner, dict) else None
found = Found(
where=where,
path=".".join(map(str, path)),
value=value,
button=button if where == "button" else None,
)
if where != "text":
return found
fallback = fallback or found
return fallback
+17 -4
View File
@@ -10,7 +10,10 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"]
HitSource = Literal["text", "stt", "version"]
HitSource = Literal["text", "stt", "version", "data"]
FoundWhere = Literal[
"text", "button", "link", "forward", "reply", "file", "preview", "data"
]
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
@@ -34,6 +37,7 @@ class SearchFilters(BaseModel):
date_from: datetime | None = None
date_to: datetime | None = None
regex: str | None = None
everywhere: bool = False
cursor: str | None = None
limit: int = DEFAULT_LIMIT
@@ -45,6 +49,13 @@ class SearchFilters(BaseModel):
return value
class Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel):
chat_id: int
message_id: int
@@ -56,12 +67,14 @@ class SearchHit(BaseModel):
edited_at: datetime | None
deleted_at: datetime | None
source: HitSource
extracted_text: str | None
version_text: str | None
version_at: datetime | None
extracted_text: str | None = None
version_text: str | None = None
version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel):
hits: list[SearchHit]
next_cursor: str | None
terms: list[str]
scanned_to: datetime | None = None
+95 -3
View File
@@ -1,5 +1,12 @@
import json
import re
from datetime import UTC, datetime, timedelta
from time import monotonic
import asyncpg
from utils.read.message_view import load_raw
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
from utils.search.models import (
MAX_LIMIT,
ChatKind,
@@ -10,6 +17,12 @@ from utils.search.models import (
encode_cursor,
)
MIN_NEEDLE = 3
_WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
@@ -76,6 +89,24 @@ _WITH_MEDIA = (
_SOURCES = ("text", "stt", "version")
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
_VALUE_MATCH = (
'strict $.** ? (@.type() == "object").keyvalue() ? ('
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
+ '(@.value.type() == "string" || @.value.type() == "number") && '
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
'@.value.string() like_regex {needle} flag "iq")'
)
_EVERYWHERE = (
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
"FROM messages s WHERE {where} "
"AND CASE WHEN s.raw::text ILIKE {like} "
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
)
class _Binder:
def __init__(self) -> None:
@@ -130,9 +161,72 @@ def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
async with pool.acquire() as conn, conn.transaction():
await conn.execute("SET LOCAL jit = off")
if filters.everywhere:
return await _everywhere(conn, filters)
return await _search(conn, filters)
async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
if filters.kind in {"group", "channel"}:
return await conn.fetchval(_CHANNELS, filters.account_id)
return []
def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
data = dict(row)
raw = load_raw(data.pop("raw"))
return SearchHit(**data, source="data", found=find(raw, needle))
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
needle = (filters.query or "").strip()
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
if len(needle) < MIN_NEEDLE or floor is None:
return SearchPage(hits=[], next_cursor=None, terms=[])
channels = await _channels(conn, filters)
quoted = json.dumps(needle, ensure_ascii=False)
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
path = _VALUE_MATCH.format(needle=quoted)
size = min(filters.limit, MAX_LIMIT)
cursor = filters.cursor
top = decode_cursor(cursor)[0] if cursor else None
hits: list[SearchHit] = []
started = monotonic()
while True:
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
bind = _Binder()
conds = _conditions(
filters.model_copy(update={"cursor": cursor}), channels, bind
)
conds.append(f"s.date >= {bind(lower)}")
if top is not None:
conds.append(f"s.date <= {bind(top)}")
limit = size - len(hits)
sql = _EVERYWHERE.format(
where=" AND ".join(conds),
hidden=bind(list(HIDDEN_KEYS)),
like=bind(f"%{like}%"),
path=bind(path),
limit=bind(limit),
)
account = bind(filters.account_id)
rows = await conn.fetch(
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
)
hits += [_data_hit(row, needle) for row in rows]
if len(rows) >= limit:
last = rows[-1]
top = last["date"]
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
elif lower > floor:
top = lower
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
else:
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip()
terms: list[str] = []
@@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag
return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"]
channels: list[int] = []
if filters.kind in {"group", "channel"}:
channels = await conn.fetchval(_CHANNELS, filters.account_id)
channels = await _channels(conn, filters)
bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT)
+4
View File
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
def get(self, key: str) -> bytes:
return self._path(key).read_bytes()
def head(self, key: str, size: int = 16) -> bytes:
with self._path(key).open("rb") as file:
return file.read(size)
def exists(self, key: str) -> bool:
return self._path(key).exists()
+21 -1
View File
@@ -7,7 +7,24 @@ export type PolicyScopeType =
| "default_channel"
| "folder"
| "chat";
export type SearchSource = "text" | "stt" | "version";
export type SearchSource = "text" | "stt" | "version" | "data";
export type FoundWhere =
| "text"
| "button"
| "link"
| "forward"
| "reply"
| "file"
| "preview"
| "data";
export interface SearchFound {
button: string | null;
path: string;
value: string;
where: FoundWhere;
}
export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus =
@@ -289,6 +306,7 @@ export interface SearchHit {
deleted_at: string | null;
edited_at: string | null;
extracted_text: string | null;
found: SearchFound | null;
has_media: boolean;
media_kind: string | null;
message_id: number;
@@ -302,6 +320,7 @@ export interface SearchHit {
export interface SearchPage {
hits: SearchHit[];
next_cursor: string | null;
scanned_to: string | null;
terms: string[];
}
@@ -312,6 +331,7 @@ export interface SearchQuery {
date_to?: string;
deleted?: boolean;
edited?: boolean;
everywhere?: boolean;
has_media?: boolean;
kind?: SearchChatKind;
limit?: number;
+49 -2
View File
@@ -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">
+17 -10
View File
@@ -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}
+84 -27
View File
@@ -1,8 +1,12 @@
import type { InlineButton } from "#lib/api/types.js";
export interface Segment {
hit: boolean;
text: string;
}
type Range = [number, number];
const WORD = /[\p{L}\p{N}_]+/gu;
const SPACE = /\s/;
const PREFIX_MIN = 3;
@@ -20,39 +24,92 @@ function matches(word: string, terms: string[]): boolean {
);
}
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;
function wordRanges(text: string, terms: string[]): Range[] {
return [...text.matchAll(WORD)]
.filter((match) => matches(match[0], terms))
.map((match) => [match.index, match.index + match[0].length]);
}
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)) {
function markRanges(text: string, marks: string[]): Range[] {
const folded = fold(text);
if (folded.length !== text.length) {
return [];
}
const ranges: Range[] = [];
for (const mark of marks.map(fold)) {
if (mark.length < PREFIX_MIN) {
ranges.push(...wordRanges(text, [mark]));
continue;
}
if (match.index > last) {
segments.push({ hit: false, text: shown.slice(last, match.index) });
for (let at = folded.indexOf(mark); at !== -1; ) {
ranges.push([at, at + mark.length]);
at = folded.indexOf(mark, at + mark.length);
}
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) });
ranges.sort((a, b) => a[0] - b[0]);
const merged: Range[] = [];
for (const [start, end] of ranges) {
const last = merged.at(-1);
if (last && start <= last[1]) {
last[1] = Math.max(last[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
function split(text: string, ranges: Range[]): Segment[] {
const segments: Segment[] = [];
let last = 0;
for (const [start, end] of ranges) {
if (start > last) {
segments.push({ hit: false, text: text.slice(last, start) });
}
segments.push({ hit: true, text: text.slice(start, end) });
last = end;
}
if (last < text.length) {
segments.push({ hit: false, text: text.slice(last) });
}
return segments;
}
function windowed(text: string, ranges: Range[]): Segment[] {
const first = ranges[0]?.[0];
if (first === undefined || first <= REACH) {
return split(text, ranges);
}
const cut = first - LEAD;
let start = cut;
while (start > 0 && !SPACE.test(text[start - 1])) {
start -= 1;
}
if (start === 0) {
start = cut;
}
const shifted = ranges.map(([a, b]): Range => [a - start, b - start]);
return [{ hit: false, text: "…" }, ...split(text.slice(start), shifted)];
}
export function highlight(text: string, terms: string[]): Segment[] {
return windowed(text, wordRanges(text, terms));
}
export function highlightMarks(text: string, marks: string[]): Segment[] {
return windowed(text, markRanges(text, marks));
}
export function markText(text: string, marks: string[]): Segment[] {
return split(text, markRanges(text, marks));
}
export function hasMark(text: string | null, marks: string[]): boolean {
return text !== null && markRanges(text, marks).length > 0;
}
export function buttonHasMark(button: InlineButton, marks: string[]): boolean {
return [button.text, button.data, button.url].some((value) =>
hasMark(value, marks)
);
}
+27
View File
@@ -9,6 +9,7 @@ import type {
} from "#lib/api/watches.js";
import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { MARK_PARAM } from "#lib/stores/feed.svelte.js";
export const KIND_ICONS: Record<WatchKind, string> = {
keyword: "search",
@@ -162,6 +163,32 @@ export function alertTitle(alert: AlertItem): string {
return personName(alert.peer);
}
function alertMarks(alert: AlertItem): string[] {
const pattern = patternOf(alert.watch.params);
if (alert.watch.kind !== "keyword" || !pattern) {
return [];
}
if (!alert.watch.params.regex) {
return [pattern];
}
try {
const found = (alert.message?.text ?? "").matchAll(
new RegExp(pattern, "gi")
);
return [...new Set([...found].map((match) => match[0]).filter(Boolean))];
} catch {
return [];
}
}
export function alertHref(alert: AlertItem): string {
const url = new URL(alert.path, "http://app");
for (const mark of alertMarks(alert)) {
url.searchParams.append(MARK_PARAM, mark);
}
return `${url.pathname}${url.search}`;
}
export function alertLabel(alert: AlertItem): string {
if (alert.watch.name) {
return alert.watch.name;
+26
View File
@@ -13,10 +13,36 @@ export const search = {
"search.clear": { en: "Clear", ru: "Очистить" },
"search.deleted": { en: "Deleted", ru: "Удалённые" },
"search.edited": { en: "Edited", ru: "Изменённые" },
"search.everywhere": { en: "Search everywhere", ru: "Искать везде" },
"search.everywhereHint": {
en: "In buttons, links and message data",
ru: "В кнопках, ссылках и данных сообщений",
},
"search.everywhereNothing": {
en: "Nothing else anywhere",
ru: "Больше нигде не нашлось",
},
"search.everywhereScanned": {
en: "Searching, back to {date}",
ru: "Ищу, проверено до {date}",
},
"search.everywhereScanning": {
en: "Searching everywhere",
ru: "Ищу везде",
},
"search.everywhereSection": { en: "Everywhere", ru: "Везде" },
"search.failed": {
en: "Search did not respond",
ru: "Поиск не ответил",
},
"search.found.button": { en: "Button “{text}”", ru: "Кнопка «{text}»" },
"search.found.data": { en: "Message data", ru: "Данные сообщения" },
"search.found.file": { en: "File name", ru: "Имя файла" },
"search.found.forward": { en: "Forwarded from", ru: "Переслано от" },
"search.found.link": { en: "Link", ru: "Ссылка" },
"search.found.preview": { en: "Link preview", ru: "Превью ссылки" },
"search.found.reply": { en: "Reply", ru: "Ответ" },
"search.found.text": { en: "Text", ru: "Текст" },
"search.from": { en: "From", ru: "От кого" },
"search.fromName": { en: "Name or @username", ru: "Имя или @username" },
"search.inChat": { en: "Search this chat", ru: "Поиск в чате" },
+2 -2
View File
@@ -3,7 +3,7 @@ import {
type LiveAlertEvent,
unseenAlerts,
} from "#lib/api/watches.js";
import { alertLine, alertTitle } from "#lib/format/watches.js";
import { alertHref, alertLine, alertTitle } from "#lib/format/watches.js";
import { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
@@ -49,7 +49,7 @@ function createAlerts() {
});
popup.onclick = () => {
window.focus();
goto(alert.path);
goto(alertHref(alert));
popup.close();
};
}
+2
View File
@@ -9,6 +9,7 @@ const LIVE_DELAY = 600;
const KIND_PARAM = "feed";
const CHATS_PARAM = "chats";
const MESSAGE_PARAM = "message";
export const MARK_PARAM = "mark";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
@@ -72,6 +73,7 @@ export function messageHref(
export function withoutMessage(url: UrlLike): string {
const next = new URL(url.href);
next.searchParams.delete(MESSAGE_PARAM);
next.searchParams.delete(MARK_PARAM);
return href(next);
}
+49 -2
View File
@@ -11,6 +11,7 @@ import type {
SearchQuery,
} from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { MARK_PARAM } from "#lib/stores/feed.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -30,6 +31,7 @@ const DEBOUNCE_MS = 250;
const REMOTE_DELAY_MS = 450;
const CHAT_LIMIT = 30;
const PAGE_SIZE = 30;
export const EVERYWHERE_MIN = 3;
const KINDS = new Set<string>(["private", "group", "channel"]);
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const PARAMS = [
@@ -41,6 +43,7 @@ const PARAMS = [
"from",
"since",
"until",
"everywhere",
] as const;
type Param = (typeof PARAMS)[number];
@@ -90,6 +93,7 @@ export function createHitPager() {
let hits = $state<SearchHit[]>([]);
let terms = $state<string[]>([]);
let cursor = $state<string | null>(null);
let scannedTo = $state<string | null>(null);
let loading = $state(false);
let appending = $state(false);
let failed = $state(false);
@@ -106,6 +110,7 @@ export function createHitPager() {
hits = request.cursor ? [...hits, ...result.hits] : result.hits;
({ terms } = result);
cursor = result.next_cursor;
scannedTo = result.scanned_to;
} catch {
if (current === seq) {
failed = true;
@@ -122,6 +127,7 @@ export function createHitPager() {
seq += 1;
base = request;
cursor = null;
scannedTo = null;
failed = false;
appending = false;
loading = request !== null;
@@ -160,12 +166,21 @@ export function createHitPager() {
run(base);
},
run,
get scannedTo() {
return scannedTo;
},
get terms() {
return terms;
},
};
}
export type HitPager = ReturnType<typeof createHitPager>;
export function hitKey(hit: SearchHit): string {
return `${hit.chat_id}:${hit.message_id}`;
}
function createSearch() {
let open = $state(false);
let draft = $state("");
@@ -177,7 +192,9 @@ function createSearch() {
let lastRequest = "";
let lastText = "";
let seq = 0;
let lastEverywhere = "";
const messages = createHitPager();
const everywhere = createHitPager();
const params = $derived(page.url.searchParams);
const urlQuery = $derived(params.get("q") ?? "");
@@ -192,6 +209,14 @@ function createSearch() {
}
return JSON.stringify([accounts.selectedId, toRequest(query, filters)]);
});
const everywhereRequest = $derived.by(() => {
const query = urlQuery.trim();
if (!params.has("everywhere") || query.length < EVERYWHERE_MIN) {
return "";
}
const deep = { ...toRequest(query, filters), everywhere: true };
return JSON.stringify([accounts.selectedId, deep]);
});
const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id));
@@ -272,7 +297,9 @@ function createSearch() {
stopTyping();
lastRequest = "";
lastText = "";
lastEverywhere = "";
messages.run(null);
everywhere.run(null);
runText("");
}
@@ -288,14 +315,21 @@ function createSearch() {
const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
write(update).catch(() => undefined);
},
everywhere,
get everywhereRequest() {
return everywhereRequest;
},
get filtered() {
return filtered;
},
get filters() {
return filters;
},
hitHref(chatId: number, messageId: number): string {
hitHref(chatId: number, messageId: number, marks: string[]): string {
const next = new URLSearchParams({ message: String(messageId) });
for (const mark of marks) {
next.append(MARK_PARAM, mark);
}
for (const key of PARAMS) {
const value = params.get(key);
if (value) {
@@ -315,6 +349,9 @@ function createSearch() {
return request;
},
reset,
searchEverywhere() {
write({ everywhere: "1" }).catch(() => undefined);
},
setFilter(key: "from" | "kind" | "since" | "until", value: string | null) {
write({ [key]: value }).catch(() => undefined);
},
@@ -329,7 +366,11 @@ function createSearch() {
}
timer = setTimeout(() => {
timer = null;
write({ q: draft.trim() ? draft : null })
const same = draft.trim() === urlQuery.trim();
write({
everywhere: same ? params.get("everywhere") : null,
q: draft.trim() ? draft : null,
})
.catch(() => undefined)
.finally(() => {
if (timer === null) {
@@ -352,6 +393,12 @@ function createSearch() {
runText(value);
}
},
syncEverywhere(key: string) {
if (key !== lastEverywhere) {
lastEverywhere = key;
everywhere.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
}
},
get text() {
return text;
},
+3 -2
View File
@@ -36,6 +36,7 @@ export interface PanelState {
interface JumpTarget {
chatId: number;
marks: string[];
messageId: number;
}
@@ -141,8 +142,8 @@ function createUi() {
: null;
},
record,
requestJump(chatId: number, messageId: number) {
jumpTarget = { chatId, messageId };
requestJump(chatId: number, messageId: number, marks: string[] = []) {
jumpTarget = { chatId, marks, messageId };
},
resetPanel() {
panel = null;
+6
View File
@@ -159,6 +159,12 @@ html.theme-transition * {
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
select {
font-size: max(1em, 1rem) !important;
}
}
.bg-menu-content {
@@ -4,7 +4,7 @@
import MessageList from "#lib/components/MessageList.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { withoutMessage } from "#lib/stores/feed.svelte.js";
import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -27,7 +27,11 @@
const messageId = Number(linkedMessage);
untrack(() => {
if (Number.isSafeInteger(messageId)) {
ui.requestJump(chatId, messageId);
ui.requestJump(
chatId,
messageId,
page.url.searchParams.getAll(MARK_PARAM)
);
}
goto(withoutMessage(page.url), { replace: true, reset: false });
});