Compare commits

..
13 Commits
Author SHA1 Message Date
hh c2180e80ad feat(backend,frontend): feed gets a bots tab so private is people only, a folder's context menu opens the feed of its chats 2026-10-26 01:50:44 +00:00
renovate Bot b9e8a96774 chore(deps): update ghcr.io/astral-sh/uv:python3.13-trixie-slim docker digest to 331fbb1 (main) (#22)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:41 +00:00
hh d660079c02 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 2026-10-23 22:59:29 +00:00
hh acfa726e61 fix(backend,frontend): expired story keeps its kind and archive heals stories that lost it, phone inputs no longer zoom the page 2026-10-23 16:41:42 +02:00
hh d9378056f4 fix(frontend): chat header keeps the name readable on a phone, all its actions are in its menu, menus open below the pointer and stay on screen 2026-10-19 06:19:46 +00:00
hh c307426f4e fix(frontend): context menus stay inside a narrow screen 2026-10-19 06:12:48 +00:00
hh 977dee3cb2 chore(frontend): toasts lose the success kind nobody calls anymore 2026-10-19 06:07:54 +00:00
hh bca0b34636 feat(frontend): a finished action answers on the button that was pressed instead of a toast 2026-10-19 06:07:54 +00:00
hh e228d999dd fix(frontend): account phone stored with a plus is not shown with two 2026-10-19 05:47:09 +00:00
hh aff8a9e443 fix(frontend): empty media and links tabs keep a gap under the tabs instead of sticking to them 2026-10-19 05:38:24 +00:00
hh 25364f4523 feat(frontend): forwarded header, pinned bar, folder tabs, inline buttons, edit history and deleted accounts speak russian and english 2026-10-19 05:38:24 +00:00
hh 0cd34f3037 feat(frontend): profile media, files and links tabs speak russian and english 2026-10-19 05:38:24 +00:00
hh cff493f5dd feat(frontend): analytics panel and online statuses speak russian and english with real plurals 2026-10-19 05:37:44 +00:00
78 changed files with 1620 additions and 412 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20 FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:331fbb158258608b9391e802305a9a2c9e42d689435c1d98706647122a122ab0
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
+5 -4
View File
@@ -298,10 +298,11 @@ async def get_feed(
"""Deleted and edited messages and profile changes across chats, newest first. """Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all; `kind` picks deleted, edited, profile, messages (deleted and edited) or all;
`since` keeps items at or after that time; `chat_id` or `chat_kind` (dm, `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm
group, channel) narrow it down. Edited items carry `before` and `after` with people, bot, group, channel) narrow it down. Edited items carry
texts. A deleted item with `message_count` above 1 is a bulk deletion in `before` and `after` texts. A deleted item with `message_count` above 1 is
one chat; read its messages with `get_deleted_messages` for that chat. a bulk deletion in one chat; read its messages with `get_deleted_messages`
for that chat.
Profile items cover people in private chats or contacts and chats in the Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass `title` to `[old, new]`, changes a day apart or closer are merged. Pass
+8 -1
View File
@@ -29,6 +29,7 @@ async def list_feed(
kind: Annotated[FeedKind, Query()] = FeedKind.ALL, kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None, chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None, chat_kind: Annotated[FeedChatKind | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None, since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None, cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
@@ -36,7 +37,13 @@ async def list_feed(
return await feed.list_feed( return await feed.list_feed(
pool, pool,
account_id, account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since), FeedFilter(
kind=kind,
chat_id=chat_id,
chat_kind=chat_kind,
folder_id=folder_id,
since=since,
),
limit=limit, limit=limit,
cursor=cursor, cursor=cursor,
) )
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse 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 import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} _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") @router.get("/stories")
async def list_stories( async def list_stories(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId, account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None, peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StoryView]: ) -> list[StoryView]:
return await peers.get_stories( stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id 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") @router.get("/stories/{peer_id}/{story_id}/media")
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
INSERT INTO stories INSERT INTO stories
(account_id, peer_id, story_id, date, expire_date, caption, media_kind, (account_id, peer_id, story_id, date, expire_date, caption, media_kind,
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at) 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 ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
date = EXCLUDED.date, date = EXCLUDED.date,
expire_date = EXCLUDED.expire_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, downloaded = stories.downloaded OR EXCLUDED.downloaded,
views = EXCLUDED.views, views = EXCLUDED.views,
pinned = EXCLUDED.pinned, pinned = EXCLUDED.pinned,
deleted = EXCLUDED.deleted, deleted = false,
raw = EXCLUDED.raw 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( async def is_downloaded(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
raw: str, raw: str,
*, *,
pinned: bool, pinned: bool,
deleted: bool,
downloaded: bool, downloaded: bool,
) -> None: ) -> None:
await pool.execute( await pool.execute(
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
downloaded, downloaded,
views, views,
pinned, pinned,
deleted,
raw, 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: async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
peer_id = story_peer_id(story) 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 storage_key: str | None = None
file_size: int | None = None file_size: int | None = None
downloaded = False downloaded = False
stored = await repository.is_downloaded( stored = await repository.is_downloaded(
capture.pool, capture.account_id, peer_id, story.id 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) data = await download_bytes(client, story)
if data is not None: if data is not None:
storage_key = capture.storage.put(data) storage_key = capture.storage.put(data)
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
story.views, story.views,
raw_json(story.raw), raw_json(story.raw),
pinned=bool(story.pinned), pinned=bool(story.pinned),
deleted=bool(story.deleted),
downloaded=downloaded, 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)}" 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( def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None kind: str | None, mime: str | None, file_name: str | None = None
) -> str: ) -> str:
+41 -4
View File
@@ -5,6 +5,7 @@ from enum import StrEnum
import asyncpg import asyncpg
from utils.policy import repository as policy_repository
from utils.read import chats, peers from utils.read import chats, peers
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ( from utils.read.models import (
@@ -45,6 +46,13 @@ LEFT JOIN LATERAL (
) p ON true ) p ON true
""" """
_IS_BOT = """EXISTS (
SELECT 1 FROM peers p
WHERE p.account_id = feed_items.account_id AND p.peer_id = feed_items.chat_id
AND COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
p.raw->>'type' = 'ChatType.BOT', false)
)"""
_UNSEEN = """ _UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted, SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited count(*) FILTER (WHERE kind = 'edited') AS edited
@@ -69,15 +77,25 @@ class FeedKind(StrEnum):
class FeedChatKind(StrEnum): class FeedChatKind(StrEnum):
DM = "dm" DM = "dm"
BOT = "bot"
GROUP = "group" GROUP = "group"
CHANNEL = "channel" CHANNEL = "channel"
_CHAT_KIND_CLAUSES = {
FeedChatKind.DM: f"chat_kind = 'dm' AND NOT {_IS_BOT}",
FeedChatKind.BOT: f"chat_kind = 'dm' AND {_IS_BOT}",
FeedChatKind.GROUP: "chat_kind = 'group'",
FeedChatKind.CHANNEL: "chat_kind = 'channel'",
}
@dataclass(slots=True) @dataclass(slots=True)
class FeedFilter: class FeedFilter:
kind: FeedKind = FeedKind.ALL kind: FeedKind = FeedKind.ALL
chat_id: int | None = None chat_id: int | None = None
chat_kind: FeedChatKind | None = None chat_kind: FeedChatKind | None = None
folder_id: int | None = None
since: datetime | None = None since: datetime | None = None
@@ -90,18 +108,34 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
return _EPOCH + int(micros) * _MICROSECOND, int(key) return _EPOCH + int(micros) * _MICROSECOND, int(key)
async def _folder_chat_ids(
pool: asyncpg.Pool, account_id: int, folder_id: int | None
) -> list[int] | None:
if folder_id is None:
return None
folder = await policy_repository.get_folder(pool, account_id, folder_id)
if folder is None:
return []
return await chats.folder_chat_ids(pool, account_id, folder)
def _where( def _where(
account_id: int, feed: FeedFilter, cursor: str | None account_id: int,
feed: FeedFilter,
folder_chat_ids: list[int] | None,
cursor: str | None,
) -> tuple[str, list[object]]: ) -> tuple[str, list[object]]:
params: list[object] = [account_id] params: list[object] = [account_id]
clauses = ["account_id = $1"] clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES: if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'") clauses.append("kind <> 'profile'")
if feed.chat_kind is not None:
clauses.append(_CHAT_KIND_CLAUSES[feed.chat_kind])
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES} exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = ( conditions = (
("kind = {}", feed.kind.value if exact else None), ("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id), ("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None), ("chat_id = ANY({}::bigint[])", folder_chat_ids),
("at >= {}", feed.since), ("at >= {}", feed.since),
) )
for template, value in conditions: for template, value in conditions:
@@ -191,7 +225,9 @@ async def _build_items(
chat=FeedChat( chat=FeedChat(
chat_id=row["chat_id"], chat_id=row["chat_id"],
title=chat.title if chat else None, title=chat.title if chat else None,
kind=row["chat_kind"], kind=FeedChatKind.BOT
if chat and chat.is_bot and row["chat_kind"] == FeedChatKind.DM
else row["chat_kind"],
has_avatar=chat.has_avatar if chat else False, has_avatar=chat.has_avatar if chat else False,
), ),
message_id=row["message_id"], message_id=row["message_id"],
@@ -218,7 +254,8 @@ async def list_feed(
cursor: str | None = None, cursor: str | None = None,
) -> FeedPage: ) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT)) size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor) folder_chat_ids = await _folder_chat_ids(pool, account_id, feed.folder_id)
where, params = _where(account_id, feed, folder_chat_ids, cursor)
params.append(size + 1) params.append(size + 1)
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608 f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
+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 "SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories " "storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} " 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)}", f"OFFSET ${len(params)}",
*params, *params,
) )
@@ -95,3 +95,16 @@ async def get_story(
story_id, story_id,
) )
return StoryView(**dict(row)) if row else None 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) _MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"] 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: 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_from: datetime | None = None
date_to: datetime | None = None date_to: datetime | None = None
regex: str | None = None regex: str | None = None
everywhere: bool = False
cursor: str | None = None cursor: str | None = None
limit: int = DEFAULT_LIMIT limit: int = DEFAULT_LIMIT
@@ -45,6 +49,13 @@ class SearchFilters(BaseModel):
return value return value
class Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel): class SearchHit(BaseModel):
chat_id: int chat_id: int
message_id: int message_id: int
@@ -56,12 +67,14 @@ class SearchHit(BaseModel):
edited_at: datetime | None edited_at: datetime | None
deleted_at: datetime | None deleted_at: datetime | None
source: HitSource source: HitSource
extracted_text: str | None extracted_text: str | None = None
version_text: str | None version_text: str | None = None
version_at: datetime | None version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel): class SearchPage(BaseModel):
hits: list[SearchHit] hits: list[SearchHit]
next_cursor: str | None next_cursor: str | None
terms: list[str] 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 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 ( from utils.search.models import (
MAX_LIMIT, MAX_LIMIT,
ChatKind, ChatKind,
@@ -10,6 +17,12 @@ from utils.search.models import (
encode_cursor, encode_cursor,
) )
MIN_NEEDLE = 3
_WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """ _PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text, SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery '''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
@@ -76,6 +89,24 @@ _WITH_MEDIA = (
_SOURCES = ("text", "stt", "version") _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: class _Binder:
def __init__(self) -> None: 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 def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
await conn.execute("SET LOCAL jit = off") await conn.execute("SET LOCAL jit = off")
if filters.everywhere:
return await _everywhere(conn, filters)
return await _search(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: async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip() query = (filters.query or "").strip()
terms: list[str] = [] terms: list[str] = []
@@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag
return SearchPage(hits=[], next_cursor=None, terms=terms) return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"] tsquery = parsed["tsquery"]
channels: list[int] = [] channels = await _channels(conn, filters)
if filters.kind in {"group", "channel"}:
channels = await conn.fetchval(_CHANNELS, filters.account_id)
bind = _Binder() bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind)) where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT) size = min(filters.limit, MAX_LIMIT)
+4
View File
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
def get(self, key: str) -> bytes: def get(self, key: str) -> bytes:
return self._path(key).read_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: def exists(self, key: str) -> bool:
return self._path(key).exists() return self._path(key).exists()
+1
View File
@@ -284,6 +284,7 @@ export interface FeedQuery {
chat_id?: number; chat_id?: number;
chat_kind?: FeedChatKind; chat_kind?: FeedChatKind;
cursor?: string; cursor?: string;
folder_id?: number;
kind?: FeedKind; kind?: FeedKind;
limit?: number; limit?: number;
} }
+22 -2
View File
@@ -7,7 +7,24 @@ export type PolicyScopeType =
| "default_channel" | "default_channel"
| "folder" | "folder"
| "chat"; | "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 SearchChatKind = "private" | "group" | "channel";
export type JobStatus = export type JobStatus =
@@ -228,7 +245,7 @@ export interface MessageVersion {
} }
export type FeedKind = "all" | "deleted" | "edited" | "profile"; export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel"; export type FeedChatKind = "dm" | "bot" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
chat_id: number; chat_id: number;
@@ -289,6 +306,7 @@ export interface SearchHit {
deleted_at: string | null; deleted_at: string | null;
edited_at: string | null; edited_at: string | null;
extracted_text: string | null; extracted_text: string | null;
found: SearchFound | null;
has_media: boolean; has_media: boolean;
media_kind: string | null; media_kind: string | null;
message_id: number; message_id: number;
@@ -302,6 +320,7 @@ export interface SearchHit {
export interface SearchPage { export interface SearchPage {
hits: SearchHit[]; hits: SearchHit[];
next_cursor: string | null; next_cursor: string | null;
scanned_to: string | null;
terms: string[]; terms: string[];
} }
@@ -312,6 +331,7 @@ export interface SearchQuery {
date_to?: string; date_to?: string;
deleted?: boolean; deleted?: boolean;
edited?: boolean; edited?: boolean;
everywhere?: boolean;
has_media?: boolean; has_media?: boolean;
kind?: SearchChatKind; kind?: SearchChatKind;
limit?: number; limit?: number;
@@ -185,6 +185,16 @@
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")} <ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem >{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem icon="stats" onselect={() => ui.openPanel("presence")}
>{t("chat.analytics")}</ContextMenuItem
>
<ContextMenuItem
icon={backfilled ? "check" : "cloud-download"}
onselect={backfill}
>{backfilled
? t("chat.backfill.started")
: t("chat.backfill")}</ContextMenuItem
>
<ContextMenuItem <ContextMenuItem
icon="play-story" icon="play-story"
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
@@ -209,6 +219,7 @@
</Button> </Button>
<Button <Button
aria-label={t("feed.changes")} aria-label={t("feed.changes")}
class="wide-only"
onclick={() => ui.openPanel("changes")} onclick={() => ui.openPanel("changes")}
round round
smaller smaller
@@ -227,6 +238,7 @@
</Button> </Button>
<Button <Button
aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")} aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
class="wide-only"
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
@@ -286,6 +298,12 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
margin-left: auto; margin-left: auto;
@media (max-width: 600px) {
:global(.wide-only) {
display: none;
}
}
} }
.title { .title {
+49 -2
View File
@@ -8,14 +8,16 @@
jumboEmojiCount, jumboEmojiCount,
linkHref, linkHref,
} from "#lib/format/entities.js"; } from "#lib/format/entities.js";
import { markText } from "#lib/format/search.js";
interface Props { interface Props {
entities: EntityView[]; entities: EntityView[];
marks?: string[];
own?: boolean; own?: boolean;
text: string; text: string;
} }
let { text, entities, own = false }: Props = $props(); let { text, entities, marks = [], own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities)); const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0); const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
@@ -32,13 +34,29 @@
)} )}
{#each items as node, i (i)} {#each items as node, i (i)}
{#if node.kind === "text"} {#if node.kind === "text"}
{node.text} {@render plain(node.text)}
{:else} {:else}
{@render entity(node)} {@render entity(node)}
{/if} {/if}
{/each} {/each}
{/snippet} {/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( {#snippet entity(
node: EntityNode node: EntityNode
)} )}
@@ -186,6 +204,35 @@
background-color: var(--color-primary-tint); 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 { .spoiler {
cursor: pointer; cursor: pointer;
+11 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { page } from "$app/state";
interface Tab { interface Tab {
id: number | null; id: number | null;
@@ -10,7 +13,7 @@
} }
const tabs = $derived<Tab[]>([ const tabs = $derived<Tab[]>([
{ id: null, title: "All Chats" }, { id: null, title: t("folders.all") },
...folders.list.map((folder) => ({ ...folders.list.map((folder) => ({
id: folder.folder_id, id: folder.folder_id,
title: folder.title, title: folder.title,
@@ -92,8 +95,14 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem
icon="recent"
onselect={() => feed.open(page.url, tab.id)}
>
{t("feed.folder")}
</ContextMenuItem>
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ForwardView } from "#lib/api/types.js"; import type { ForwardView } from "#lib/api/types.js";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
interface Props { interface Props {
@@ -14,20 +15,20 @@
); );
const name = $derived.by(() => { const name = $derived.by(() => {
if (forward.kind === "channel") { if (forward.kind === "channel") {
return forward.chat_title ?? "Channel"; return forward.chat_title ?? t("forward.channel");
} }
if (forward.kind === "hidden") { if (forward.kind === "hidden") {
return forward.from_name ?? "Hidden account"; return forward.from_name ?? t("forward.hidden");
} }
if (peer) { if (peer) {
return peerName(peer); return peerName(peer);
} }
return forward.from_name ?? "Unknown"; return forward.from_name ?? t("forward.unknown");
}); });
</script> </script>
<div class="ForwardHeader"> <div class="ForwardHeader">
<span class="label">Forwarded from</span> <span class="label">{t("forward.from")}</span>
<span class="name">{name}</span> <span class="name">{name}</span>
{#if forward.signature} {#if forward.signature}
<span class="signature">({forward.signature})</span> <span class="signature">({forward.signature})</span>
@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { InlineButton } from "#lib/api/types.js"; import type { InlineButton } from "#lib/api/types.js";
import { buttonHasMark } from "#lib/format/search.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
marks?: string[];
rows: InlineButton[][]; rows: InlineButton[][];
} }
let { rows }: Props = $props(); let { marks = [], rows }: Props = $props();
let copied = $state<string | null>(null); let copied = $state<string | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined; let resetTimer: ReturnType<typeof setTimeout> | undefined;
@@ -26,12 +29,14 @@
<div class="row"> <div class="row">
{#each row as button, colIndex (colIndex)} {#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`} {@const key = `${rowIndex}:${colIndex}`}
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
{#if button.kind === "url" && button.url} {#if button.kind === "url" && button.url}
<a <a
class="button" class="button"
href={safeHref(button.url)} href={safeHref(button.url)}
rel="noopener" rel="noopener"
target="_blank" target="_blank"
class:found={lit}
> >
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
<span class="corner">↗</span> <span class="corner">↗</span>
@@ -41,13 +46,14 @@
class="button" class="button"
onclick={() => copy(key, button.data ?? "")} onclick={() => copy(key, button.data ?? "")}
type="button" type="button"
class:found={lit}
> >
<span class="label" <span class="label"
>{copied === key ? "Скопировано" : button.text}</span >{copied === key ? t("inline.copied") : button.text}</span
> >
</button> </button>
{:else} {:else}
<span class="button static"> <span class="button static" class:found={lit}>
<span class="label">{button.text}</span> <span class="label">{button.text}</span>
</span> </span>
{/if} {/if}
@@ -103,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 { .label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+11 -3
View File
@@ -17,6 +17,7 @@
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -49,6 +50,7 @@
let loading = $state(false); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let token = 0; let token = 0;
const queued = createFlash<number>();
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : ""); const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
const isImage = $derived(mime.startsWith("image/") || kind === "photo"); const isImage = $derived(mime.startsWith("image/") || kind === "photo");
@@ -140,9 +142,10 @@
if (messageId === null) { if (messageId === null) {
return; return;
} }
const target = messageId;
try { try {
await fetchMedia(chatId, messageId); await fetchMedia(chatId, target);
toasts.success(t("media.queued")); queued.show(target);
} catch { } catch {
toasts.error(t("media.queueFailed")); toasts.error(t("media.queueFailed"));
} }
@@ -312,7 +315,12 @@
<div class="media-message"> <div class="media-message">
<p>{t("media.notDownloaded")}</p> <p>{t("media.notDownloaded")}</p>
<Button fluid onclick={queueFetch} variant="primary"> <Button fluid onclick={queueFetch} variant="primary">
{t("media.downloadToArchive")} {#if queued.current === messageId}
<Icon name="check" />
{t("chat.media.queued")}
{:else}
{t("media.downloadToArchive")}
{/if}
</Button> </Button>
</div> </div>
{:else if result?.state === "missing"} {:else if result?.state === "missing"}
@@ -19,6 +19,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WebPage from "#lib/components/WebPage.svelte"; import WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js"; 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 { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
@@ -31,7 +32,7 @@
interface Props { interface Props {
animate: boolean; animate: boolean;
firstInGroup: boolean; firstInGroup: boolean;
highlighted: boolean; highlight: string[] | null;
isGroupChat: boolean; isGroupChat: boolean;
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
@@ -47,7 +48,7 @@
isGroupChat, isGroupChat,
firstInGroup, firstInGroup,
lastInGroup, lastInGroup,
highlighted, highlight,
animate, animate,
onjump, onjump,
onmedia, onmedia,
@@ -55,6 +56,14 @@
}: Props = $props(); }: Props = $props();
const deleted = $derived(message.deleted_at !== null); 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 hasText = $derived(Boolean(message.text));
const special = $derived( const special = $derived(
Boolean( Boolean(
@@ -162,7 +171,7 @@
data-message-id={message.message_id} data-message-id={message.message_id}
class:deleted class:deleted
class:first-in-group={firstInGroup} class:first-in-group={firstInGroup}
class:highlighted class:highlighted={highlight !== null && !markedInside}
class:last-in-group={lastInGroup} class:last-in-group={lastInGroup}
class:own class:own
class:with-avatar={isGroupChat} class:with-avatar={isGroupChat}
@@ -239,6 +248,7 @@
<div class="text"> <div class="text">
<EntityText <EntityText
entities={message.entities} entities={message.entities}
{marks}
{own} {own}
text={message.text ?? ""} text={message.text ?? ""}
/> />
@@ -270,7 +280,7 @@
{own} {own}
/> />
{#if message.inline_buttons.length} {#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} /> <InlineButtons {marks} rows={message.inline_buttons} />
{/if} {/if}
{#if hasCallbacks} {#if hasCallbacks}
<button class="detail-chip" onclick={openCallbacks} type="button"> <button class="detail-chip" onclick={openCallbacks} type="button">
+17 -10
View File
@@ -38,6 +38,7 @@
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360; const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400; const SETTLE_DELAY = 400;
const HIGHLIGHT_MS = 3000;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -57,7 +58,7 @@
let mediaIndex = $state(0); let mediaIndex = $state(0);
let versionsOpen = $state(false); let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null); 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 calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null); let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | 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; 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 owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>( const target = container?.querySelector<HTMLElement>(
`[data-message-id="${owner}"]` `[data-message-id="${owner}"]`
@@ -242,13 +247,13 @@
behavior: smooth ? "smooth" : "auto", behavior: smooth ? "smooth" : "auto",
block: "center", block: "center",
}); });
highlightId = owner; highlight = { id: owner, marks };
if (highlightTimer) { if (highlightTimer) {
clearTimeout(highlightTimer); clearTimeout(highlightTimer);
} }
highlightTimer = setTimeout(() => { highlightTimer = setTimeout(() => {
highlightId = null; highlight = null;
}, 1600); }, HIGHLIGHT_MS);
} }
function scrollToBottom() { function scrollToBottom() {
@@ -283,7 +288,7 @@
ui.clearJump(); ui.clearJump();
await loadAround(pending.messageId); await loadAround(pending.messageId);
await tick(); await tick();
jumpToMessage(pending.messageId, false); jumpToMessage(pending.messageId, false, pending.marks);
return; return;
} }
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
@@ -574,14 +579,14 @@
} }
} }
async function goTo(messageId: number) { async function goTo(messageId: number, marks: string[] = []) {
try { try {
const loaded = ownerOf(messageId) !== null; const loaded = ownerOf(messageId) !== null;
if (!loaded) { if (!loaded) {
await loadAround(messageId); await loadAround(messageId);
} }
await tick(); await tick();
jumpToMessage(messageId, loaded); jumpToMessage(messageId, loaded, marks);
} catch { } catch {
toasts.error(t("thread.loadFailed")); toasts.error(t("thread.loadFailed"));
} }
@@ -593,7 +598,7 @@
return; return;
} }
ui.clearJump(); ui.clearJump();
goTo(target.messageId); goTo(target.messageId, target.marks);
}); });
$effect(() => { $effect(() => {
@@ -663,7 +668,9 @@
<MessageBubble <MessageBubble
animate={!suppressAppear} animate={!suppressAppear}
firstInGroup={row.firstInGroup} firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id} highlight={highlight?.id === row.message.message_id
? highlight.marks
: null}
{isGroupChat} {isGroupChat}
lastInGroup={row.lastInGroup} lastInGroup={row.lastInGroup}
message={row.message} message={row.message}
@@ -6,6 +6,7 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -33,7 +34,7 @@
versions = text; versions = text;
mediaVersions = media; mediaVersions = media;
}) })
.catch(() => toasts.error("Failed to load edit history")) .catch(() => toasts.error(t("versions.failed")))
.finally(() => { .finally(() => {
loading = false; loading = false;
}); });
@@ -46,8 +47,8 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Edit history</Dialog.Title> <Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
<Dialog.Close aria-label="Close" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
@@ -55,11 +56,11 @@
{#if loading} {#if loading}
<div class="centered"><Spinner /></div> <div class="centered"><Spinner /></div>
{:else if versions.length === 0 && mediaVersions.length === 0} {:else if versions.length === 0 && mediaVersions.length === 0}
<p class="empty">No versions recorded.</p> <p class="empty">{t("versions.none")}</p>
{:else} {:else}
{#if mediaVersions.length > 0} {#if mediaVersions.length > 0}
<div class="media-versions"> <div class="media-versions">
<span class="media-label">Media versions</span> <span class="media-label">{t("versions.media")}</span>
<div class="media-strip"> <div class="media-strip">
{#each mediaVersions as media (media.id)} {#each mediaVersions as media (media.id)}
<MediaVersionThumb version={media} /> <MediaVersionThumb version={media} />
@@ -70,12 +71,14 @@
{#each versions as version, index (version.observed_at)} {#each versions as version, index (version.observed_at)}
<div class="version"> <div class="version">
<div class="version-meta"> <div class="version-meta">
<span class="version-label">Version {index + 1}</span> <span class="version-label"
>{t("versions.version", { number: index + 1 })}</span
>
<span class="version-date" <span class="version-date"
>{formatFull(version.observed_at)}</span >{formatFull(version.observed_at)}</span
> >
</div> </div>
<div class="version-text">{version.text ?? "(no text)"}</div> <div class="version-text">{version.text ?? t("feed.noText")}</div>
</div> </div>
{/each} {/each}
{/if} {/if}
+3 -2
View File
@@ -3,6 +3,7 @@
import type { PinnedView } from "#lib/api/types.js"; import type { PinnedView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js"; import { mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props { interface Props {
@@ -19,7 +20,7 @@
? (pinned.text ?? ? (pinned.text ??
(pinned.media_kind (pinned.media_kind
? mediaKindLabel(pinned.media_kind) ? mediaKindLabel(pinned.media_kind)
: "Pinned message")) : t("pinned.message")))
: "" : ""
); );
@@ -56,7 +57,7 @@
> >
<Icon name="pin" size="1.125rem" /> <Icon name="pin" size="1.125rem" />
<span class="body"> <span class="body">
<span class="label">Pinned message</span> <span class="label">{t("pinned.message")}</span>
<span class="preview">{preview}</span> <span class="preview">{preview}</span>
</span> </span>
</button> </button>
+8 -4
View File
@@ -6,6 +6,7 @@
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -15,6 +16,7 @@
let { chatId }: Props = $props(); let { chatId }: Props = $props();
let busy = $state(false); let busy = $state(false);
const started = createFlash<true>();
const item = $derived(discover.get(chatId)); const item = $derived(discover.get(chatId));
const tracked = $derived(item?.tracked ?? false); const tracked = $derived(item?.tracked ?? false);
@@ -34,7 +36,7 @@
busy = true; busy = true;
try { try {
discover.set(await trackChat(chatId, true)); discover.set(await trackChat(chatId, true));
toasts.success(t("track.started")); started.show(true);
chats.refresh(); chats.refresh();
} catch { } catch {
toasts.error(t("track.failed")); toasts.error(t("track.failed"));
@@ -50,7 +52,7 @@
busy = true; busy = true;
try { try {
await enqueueBackfill(chatId, true, true); await enqueueBackfill(chatId, true, true);
toasts.success(t("jobs.backfillStarted")); started.show(true);
} catch { } catch {
toasts.error(t("jobs.backfillFailed")); toasts.error(t("jobs.backfillFailed"));
} finally { } finally {
@@ -67,8 +69,10 @@
<div class="actions"> <div class="actions">
{#if tracked} {#if tracked}
<Button loading={busy} onclick={backfill} pill variant="secondary"> <Button loading={busy} onclick={backfill} pill variant="secondary">
<Icon name="cloud-download" /> <Icon name={started.current ? "check" : "cloud-download"} />
<span>{t("track.loadHistory")}</span> <span
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
>
</Button> </Button>
{:else} {:else}
<Button loading={busy} onclick={track} pill variant="primary"> <Button loading={busy} onclick={track} pill variant="primary">
@@ -16,6 +16,7 @@
import { formatFull, formatListDate } from "#lib/format/datetime.js"; import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { import {
alertAvatar, alertAvatar,
alertHref,
alertLabel, alertLabel,
alertPreview, alertPreview,
alertTitle, alertTitle,
@@ -131,7 +132,7 @@
if (isMobile()) { if (isMobile()) {
ui.resetPanel(); ui.resetPanel();
} }
await goto(alert.path); await goto(alertHref(alert));
} }
async function seeAll() { async function seeAll() {
@@ -1,16 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js"; import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js"; import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
interface Props { interface Props {
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folder?: number | null;
kind: FeedKind; kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void; onchats?: (chats: FeedChatKind | null) => void;
onfolder?: (folder: number | null) => void;
onkind: (kind: FeedKind) => void; onkind: (kind: FeedKind) => void;
} }
let { kind, chats = null, onkind, onchats }: Props = $props(); let {
kind,
chats = null,
folder = null,
onkind,
onchats,
onfolder,
}: Props = $props();
const folderTitle = $derived(
folders.list.find((item) => item.folder_id === folder)?.title ??
t("feed.folder")
);
const KIND_KEYS = { const KIND_KEYS = {
all: "feed.filter.all", all: "feed.filter.all",
@@ -20,6 +36,7 @@
} as const; } as const;
const CHAT_KEYS = { const CHAT_KEYS = {
bot: "feed.chats.bot",
channel: "feed.chats.channel", channel: "feed.chats.channel",
dm: "feed.chats.dm", dm: "feed.chats.dm",
group: "feed.chats.group", group: "feed.chats.group",
@@ -55,6 +72,20 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if folder !== null && onfolder}
<div class="group">
<button
aria-pressed="true"
class="chip on"
onclick={() => onfolder(null)}
type="button"
>
<Icon name="folder" size="0.875rem" />
{folderTitle}
<Icon name="close" size="0.875rem" />
</button>
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -78,6 +109,7 @@
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
gap: 0.25rem;
align-items: center; align-items: center;
height: 1.75rem; height: 1.75rem;
@@ -14,11 +14,12 @@
interface Props { interface Props {
chatId?: number; chatId?: number;
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folderId?: number | null;
kind: FeedKind; kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void; onopen: (item: FeedItem, messageId: number) => void;
} }
let { kind, chats = null, chatId, onopen }: Props = $props(); let { kind, chats = null, folderId = null, chatId, onopen }: Props = $props();
const PAGE = 30; const PAGE = 30;
const EMPTY_KEYS = { const EMPTY_KEYS = {
@@ -38,6 +39,7 @@
const filter = $derived<FeedQuery>({ const filter = $derived<FeedQuery>({
chat_id: chatId, chat_id: chatId,
chat_kind: chats ?? undefined, chat_kind: chats ?? undefined,
folder_id: folderId ?? undefined,
kind, kind,
limit: PAGE, limit: PAGE,
}); });
@@ -55,12 +55,19 @@
</header> </header>
<FeedFilters <FeedFilters
chats={filter.chats} chats={filter.chats}
folder={filter.folder}
kind={filter.kind} kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })} onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onfolder={(folder) => feed.filter(page.url, { ...filter, folder })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })} onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/> />
<div class="feed-body custom-scroll"> <div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} /> <FeedList
chats={filter.chats}
folderId={filter.folder}
kind={filter.kind}
onopen={open}
/>
</div> </div>
</div> </div>
@@ -11,6 +11,7 @@
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js"; import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -24,6 +25,7 @@
let starting = $state(false); let starting = $state(false);
let syncing = $state(false); let syncing = $state(false);
let syncingContacts = $state(false); let syncingContacts = $state(false);
const started = createFlash<"backfill" | "sync" | "contacts">();
const picker = createChatPicker(); const picker = createChatPicker();
const availableChats = $derived(picker.results); const availableChats = $derived(picker.results);
@@ -58,7 +60,7 @@
starting = true; starting = true;
try { try {
await enqueueBackfill(selected, media, true); await enqueueBackfill(selected, media, true);
toasts.success(t("jobs.backfillStarted")); started.show("backfill");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.backfillFailed")); toasts.error(t("jobs.backfillFailed"));
@@ -74,7 +76,7 @@
syncing = true; syncing = true;
try { try {
await syncDialogs(); await syncDialogs();
toasts.success(t("jobs.syncStarted")); started.show("sync");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.syncFailed")); toasts.error(t("jobs.syncFailed"));
@@ -90,7 +92,7 @@
syncingContacts = true; syncingContacts = true;
try { try {
await syncContacts(); await syncContacts();
toasts.success(t("jobs.contactsStarted")); started.show("contacts");
version += 1; version += 1;
} catch { } catch {
toasts.error(t("jobs.contactsFailed")); toasts.error(t("jobs.contactsFailed"));
@@ -110,8 +112,13 @@
<p class="hint">{t("jobs.dialogsHint")}</p> <p class="hint">{t("jobs.dialogsHint")}</p>
<div class="action"> <div class="action">
<Button fluid loading={syncing} onclick={sync} variant="secondary"> <Button fluid loading={syncing} onclick={sync} variant="secondary">
<Icon name="reload" /> {#if started.current === "sync"}
<span>{t("jobs.syncDialogs")}</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="reload" />
<span>{t("jobs.syncDialogs")}</span>
{/if}
</Button> </Button>
</div> </div>
<p class="hint">{t("jobs.contactsHint")}</p> <p class="hint">{t("jobs.contactsHint")}</p>
@@ -122,8 +129,13 @@
onclick={syncContactList} onclick={syncContactList}
variant="secondary" variant="secondary"
> >
<Icon name="user" /> {#if started.current === "contacts"}
<span>{t("jobs.syncContacts")}</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="user" />
<span>{t("jobs.syncContacts")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
@@ -188,8 +200,13 @@
onclick={start} onclick={start}
variant="primary" variant="primary"
> >
<Icon name="cloud-download" /> {#if started.current === "backfill"}
<span>{t("jobs.startBackfill")}</span> <Icon name="check" />
<span>{t("jobs.status.pending")}</span>
{:else}
<Icon name="cloud-download" />
<span>{t("jobs.startBackfill")}</span>
{/if}
</Button> </Button>
</div> </div>
</section> </section>
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -24,7 +25,17 @@
let loaded = $state(false); let loaded = $state(false);
let inline = $state<InlineMedia | null>(null); let inline = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
const queued = createFlash<true>();
const queueIcon = $derived.by(() => {
if (queuing) {
return "timer";
}
return queued.current ? "check" : "download";
});
const queueLabel = $derived(
queued.current ? t("chat.media.queued") : t("media.downloadToArchive")
);
const ready = $derived(inline?.state === "ready" ? inline : null); const ready = $derived(inline?.state === "ready" ? inline : null);
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false); const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
@@ -40,7 +51,7 @@
queuing = true; queuing = true;
try { try {
await fetchMedia(chatId, media.message_id); await fetchMedia(chatId, media.message_id);
toasts.success(t("media.queued")); queued.show(true);
} catch { } catch {
toasts.error(t("media.queueFailed")); toasts.error(t("media.queueFailed"));
} finally { } finally {
@@ -66,8 +77,14 @@
<img alt={t("media.attachment")} src={ready.url}> <img alt={t("media.attachment")} src={ready.url}>
</button> </button>
{:else if inline?.state === "not-downloaded"} {:else if inline?.state === "not-downloaded"}
<button class="tile placeholder" onclick={queue} type="button"> <button
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> aria-label={queueLabel}
class="tile placeholder"
onclick={queue}
title={queueLabel}
type="button"
>
<Icon name={queueIcon} size="1.5rem" />
</button> </button>
{:else if loaded} {:else if loaded}
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div> <div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
@@ -24,35 +24,11 @@
formatSpan, formatSpan,
isOnline, isOnline,
} from "#lib/format/presence.js"; } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
const TEXT = {
average: "В среднем за день",
byDay: "По дням",
byHour: "По часам недели",
failed: "Не удалось загрузить аналитику.",
firstOnline: "Обычно появляется",
hidden:
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
lastOffline: "Обычно уходит",
median: "Обычная сессия",
messages: "Сообщения",
mine: "Я отвечаю",
missing: "Нет данных",
noData: "нет данных",
noMessages: "За этот период сообщений нет.",
noStatus: "Статусов пока нет.",
openChat: "Откройте чат",
period: (count: number) => `${count} дней`,
retry: "Повторить",
sessions: "Сессии",
theirs: "Мне отвечают",
title: "Аналитика",
total: (count: number) => `в сети за ${count} дней`,
};
const PERIODS = [7, 30, 90]; const PERIODS = [7, 30, 90];
const DEFAULT_PERIOD = 30; const DEFAULT_PERIOD = 30;
const SESSION_PAGE = 30; const SESSION_PAGE = 30;
@@ -60,7 +36,7 @@
const MS_IN_SECOND = 1000; const MS_IN_SECOND = 1000;
const tz = browserTimeZone(); const tz = browserTimeZone();
const zone = timeZoneLabel(); const zone = $derived(timeZoneLabel());
const chatId = $derived( const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
@@ -97,25 +73,25 @@
const result: { label: string; value: string }[] = []; const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) { if (summary.daily_seconds !== null) {
result.push({ result.push({
label: TEXT.average, label: t("presence.average"),
value: formatSpan(summary.daily_seconds), value: formatSpan(summary.daily_seconds),
}); });
} }
if (summary.median_session_seconds !== null) { if (summary.median_session_seconds !== null) {
result.push({ result.push({
label: TEXT.median, label: t("presence.median"),
value: formatSpan(summary.median_session_seconds), value: formatSpan(summary.median_session_seconds),
}); });
} }
if (summary.first_online !== null) { if (summary.first_online !== null) {
result.push({ result.push({
label: TEXT.firstOnline, label: t("presence.firstOnline"),
value: formatClock(summary.first_online), value: formatClock(summary.first_online),
}); });
} }
if (summary.last_offline !== null) { if (summary.last_offline !== null) {
result.push({ result.push({
label: TEXT.lastOffline, label: t("presence.lastOffline"),
value: formatClock(summary.last_offline), value: formatClock(summary.last_offline),
}); });
} }
@@ -124,13 +100,16 @@
MS_IN_SECOND; MS_IN_SECOND;
const missing = period - summary.covered_seconds; const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) { if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) }); result.push({
label: t("presence.missing"),
value: formatLongSpan(missing),
});
} }
return result; return result;
}); });
function reply(seconds: number | null): string { function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds); return seconds === null ? t("presence.noData") : formatSpan(seconds);
} }
async function load(id: number, period: number) { async function load(id: number, period: number) {
@@ -172,7 +151,7 @@
</script> </script>
{#if chatId === null} {#if chatId === null}
<EmptyState description={TEXT.openChat} title={TEXT.title} /> <EmptyState description={t("profile.empty")} title={t("profile.analytics")} />
{:else} {:else}
<div class="analytics"> <div class="analytics">
<div class="periods"> <div class="periods">
@@ -185,20 +164,20 @@
}} }}
type="button" type="button"
> >
{TEXT.period(period)} {t("presence.days", { count: period })}
</button> </button>
{/each} {/each}
</div> </div>
{#if failed} {#if failed}
<div class="state"> <div class="state">
<p>{TEXT.failed}</p> <p>{t("presence.failed")}</p>
<Button <Button
onclick={() => chatId !== null && load(chatId, days)} onclick={() => chatId !== null && load(chatId, days)}
smaller smaller
variant="text" variant="text"
> >
{TEXT.retry} {t("ui.retry")}
</Button> </Button>
</div> </div>
{:else if volume === null || (isDm && stats === null)} {:else if volume === null || (isDm && stats === null)}
@@ -211,7 +190,9 @@
<span class="total" <span class="total"
>{formatSpan(stats.summary.online_seconds)}</span >{formatSpan(stats.summary.online_seconds)}</span
> >
<span class="count">{TEXT.total(shownDays)}</span> <span class="count"
>{t("presence.total", { count: shownDays })}</span
>
</div> </div>
{/if} {/if}
{#if sample} {#if sample}
@@ -220,9 +201,9 @@
</p> </p>
{/if} {/if}
{#if stats.hidden} {#if stats.hidden}
<p class="note">{TEXT.hidden}</p> <p class="note">{t("presence.hidden")}</p>
{:else if !tracked} {:else if !tracked}
<p class="note">{TEXT.noStatus}</p> <p class="note">{t("presence.noStatus")}</p>
{/if} {/if}
</section> </section>
@@ -242,34 +223,34 @@
<section> <section>
<div class="section-title"> <div class="section-title">
<h3>{TEXT.byHour}</h3> <h3>{t("presence.byHour")}</h3>
<span>{zone}</span> <span>{zone}</span>
</div> </div>
<PresenceHeatmap week={stats.week} /> <PresenceHeatmap week={stats.week} />
</section> </section>
<section> <section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div> <div class="section-title"><h3>{t("presence.byDay")}</h3></div>
<OnlineDays days={stats.days} /> <OnlineDays days={stats.days} />
</section> </section>
{/if} {/if}
{/if} {/if}
<section> <section>
<div class="section-title"><h3>{TEXT.messages}</h3></div> <div class="section-title"><h3>{t("presence.messages")}</h3></div>
{#if quiet} {#if quiet}
<p class="state">{TEXT.noMessages}</p> <p class="state">{t("presence.noMessages")}</p>
{:else} {:else}
<VolumeChart {volume} /> <VolumeChart {volume} />
{/if} {/if}
{#if response && response.mine_count + response.their_count > 0} {#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies"> <dl class="rows replies">
<div class="row"> <div class="row">
<dt>{TEXT.mine}</dt> <dt>{t("presence.mine")}</dt>
<dd>{reply(response.mine_median_seconds)}</dd> <dd>{reply(response.mine_median_seconds)}</dd>
</div> </div>
<div class="row"> <div class="row">
<dt>{TEXT.theirs}</dt> <dt>{t("presence.theirs")}</dt>
<dd>{reply(response.their_median_seconds)}</dd> <dd>{reply(response.their_median_seconds)}</dd>
</div> </div>
</dl> </dl>
@@ -278,7 +259,7 @@
{#if isDm && stats && tracked && !stats.hidden} {#if isDm && stats && tracked && !stats.hidden}
<section> <section>
<div class="section-title"><h3>{TEXT.sessions}</h3></div> <div class="section-title"><h3>{t("presence.sessions")}</h3></div>
{#key `${chatId}:${shownDays}:${stats.until}`} {#key `${chatId}:${shownDays}:${stats.until}`}
<PresenceSessions <PresenceSessions
days={shownDays} days={shownDays}
@@ -2,6 +2,7 @@
import type { PresenceDay } from "#lib/api/types.js"; import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js"; import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
days: PresenceDay[]; days: PresenceDay[];
@@ -12,18 +13,6 @@
const SECONDS_IN_HOUR = 3600; const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400; const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds))); const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]); const first = $derived(days[0]);
@@ -31,6 +20,23 @@
return formatDay(parseDay(day).toISOString()); return formatDay(parseDay(day).toISOString());
} }
function title(day: PresenceDay, index: number): string {
const date = label(day.day);
const missing = absent(day, index);
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return t("presence.dayNoData", { day: date });
}
const span = formatSpan(day.online_seconds);
if (missing >= SECONDS_IN_HOUR) {
return t("presence.dayGap", {
day: date,
gap: formatSpan(missing),
span,
});
}
return t("presence.day", { day: date, span });
}
function absent(day: PresenceDay, index: number): number { function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) { if (index === days.length - 1) {
return 0; return 0;
@@ -46,11 +52,7 @@
{#each days as day, index (day.day)} {#each days as day, index (day.day)}
<div <div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]} class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar( title={title(day, index)}
label(day.day),
day.online_seconds,
absent(day, index)
)}
> >
<span <span
class={["bar", { zero: day.online_seconds === 0 }]} class={["bar", { zero: day.online_seconds === 0 }]}
@@ -63,7 +65,7 @@
{#if first} {#if first}
<div class="axis"> <div class="axis">
<span>{label(first.day)}</span> <span>{label(first.day)}</span>
<span>{TEXT.today}</span> <span>{t("presence.axisToday")}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PresenceHour } from "#lib/api/types.js"; import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
week: PresenceHour[]; week: PresenceHour[];
@@ -8,28 +9,14 @@
const { week }: Props = $props(); const { week }: Props = $props();
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
const HOURS_IN_DAY = 24; const HOURS_IN_DAY = 24;
const SECONDS_IN_MINUTE = 60;
const HOUR_TICKS = [0, 6, 12, 18]; const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18; const MIN_SHADE = 0.18;
const weekdays = $derived(weekdayLabels());
const rows = $derived( const rows = $derived(
TEXT.days.map((_, weekday) => weekdays.map((_, weekday) =>
week week
.filter((cell) => cell.weekday === weekday) .filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour) .sort((a, b) => a.hour - b.hour)
@@ -44,6 +31,20 @@
return cell.hours > 0 && cell.online_minutes === null; return cell.hours > 0 && cell.online_minutes === null;
} }
function title(day: string, cell: PresenceHour): string {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return t("presence.cellNoData", { slot });
}
return t("presence.cellAverage", {
slot,
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
});
}
function shade(minutes: number | null): number { function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) { if (minutes === null || minutes === 0 || peak === 0) {
return 0; return 0;
@@ -55,7 +56,7 @@
<div class="heatmap"> <div class="heatmap">
{#each rows as cells, weekday (weekday)} {#each rows as cells, weekday (weekday)}
<div class="row"> <div class="row">
<span class="day">{TEXT.days[weekday]}</span> <span class="day">{weekdays[weekday]}</span>
<div class="cells"> <div class="cells">
{#each cells as cell (cell.hour)} {#each cells as cell (cell.hour)}
<span <span
@@ -67,7 +68,7 @@
zero: cell.online_minutes === 0, zero: cell.online_minutes === 0,
}, },
]} ]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)} title={title(weekdays[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%" style:--shade="{shade(cell.online_minutes) * 100}%"
></span> ></span>
{/each} {/each}
@@ -87,13 +88,13 @@
{#if peak > 0} {#if peak > 0}
<span class="key"> <span class="key">
<span class="scale"></span> <span class="scale"></span>
{TEXT.max(peak)} {t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
</span> </span>
{/if} {/if}
{#if hasGaps} {#if hasGaps}
<span class="key"> <span class="key">
<span class="swatch empty"></span> <span class="swatch empty"></span>
{TEXT.empty} {t("presence.noData")}
</span> </span>
{/if} {/if}
</div> </div>
@@ -115,7 +116,7 @@
.day { .day {
flex-shrink: 0; flex-shrink: 0;
width: 1.375rem; width: 1.75rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@@ -173,7 +174,7 @@
gap: 0.25rem 0.75rem; gap: 0.25rem 0.75rem;
align-items: center; align-items: center;
margin: 0.375rem 0 0 1.875rem; margin: 0.375rem 0 0 2.25rem;
font-size: 0.75rem; font-size: 0.75rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -5,6 +5,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js"; import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js"; import { formatSpan } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
days: number; days: number;
@@ -16,18 +17,6 @@
const { days, first, pageSize, peerId, tz }: Props = $props(); const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000; const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]); let extra = $state<PresenceSession[]>([]);
@@ -54,18 +43,22 @@
function dayLabel(date: Date): string { function dayLabel(date: Date): string {
const today = new Date(); const today = new Date();
if (date.toDateString() === today.toDateString()) { if (date.toDateString() === today.toDateString()) {
return TEXT.today; return t("presence.today");
} }
if ( if (
date.toDateString() === date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString() new Date(today.getTime() - MS_IN_DAY).toDateString()
) { ) {
return TEXT.yesterday; return t("presence.yesterday");
} }
const text = formatLongDay(date); const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1); return text.charAt(0).toUpperCase() + text.slice(1);
} }
function span(from: string, to: string): string {
return from === to ? from : `${from}–${to}`;
}
async function loadMore() { async function loadMore() {
if (loading) { if (loading) {
return; return;
@@ -92,7 +85,7 @@
</script> </script>
{#if sessions.length === 0} {#if sessions.length === 0}
<p class="state">{TEXT.none}</p> <p class="state">{t("presence.noSessions")}</p>
{:else} {:else}
{#each groups as group (group.label)} {#each groups as group (group.label)}
<h4 class="day">{group.label}</h4> <h4 class="day">{group.label}</h4>
@@ -101,14 +94,13 @@
<li> <li>
<span> <span>
{session.ongoing {session.ongoing
? TEXT.since(formatTime(session.started)) ? t("presence.since", { time: formatTime(session.started) })
: TEXT.span( : span(formatTime(session.started), formatTime(session.ended))}
formatTime(session.started),
formatTime(session.ended)
)}
</span> </span>
<span class={["length", { online: session.ongoing }]}> <span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)} {session.ongoing
? t("presence.online")
: formatSpan(session.seconds)}
</span> </span>
</li> </li>
{/each} {/each}
@@ -118,11 +110,13 @@
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>{TEXT.failed}</p> <p>{t("presence.sessionsFailed")}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button> <Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
</div> </div>
{:else if !exhausted} {:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button> <button class="more" onclick={loadMore} type="button">
{t("ui.showMore")}
</button>
{/if} {/if}
{/if} {/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js"; import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js"; import { formatDay, parseDay } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
volume: VolumeBucket[]; volume: VolumeBucket[];
@@ -8,22 +9,25 @@
const { volume }: Props = $props(); const { volume }: Props = $props();
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total))); const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const first = $derived(volume[0]); const first = $derived(volume[0]);
function label(day: string): string { function label(day: string): string {
return formatDay(parseDay(day).toISOString()); return formatDay(parseDay(day).toISOString());
} }
function title(bucket: VolumeBucket): string {
const day = label(bucket.bucket);
if (bucket.total === 0) {
return t("presence.volumeEmpty", { day });
}
return t("presence.volume", {
day,
incoming: bucket.incoming,
outgoing: bucket.outgoing,
total: bucket.total,
});
}
</script> </script>
<div class="volume"> <div class="volume">
@@ -31,7 +35,7 @@
<span class="peak">{peak}</span> <span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}> <div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)} {#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}> <div class="slot" title={title(bucket)}>
<div <div
class={["bar", { zero: bucket.total === 0 }]} class={["bar", { zero: bucket.total === 0 }]}
style:height="{(bucket.total / peak) * 100}%" style:height="{(bucket.total / peak) * 100}%"
@@ -46,12 +50,16 @@
{#if first} {#if first}
<div class="axis"> <div class="axis">
<span>{label(first.bucket)}</span> <span>{label(first.bucket)}</span>
<span>{TEXT.today}</span> <span>{t("presence.axisToday")}</span>
</div> </div>
{/if} {/if}
<div class="legend"> <div class="legend">
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span> <span class="key"
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span> ><span class="swatch in"></span>{t("presence.volumeIncoming")}</span
>
<span class="key"
><span class="swatch out"></span>{t("presence.volumeOutgoing")}</span
>
</div> </div>
</div> </div>
@@ -18,6 +18,7 @@
import ProfileSection from "#lib/components/profile/ProfileSection.svelte"; import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatDate, formatFull } from "#lib/format/datetime.js"; import { formatDate, formatFull } from "#lib/format/datetime.js";
import { formatPhone } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
@@ -56,7 +57,7 @@
result.push({ result.push({
icon: "phone", icon: "phone",
label: t("profile.phone"), label: t("profile.phone"),
value: `+${peer.phone}`, value: formatPhone(peer.phone),
}); });
} }
if (peer?.username) { if (peer?.username) {
@@ -9,7 +9,7 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js"; import { formatPhone, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -73,7 +73,7 @@
if (peer?.username) { if (peer?.username) {
return `@${peer.username}`; return `@${peer.username}`;
} }
return peer?.phone ? `+${peer.phone}` : `ID ${targetId}`; return peer?.phone ? formatPhone(peer.phone) : `ID ${targetId}`;
} }
const count = chat?.message_count ?? 0; const count = chat?.message_count ?? 0;
return count > 0 ? t("profile.messages", { count }) : t("profile.group"); return count > 0 ? t("profile.messages", { count }) : t("profile.group");
@@ -7,6 +7,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { safeHref } from "#lib/format/url.js"; import { safeHref } from "#lib/format/url.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -83,7 +84,7 @@
{#if loading} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Нет ссылок" /> <div class="center"><EmptyState title={t("social.links.empty")} /></div>
{/if} {/if}
{:else} {:else}
<ul class="list"> <ul class="list">
@@ -122,7 +123,7 @@
onclick={() => loadMore()} onclick={() => loadMore()}
type="button" type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? t("ui.loading") : t("ui.showMore")}
</button> </button>
{/if} {/if}
@@ -17,6 +17,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
@@ -56,14 +57,16 @@
); );
function displayName(item: MediaView): string { function displayName(item: MediaView): string {
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл"; return (
item.file_name ?? mediaKindLabel(item.kind) ?? t("media.kind.document")
);
} }
async function save(item: MediaView) { async function save(item: MediaView) {
try { try {
await downloadMedia(item.id); await downloadMedia(item.id);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("ui.downloadFailed"));
} }
} }
@@ -175,7 +178,7 @@
{#if loading} {#if loading}
<div class="center"><Spinner /></div> <div class="center"><Spinner /></div>
{:else} {:else}
<EmptyState title="Пусто" /> <div class="center"><EmptyState title={t("shared.media.empty")} /></div>
{/if} {/if}
{:else if layout === "grid"} {:else if layout === "grid"}
<div class="grid"> <div class="grid">
@@ -217,17 +220,17 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)} <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть на весь экран</ContextMenuItem >{t("shared.media.fullscreen")}</ContextMenuItem
> >
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)} <ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem >{t("ui.jumpToMessage")}</ContextMenuItem
> >
{#if item.downloaded} {#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)} <ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem >{t("ui.downloadFile")}</ContextMenuItem
> >
<ContextMenuItem icon="allow-share" onselect={() => share(item)} <ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem >{t("ui.shareLink")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -262,17 +265,17 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)} <ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
>Открыть</ContextMenuItem >{t("ui.open")}</ContextMenuItem
> >
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)} <ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
>Перейти к сообщению</ContextMenuItem >{t("ui.jumpToMessage")}</ContextMenuItem
> >
{#if item.downloaded} {#if item.downloaded}
<ContextMenuItem icon="download" onselect={() => save(item)} <ContextMenuItem icon="download" onselect={() => save(item)}
>Скачать файл</ContextMenuItem >{t("ui.downloadFile")}</ContextMenuItem
> >
<ContextMenuItem icon="allow-share" onselect={() => share(item)} <ContextMenuItem icon="allow-share" onselect={() => share(item)}
>Доступ по ссылке</ContextMenuItem >{t("ui.shareLink")}</ContextMenuItem
> >
{/if} {/if}
{/snippet} {/snippet}
@@ -289,7 +292,7 @@
onclick={() => loadMore()} onclick={() => loadMore()}
type="button" type="button"
> >
{loading ? "Загрузка…" : "Показать ещё"} {loading ? t("ui.loading") : t("ui.showMore")}
</button> </button>
{/if} {/if}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js"; import { visible } from "#lib/actions/visible.js";
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte"; import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
@@ -15,7 +16,9 @@
const DEBOUNCE_MS = 250; const DEBOUNCE_MS = 250;
const pager = createHitPager(); const pager = createHitPager();
const everywhere = createHitPager();
let query = $state(""); let query = $state("");
let deep = $state(false);
let typing = $state(false); let typing = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
@@ -23,14 +26,30 @@
page.params.chatId ? Number(page.params.chatId) : null 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() { function run() {
typing = false; typing = false;
const value = query.trim(); deep = false;
everywhere.run(null);
pager.run( 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(() => { $effect(() => {
if (chatId !== null) { if (chatId !== null) {
untrack(run); untrack(run);
@@ -46,11 +65,11 @@
timer = setTimeout(run, DEBOUNCE_MS); timer = setTimeout(run, DEBOUNCE_MS);
} }
function openHit(messageId: number) { function openHit(messageId: number, marks: string[]) {
if (chatId === null) { if (chatId === null) {
return; return;
} }
ui.requestJump(chatId, messageId); ui.requestJump(chatId, messageId, marks);
if (isMobile()) { if (isMobile()) {
ui.closePanel(); ui.closePanel();
} }
@@ -73,9 +92,9 @@
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)} {#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem <SearchMessageItem
{hit} {hit}
href={search.hitHref(hit.chat_id, hit.message_id)} href={search.hitHref(hit.chat_id, hit.message_id, pager.terms)}
inChat inChat
onclick={() => openHit(hit.message_id)} onclick={() => openHit(hit.message_id, pager.terms)}
terms={pager.terms} terms={pager.terms}
/> />
{/each} {/each}
@@ -96,7 +115,19 @@
>{t("search.retry")}</Button >{t("search.retry")}</Button
> >
</div> </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")} /> <EmptyState title={t("search.nothing")} />
{/if} {/if}
</div> </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"> <script lang="ts">
import { ripple } from "#lib/actions/ripple.js"; 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 Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js"; import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { mediaKindLabel } from "#lib/format/media.js"; import { mediaKindLabel } from "#lib/format/media.js";
import { peerName } from "#lib/format/peer.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 { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
@@ -22,6 +22,17 @@
let { hit, href, inChat = false, onclick, terms }: Props = $props(); 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 chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId); const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
@@ -81,7 +92,21 @@
const kind = $derived( const kind = $derived(
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null 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> </script>
<a class="hit" {href} {onclick} use:ripple> <a class="hit" {href} {onclick} use:ripple>
@@ -126,6 +151,21 @@
{/each}</span {/each}</span
> >
</div> </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"} {#if hit.source === "version"}
<div <div
class="note" class="note"
@@ -244,6 +284,22 @@
color: var(--color-text-secondary); 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 { mark {
padding: 0 0.0625rem; padding: 0 0.0625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -3,6 +3,7 @@
import { visible } from "#lib/actions/visible.js"; import { visible } from "#lib/actions/visible.js";
import ChatListItem from "#lib/components/ChatListItem.svelte"; import ChatListItem from "#lib/components/ChatListItem.svelte";
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.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 SearchFilters from "#lib/components/search/SearchFilters.svelte";
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte"; import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
@@ -14,7 +15,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
const { messages } = search; const { everywhere, messages } = search;
let scroller = $state<HTMLDivElement | null>(null); let scroller = $state<HTMLDivElement | null>(null);
$effect(() => { $effect(() => {
@@ -25,12 +26,22 @@
}); });
}); });
$effect(() => {
const key = search.everywhereRequest;
untrack(() => search.syncEverywhere(key));
});
const activeChatId = $derived( const activeChatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null page.params.chatId ? Number(page.params.chatId) : null
); );
const showChats = $derived(!search.filtered && search.chatHits.length > 0); const showChats = $derived(!search.filtered && search.chatHits.length > 0);
const showPeers = $derived(!search.filtered && search.peerHits.length > 0); const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
const hasMessages = $derived(messages.hits.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( const empty = $derived(
!( !(
search.typing || search.typing ||
@@ -38,7 +49,8 @@
messages.failed || messages.failed ||
showChats || showChats ||
showPeers || showPeers ||
hasMessages hasMessages ||
deep
) )
); );
@@ -74,8 +86,9 @@
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)} {#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
<SearchMessageItem <SearchMessageItem
{hit} {hit}
href={search.hitHref(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)} onclick={() =>
ui.requestJump(hit.chat_id, hit.message_id, messages.terms)}
terms={messages.terms} terms={messages.terms}
/> />
{/each} {/each}
@@ -98,6 +111,17 @@
</div> </div>
{/if} {/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} {#if empty}
<EmptyState title={t("search.nothing")} /> <EmptyState title={t("search.nothing")} />
{/if} {/if}
@@ -55,9 +55,8 @@
loginId = ""; loginId = "";
await accounts.load(); await accounts.load();
if (state.account) { if (state.account) {
accounts.select(state.account.account_id); accounts.switchTo(state.account.account_id);
} }
toasts.success(t("login.added"));
open = false; open = false;
} }
@@ -7,7 +7,7 @@
import SettingsItem from "#lib/components/settings/SettingsItem.svelte"; import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js"; import { accountName, formatPhone } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
@@ -78,7 +78,7 @@
<AccountAvatar account={current} size={5} /> <AccountAvatar account={current} size={5} />
<div class="name">{accountName(current)}</div> <div class="name">{accountName(current)}</div>
{#if current.phone} {#if current.phone}
<div class="phone">+{current.phone}</div> <div class="phone">{formatPhone(current.phone)}</div>
{/if} {/if}
{#if !current.is_active} {#if !current.is_active}
<div class="inactive">{t("account.inactive")}</div> <div class="inactive">{t("account.inactive")}</div>
@@ -19,6 +19,7 @@
import { formatShareLimits } from "#lib/format/shares.js"; import { formatShareLimits } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
@@ -72,6 +73,8 @@
let maxDownloads = $state<number | null>(null); let maxDownloads = $state<number | null>(null);
let showQr = $state(false); let showQr = $state(false);
let token = 0; let token = 0;
const saved = createFlash<number>();
const revoked = createFlash<ShareSubject>();
const settings = $derived<ShareSettings>({ const settings = $derived<ShareSettings>({
expiresInSeconds: expiresIn === KEEP ? null : expiresIn, expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
@@ -125,7 +128,6 @@
busy = true; busy = true;
try { try {
adopt(await createShare(subject, settings)); adopt(await createShare(subject, settings));
toasts.success(t("shares.opened"));
} catch (error) { } catch (error) {
fail(error, "shares.openFailed"); fail(error, "shares.openFailed");
} finally { } finally {
@@ -139,8 +141,9 @@
} }
busy = true; busy = true;
try { try {
adopt(await updateShare(share.id, settings)); const next = await updateShare(share.id, settings);
toasts.success(t("shares.updated")); adopt(next);
saved.show(next.id);
} catch (error) { } catch (error) {
fail(error, "shares.updateFailed"); fail(error, "shares.updateFailed");
} finally { } finally {
@@ -149,15 +152,15 @@
} }
async function revoke() { async function revoke() {
if (!share || busy) { if (!(share && subject) || busy) {
return; return;
} }
const target = subject;
busy = true; busy = true;
try { try {
await revokeShare(share.id); await revokeShare(share.id);
adopt(null); adopt(null);
toasts.success(t("shares.revokedToast")); revoked.show(target);
open = false;
} catch (error) { } catch (error) {
fail(error, "shares.revokeFailed"); fail(error, "shares.revokeFailed");
} finally { } finally {
@@ -314,12 +317,22 @@
{#if !loading} {#if !loading}
<div class="dialog-actions"> <div class="dialog-actions">
{#if share} {#if subject && revoked.current === subject}
<p class="done">
<Icon name="check" size="1.25rem" />
{t("shares.accessRevoked")}
</p>
{:else if share}
<Button loading={busy} onclick={revoke} pill variant="danger"> <Button loading={busy} onclick={revoke} pill variant="danger">
{t("shares.revoke")} {t("shares.revoke")}
</Button> </Button>
<Button loading={busy} onclick={applyLimits} pill> <Button loading={busy} onclick={applyLimits} pill>
{t("common.save")} {#if saved.current === share.id}
<Icon name="check" />
{t("shares.saved")}
{:else}
{t("common.save")}
{/if}
</Button> </Button>
{:else} {:else}
<Button fluid loading={busy} onclick={publish} pill> <Button fluid loading={busy} onclick={publish} pill>
@@ -333,6 +346,20 @@
</Dialog.Root> </Dialog.Root>
<style lang="scss"> <style lang="scss">
.done {
display: flex;
flex: 1;
gap: 0.375rem;
align-items: center;
justify-content: center;
height: 3rem;
margin: 0;
font-weight: var(--font-weight-medium);
color: var(--color-text-green);
}
.center { .center {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import { import {
deleteShare, deleteShare,
listShares, listShares,
@@ -11,6 +12,7 @@
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte"; import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
@@ -19,6 +21,7 @@
import { formatDownloads, formatExpiry } from "#lib/format/shares.js"; import { formatDownloads, formatExpiry } from "#lib/format/shares.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -31,10 +34,14 @@
let filter = $state<Filter>("active"); let filter = $state<Filter>("active");
let expanded = $state<number | null>(null); let expanded = $state<number | null>(null);
let token = 0; let token = 0;
const kept = new SvelteSet<number>();
const done = createFlash<number>();
const visible = $derived( const visible = $derived(
filter === "active" filter === "active"
? items.filter((item) => shareState(item) === "active") ? items.filter(
(item) => kept.has(item.id) || shareState(item) === "active"
)
: items : items
); );
const activeCount = $derived( const activeCount = $derived(
@@ -50,6 +57,7 @@
const rows = await listShares(activeOnly); const rows = await listShares(activeOnly);
if (current === token) { if (current === token) {
items = rows; items = rows;
kept.clear();
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -79,15 +87,11 @@
ui.closePanel(); ui.closePanel();
} }
async function copy(share: FileShare) {
await navigator.clipboard.writeText(shareUrl(share));
toasts.success(t("shares.copied"));
}
async function revoke(share: FileShare) { async function revoke(share: FileShare) {
try { try {
replace(await revokeShare(share.id)); replace(await revokeShare(share.id));
toasts.success(t("shares.revokedToast")); kept.add(share.id);
done.show(share.id);
} catch { } catch {
toasts.error(t("shares.revokeFailed")); toasts.error(t("shares.revokeFailed"));
} }
@@ -96,7 +100,7 @@
async function reissue(share: FileShare) { async function reissue(share: FileShare) {
try { try {
replace(await reissueShare(share.id)); replace(await reissueShare(share.id));
toasts.success(t("shares.reissued")); done.show(share.id);
} catch { } catch {
toasts.error(t("shares.reissueFailed")); toasts.error(t("shares.reissueFailed"));
} }
@@ -205,9 +209,12 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="copy" onselect={() => copy(share)}> <CopyItem
{t("shares.copy")} done={t("shares.copied")}
</ContextMenuItem> icon="copy"
label={t("shares.copy")}
text={() => shareUrl(share)}
/>
{#if share.message_id !== null || share.peer_id !== null} {#if share.message_id !== null || share.peer_id !== null}
<ContextMenuItem icon="reply" onselect={() => jump(share)}> <ContextMenuItem icon="reply" onselect={() => jump(share)}>
{t("ui.jumpToMessage")} {t("ui.jumpToMessage")}
@@ -247,7 +254,14 @@
<Icon name="reply" size="1rem" />{t("shares.toMessage")} <Icon name="reply" size="1rem" />{t("shares.toMessage")}
</button> </button>
{/if} {/if}
{#if state === "active"} {#if done.current === share.id}
<span class="done">
<Icon name="check" size="1rem" />
{state === "active"
? t("shares.reissued")
: t("shares.accessRevoked")}
</span>
{:else if state === "active"}
<button <button
class="pill danger" class="pill danger"
onclick={() => revoke(share)} onclick={() => revoke(share)}
@@ -461,6 +475,18 @@
} }
} }
.done {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0;
border: 1px solid transparent;
font-size: 0.8125rem;
color: var(--color-text-green);
}
.footnote { .footnote {
margin: 0; margin: 0;
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
@@ -18,6 +18,7 @@
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
interface Group { interface Group {
@@ -45,6 +46,7 @@
let viewerItems = $state<StoryView[]>([]); let viewerItems = $state<StoryView[]>([]);
let viewerPeerId = $state(0); let viewerPeerId = $state(0);
let backfilling = $state<number | null>(null); let backfilling = $state<number | null>(null);
const started = createFlash<number>();
async function load() { async function load() {
const current = token; const current = token;
@@ -150,7 +152,7 @@
backfilling = peerId; backfilling = peerId;
try { try {
await enqueueStoriesBackfill(peerId); await enqueueStoriesBackfill(peerId);
toasts.success(t("stories.backfillStarted")); started.show(peerId);
} catch { } catch {
toasts.error(t("stories.backfillFailed")); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
@@ -178,6 +180,10 @@
{/if} {/if}
{:else} {:else}
{#each groups as group (group.peerId)} {#each groups as group (group.peerId)}
{@const fresh = started.current === group.peerId}
{@const backfillLabel = fresh
? t("stories.backfillStarted")
: t("stories.loadOlderStories")}
<section class="group"> <section class="group">
<header class="group-head"> <header class="group-head">
<button <button
@@ -200,14 +206,15 @@
</span> </span>
</button> </button>
<Button <Button
aria-label={t("stories.loadOlderStories")} aria-label={backfillLabel}
loading={backfilling === group.peerId} loading={backfilling === group.peerId}
onclick={() => backfill(group.peerId)} onclick={() => backfill(group.peerId)}
round round
smaller smaller
title={backfillLabel}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={fresh ? "check" : "cloud-download"} />
</Button> </Button>
</header> </header>
{#if expanded[group.peerId]} {#if expanded[group.peerId]}
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { createFlash } from "#lib/stores/flash.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -27,6 +28,7 @@
); );
let backfilling = $state(false); let backfilling = $state(false);
const started = createFlash<number>();
let items = $state<StoryView[]>([]); let items = $state<StoryView[]>([]);
let loading = $state(false); let loading = $state(false);
@@ -109,7 +111,7 @@
backfilling = true; backfilling = true;
try { try {
await enqueueStoriesBackfill(id); await enqueueStoriesBackfill(id);
toasts.success(t("stories.backfillStarted")); started.show(id);
} catch { } catch {
toasts.error(t("stories.backfillFailed")); toasts.error(t("stories.backfillFailed"));
} finally { } finally {
@@ -137,7 +139,11 @@
smaller smaller
variant="secondary" variant="secondary"
> >
<Icon name="cloud-download" />{t("stories.loadOlder")} {#if started.current === peerId}
<Icon name="check" />{t("stories.backfillStarted")}
{:else}
<Icon name="cloud-download" />{t("stories.loadOlder")}
{/if}
</Button> </Button>
<Button <Button
aria-label={t("ui.refresh")} aria-label={t("ui.refresh")}
+2 -1
View File
@@ -33,13 +33,14 @@
loading = false, loading = false,
type = "button", type = "button",
disabled = false, disabled = false,
class: className,
children, children,
...rest ...rest
}: Props = $props(); }: Props = $props();
</script> </script>
<button <button
class="Button {variant}" class={["Button", variant, className]}
{disabled} {disabled}
{type} {type}
class:disabled class:disabled
@@ -63,7 +63,12 @@
{/snippet} {/snippet}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
<ContextMenu.Content class="bg-menu-content"> <ContextMenu.Content
align="start"
class="bg-menu-content"
collisionPadding={8}
side="bottom"
>
{@render menu()} {@render menu()}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Portal> </ContextMenu.Portal>
@@ -49,9 +49,5 @@
&.error { &.error {
background-color: var(--color-error); background-color: var(--color-error);
} }
&.success {
background-color: var(--color-green);
}
} }
</style> </style>
+7 -2
View File
@@ -1,4 +1,5 @@
import type { Account, PeerView } from "#lib/api/types.js"; import type { Account, PeerView } from "#lib/api/types.js";
import { t } from "#lib/i18n/locale.svelte.js";
const PEER_COLOR_COUNT = 7; const PEER_COLOR_COUNT = 7;
@@ -11,7 +12,7 @@ export function peerName(peer: PeerView | null): string {
return ""; return "";
} }
if (peer.is_deleted_account) { if (peer.is_deleted_account) {
return "Удалённый аккаунт"; return t("chat.deletedAccount");
} }
const parts = [peer.first_name, peer.last_name].filter(Boolean); const parts = [peer.first_name, peer.last_name].filter(Boolean);
if (parts.length > 0) { if (parts.length > 0) {
@@ -20,7 +21,7 @@ export function peerName(peer: PeerView | null): string {
if (peer.username) { if (peer.username) {
return `@${peer.username}`; return `@${peer.username}`;
} }
return "Удалённый аккаунт"; return t("chat.deletedAccount");
} }
export function accountName(account: Account): string { export function accountName(account: Account): string {
@@ -46,3 +47,7 @@ export function initials(name: string): string {
const last = lastWord ? lastWord[0] : ""; const last = lastWord ? lastWord[0] : "";
return (first + last).toUpperCase(); return (first + last).toUpperCase();
} }
export function formatPhone(phone: string): string {
return phone.startsWith("+") ? phone : `+${phone}`;
}
+38 -40
View File
@@ -1,6 +1,7 @@
import type { PresenceSample } from "#lib/api/types.js"; import type { PresenceSample } from "#lib/api/types.js";
import { formatDay, formatTime } from "#lib/format/datetime.js"; import { formatDay, formatTime } from "#lib/format/datetime.js";
import { plural } from "#lib/format/shares.js"; import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
const SECONDS_IN_MINUTE = 60; const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60; const MINUTES_IN_HOUR = 60;
@@ -8,32 +9,14 @@ const SECONDS_IN_HOUR = 3600;
const MS_IN_MINUTE = 60_000; const MS_IN_MINUTE = 60_000;
const SECONDS_IN_DAY = 86_400; const SECONDS_IN_DAY = 86_400;
const MS_IN_DAY = 86_400_000; const MS_IN_DAY = 86_400_000;
const DAYS_IN_WEEK = 7;
const MONDAY = Date.UTC(2024, 0, 1);
const TEXT = { const FUZZY: Record<string, MessageKey> = {
ago: (minutes: number) => last_month: "presence.lastMonth",
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`, last_week: "presence.lastWeek",
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`, long_time_ago: "presence.longAgo",
hours: (hours: number) => `${hours} ч`, recently: "presence.recently",
hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`,
justNow: "был(а) только что",
lastMonth: "был(а) в этом месяце",
lastWeek: "был(а) на этой неделе",
longAgo: "был(а) давно",
minutes: (minutes: number) => `${minutes} мин`,
offline: "не в сети",
on: (day: string, time: string) => `был(а) ${day} в ${time}`,
online: "в сети",
recently: "был(а) недавно",
seconds: (seconds: number) => `${seconds} с`,
today: (time: string) => `был(а) сегодня в ${time}`,
yesterday: (time: string) => `был(а) вчера в ${time}`,
};
const FUZZY: Record<string, string> = {
last_month: TEXT.lastMonth,
last_week: TEXT.lastWeek,
long_time_ago: TEXT.longAgo,
recently: TEXT.recently,
}; };
export function isOnline( export function isOnline(
@@ -50,20 +33,20 @@ function lastSeen(iso: string, now: number): string {
const date = new Date(iso); const date = new Date(iso);
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE); const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
if (minutes < 1) { if (minutes < 1) {
return TEXT.justNow; return t("presence.justNow");
} }
if (minutes < MINUTES_IN_HOUR) { if (minutes < MINUTES_IN_HOUR) {
return TEXT.ago(minutes); return t("presence.ago", { count: minutes });
} }
const time = formatTime(iso); const time = formatTime(iso);
const today = new Date(now); const today = new Date(now);
if (date.toDateString() === today.toDateString()) { if (date.toDateString() === today.toDateString()) {
return TEXT.today(time); return t("presence.todayAt", { time });
} }
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) { if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
return TEXT.yesterday(time); return t("presence.yesterdayAt", { time });
} }
return TEXT.on(formatDay(iso), time); return t("presence.on", { day: formatDay(iso), time });
} }
export function formatPresence( export function formatPresence(
@@ -72,29 +55,31 @@ export function formatPresence(
): string { ): string {
if (sample.status === "online") { if (sample.status === "online") {
return isOnline(sample, now) return isOnline(sample, now)
? TEXT.online ? t("presence.online")
: lastSeen(sample.next_offline_date ?? sample.ts, now); : lastSeen(sample.next_offline_date ?? sample.ts, now);
} }
const fuzzy = FUZZY[sample.status]; const fuzzy = FUZZY[sample.status];
if (fuzzy) { if (fuzzy) {
return fuzzy; return t(fuzzy);
} }
return sample.last_online_date return sample.last_online_date
? lastSeen(sample.last_online_date, now) ? lastSeen(sample.last_online_date, now)
: TEXT.offline; : t("presence.offline");
} }
export function formatSpan(seconds: number): string { export function formatSpan(seconds: number): string {
if (seconds < SECONDS_IN_MINUTE) { if (seconds < SECONDS_IN_MINUTE) {
return TEXT.seconds(Math.round(seconds)); return t("presence.seconds", { seconds: Math.round(seconds) });
} }
const minutes = Math.round(seconds / SECONDS_IN_MINUTE); const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
if (minutes < MINUTES_IN_HOUR) { if (minutes < MINUTES_IN_HOUR) {
return TEXT.minutes(minutes); return t("presence.minutes", { minutes });
} }
const hours = Math.floor(minutes / MINUTES_IN_HOUR); const hours = Math.floor(minutes / MINUTES_IN_HOUR);
const rest = minutes % MINUTES_IN_HOUR; const rest = minutes % MINUTES_IN_HOUR;
return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest); return rest === 0
? t("presence.hours", { hours })
: t("presence.hoursMinutes", { hours, minutes: rest });
} }
export function formatClock(secondsOfDay: number): string { export function formatClock(secondsOfDay: number): string {
@@ -111,7 +96,20 @@ export function formatLongSpan(seconds: number): string {
} }
const days = Math.floor(seconds / SECONDS_IN_DAY); const days = Math.floor(seconds / SECONDS_IN_DAY);
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR); const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
return hours === 0 const text = t("presence.days", { count: days });
? TEXT.days(days) return hours === 0 ? text : `${text} ${t("presence.hours", { hours })}`;
: `${TEXT.days(days)} ${TEXT.hours(hours)}`; }
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function weekdayLabels(): string[] {
const format = new Intl.DateTimeFormat(locale.tag, {
timeZone: "UTC",
weekday: "short",
});
return Array.from({ length: DAYS_IN_WEEK }, (_, day) =>
capitalize(format.format(MONDAY + day * MS_IN_DAY))
);
} }
+84 -27
View File
@@ -1,8 +1,12 @@
import type { InlineButton } from "#lib/api/types.js";
export interface Segment { export interface Segment {
hit: boolean; hit: boolean;
text: string; text: string;
} }
type Range = [number, number];
const WORD = /[\p{L}\p{N}_]+/gu; const WORD = /[\p{L}\p{N}_]+/gu;
const SPACE = /\s/; const SPACE = /\s/;
const PREFIX_MIN = 3; const PREFIX_MIN = 3;
@@ -20,39 +24,92 @@ function matches(word: string, terms: string[]): boolean {
); );
} }
function windowed(text: string, terms: string[]): string { function wordRanges(text: string, terms: string[]): Range[] {
for (const match of text.matchAll(WORD)) { return [...text.matchAll(WORD)]
if (!matches(match[0], terms)) { .filter((match) => matches(match[0], terms))
continue; .map((match) => [match.index, match.index + match[0].length]);
}
if (match.index <= REACH) {
return text;
}
let start = match.index - LEAD;
while (start > 0 && !SPACE.test(text[start - 1])) {
start -= 1;
}
return `…${text.slice(start)}`;
}
return text;
} }
export function highlight(text: string, terms: string[]): Segment[] { function markRanges(text: string, marks: string[]): Range[] {
const shown = windowed(text, terms); const folded = fold(text);
const segments: Segment[] = []; if (folded.length !== text.length) {
let last = 0; return [];
for (const match of shown.matchAll(WORD)) { }
if (!matches(match[0], terms)) { const ranges: Range[] = [];
for (const mark of marks.map(fold)) {
if (mark.length < PREFIX_MIN) {
ranges.push(...wordRanges(text, [mark]));
continue; continue;
} }
if (match.index > last) { for (let at = folded.indexOf(mark); at !== -1; ) {
segments.push({ hit: false, text: shown.slice(last, match.index) }); 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) { ranges.sort((a, b) => a[0] - b[0]);
segments.push({ hit: false, text: shown.slice(last) }); 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; 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)
);
}
-24
View File
@@ -2,30 +2,6 @@ import type { FileShare } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js"; import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
const TEEN_START = 11;
const TEEN_END = 14;
const FEW_END = 4;
export function plural(
count: number,
one: string,
few: string,
many: string
): string {
const tail = count % 100;
if (tail >= TEEN_START && tail <= TEEN_END) {
return many;
}
const last = count % 10;
if (last === 1) {
return one;
}
if (last >= 2 && last <= FEW_END) {
return few;
}
return many;
}
export function formatDownloads(share: FileShare): string { export function formatDownloads(share: FileShare): string {
if (share.max_downloads === null) { if (share.max_downloads === null) {
return t("shares.downloads", { count: share.download_count }); return t("shares.downloads", { count: share.download_count });
+27
View File
@@ -9,6 +9,7 @@ import type {
} from "#lib/api/watches.js"; } from "#lib/api/watches.js";
import { formatTime } from "#lib/format/datetime.js"; import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.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> = { export const KIND_ICONS: Record<WatchKind, string> = {
keyword: "search", keyword: "search",
@@ -162,6 +163,32 @@ export function alertTitle(alert: AlertItem): string {
return personName(alert.peer); 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 { export function alertLabel(alert: AlertItem): string {
if (alert.watch.name) { if (alert.watch.name) {
return alert.watch.name; return alert.watch.name;
+6
View File
@@ -10,12 +10,15 @@ import { login } from "#lib/i18n/messages/login.js";
import { media } from "#lib/i18n/messages/media.js"; import { media } from "#lib/i18n/messages/media.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { notes } from "#lib/i18n/messages/notes.js"; import { notes } from "#lib/i18n/messages/notes.js";
import { parts } from "#lib/i18n/messages/parts.js";
import { policy } from "#lib/i18n/messages/policy.js"; import { policy } from "#lib/i18n/messages/policy.js";
import { presence } from "#lib/i18n/messages/presence.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js"; import { search } from "#lib/i18n/messages/search.js";
import { service } from "#lib/i18n/messages/service.js"; import { service } from "#lib/i18n/messages/service.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
import { shared } from "#lib/i18n/messages/shared.js";
import { shares } from "#lib/i18n/messages/shares.js"; import { shares } from "#lib/i18n/messages/shares.js";
import { social } from "#lib/i18n/messages/social.js"; import { social } from "#lib/i18n/messages/social.js";
import { storage } from "#lib/i18n/messages/storage.js"; import { storage } from "#lib/i18n/messages/storage.js";
@@ -37,12 +40,15 @@ export const messages = {
...media, ...media,
...menu, ...menu,
...notes, ...notes,
...parts,
...policy, ...policy,
...presence,
...profile, ...profile,
...read, ...read,
...search, ...search,
...service, ...service,
...settings, ...settings,
...shared,
...shares, ...shares,
...social, ...social,
...storage, ...storage,
+2
View File
@@ -59,6 +59,7 @@ export const feed = {
}, },
}, },
"feed.changes": { en: "Changes", ru: "Изменения" }, "feed.changes": { en: "Changes", ru: "Изменения" },
"feed.chats.bot": { en: "Bots", ru: "Боты" },
"feed.chats.channel": { en: "Channels", ru: "Каналы" }, "feed.chats.channel": { en: "Channels", ru: "Каналы" },
"feed.chats.dm": { en: "Private", ru: "Личка" }, "feed.chats.dm": { en: "Private", ru: "Личка" },
"feed.chats.group": { en: "Groups", ru: "Группы" }, "feed.chats.group": { en: "Groups", ru: "Группы" },
@@ -92,6 +93,7 @@ export const feed = {
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" }, "feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.filter.profile": { en: "Profiles", ru: "Профили" }, "feed.filter.profile": { en: "Profiles", ru: "Профили" },
"feed.folder": { en: "Folder feed", ru: "Лента папки" },
"feed.lostText": { "feed.lostText": {
en: "The earlier text wasn't saved", en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился", ru: "Прежний текст не сохранился",
-12
View File
@@ -10,10 +10,6 @@ export const jobs = {
en: "A full backfill rereads the whole chat history from the very beginning. The button in the chat header only loads messages newer than the last saved one.", en: "A full backfill rereads the whole chat history from the very beginning. The button in the chat header only loads messages newer than the last saved one.",
ru: "Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в шапке чата догружает только сообщения новее последнего сохранённого.", ru: "Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в шапке чата догружает только сообщения новее последнего сохранённого.",
}, },
"jobs.backfillStarted": {
en: "Full backfill started",
ru: "Полный бэкфилл запущен",
},
"jobs.chat": { en: "chat {id}", ru: "чат {id}" }, "jobs.chat": { en: "chat {id}", ru: "чат {id}" },
"jobs.contactsFailed": { "jobs.contactsFailed": {
en: "Could not sync contacts", en: "Could not sync contacts",
@@ -23,10 +19,6 @@ export const jobs = {
en: "Contacts are synced separately, then search finds people you haven't chatted with yet.", en: "Contacts are synced separately, then search finds people you haven't chatted with yet.",
ru: "Контакты подтягиваются отдельно, тогда в поиске появятся люди, с которыми ещё не было переписки.", ru: "Контакты подтягиваются отдельно, тогда в поиске появятся люди, с которыми ещё не было переписки.",
}, },
"jobs.contactsStarted": {
en: "Contact sync started",
ru: "Синхронизация контактов запущена",
},
"jobs.dialogs": { en: "Chats", ru: "Диалоги" }, "jobs.dialogs": { en: "Chats", ru: "Диалоги" },
"jobs.dialogsHint": { "jobs.dialogsHint": {
en: "Sync pulls in every chat of the account, so old chats show up and can be backfilled.", en: "Sync pulls in every chat of the account, so old chats show up and can be backfilled.",
@@ -76,9 +68,5 @@ export const jobs = {
en: "Could not sync chats", en: "Could not sync chats",
ru: "Не удалось синхронизировать диалоги", ru: "Не удалось синхронизировать диалоги",
}, },
"jobs.syncStarted": {
en: "Chat sync started",
ru: "Синхронизация диалогов запущена",
},
"jobs.title": { en: "Jobs", ru: "Задачи" }, "jobs.title": { en: "Jobs", ru: "Задачи" },
} satisfies Messages; } satisfies Messages;
-1
View File
@@ -1,7 +1,6 @@
import type { Messages } from "#lib/i18n/types.js"; import type { Messages } from "#lib/i18n/types.js";
export const login = { export const login = {
"login.added": { en: "Account added", ru: "Аккаунт добавлен" },
"login.byPhone": { "login.byPhone": {
en: "Log in by phone number", en: "Log in by phone number",
ru: "Войти по номеру телефона", ru: "Войти по номеру телефона",
-4
View File
@@ -27,10 +27,6 @@ export const media = {
en: "Play voice message", en: "Play voice message",
ru: "Воспроизвести голосовое", ru: "Воспроизвести голосовое",
}, },
"media.queued": {
en: "Download queued",
ru: "Скачивание поставлено в очередь",
},
"media.queueFailed": { "media.queueFailed": {
en: "Could not queue the download", en: "Could not queue the download",
ru: "Не удалось поставить в очередь", ru: "Не удалось поставить в очередь",
+22
View File
@@ -0,0 +1,22 @@
import type { Messages } from "#lib/i18n/types.js";
export const parts = {
"folders.all": { en: "All Chats", ru: "Все чаты" },
"forward.channel": { en: "Channel", ru: "Канал" },
"forward.from": { en: "Forwarded from", ru: "Переслано от" },
"forward.hidden": { en: "Hidden account", ru: "Скрытый аккаунт" },
"forward.unknown": { en: "Unknown sender", ru: "Неизвестный отправитель" },
"inline.copied": { en: "Copied", ru: "Скопировано" },
"pinned.message": { en: "Pinned Message", ru: "Закреплённое сообщение" },
"versions.failed": {
en: "Couldn't load the edit history",
ru: "Не удалось загрузить историю изменений",
},
"versions.media": { en: "Media versions", ru: "Версии медиа" },
"versions.none": {
en: "No versions saved.",
ru: "Версии не сохранились.",
},
"versions.title": { en: "Edit history", ru: "История изменений" },
"versions.version": { en: "Version {number}", ru: "Версия {number}" },
} satisfies Messages;
+134
View File
@@ -0,0 +1,134 @@
import type { Messages } from "#lib/i18n/types.js";
export const presence = {
"presence.ago": {
en: "last seen {count} min ago",
ru: {
few: "был(а) {count} минуты назад",
many: "был(а) {count} минут назад",
one: "был(а) {count} минуту назад",
other: "был(а) {count} минуты назад",
},
},
"presence.average": { en: "Daily average", ru: "В среднем за день" },
"presence.axisToday": { en: "today", ru: "сегодня" },
"presence.byDay": { en: "By day", ru: "По дням" },
"presence.byHour": { en: "By hour of the week", ru: "По часам недели" },
"presence.cellAverage": {
en: "{slot}, {span} online on average",
ru: "{slot}, в среднем {span} в сети",
},
"presence.cellNoData": { en: "{slot}, no data", ru: "{slot}, нет данных" },
"presence.day": { en: "{day}, {span} online", ru: "{day}, {span} в сети" },
"presence.dayGap": {
en: "{day}, {span} online, no data for {gap}",
ru: "{day}, {span} в сети, нет данных {gap}",
},
"presence.dayNoData": { en: "{day}, no data", ru: "{day}, нет данных" },
"presence.days": {
en: { one: "{count} day", other: "{count} days" },
ru: {
few: "{count} дня",
many: "{count} дней",
one: "{count} день",
other: "{count} дня",
},
},
"presence.failed": {
en: "Couldn't load analytics.",
ru: "Не удалось загрузить аналитику.",
},
"presence.firstOnline": {
en: "Usually comes online",
ru: "Обычно появляется",
},
"presence.hidden": {
en: "Hides their last seen time. Telegram only shows “last seen recently”, so time online can't be counted.",
ru: "Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
},
"presence.hours": { en: "{hours} h", ru: "{hours} ч" },
"presence.hoursMinutes": {
en: "{hours} h {minutes} min",
ru: "{hours} ч {minutes} мин",
},
"presence.justNow": { en: "last seen just now", ru: "был(а) только что" },
"presence.lastMonth": {
en: "last seen within a month",
ru: "был(а) в этом месяце",
},
"presence.lastOffline": { en: "Usually goes offline", ru: "Обычно уходит" },
"presence.lastWeek": {
en: "last seen within a week",
ru: "был(а) на этой неделе",
},
"presence.longAgo": {
en: "last seen a long time ago",
ru: "был(а) давно",
},
"presence.max": { en: "up to {span}", ru: "до {span}" },
"presence.median": { en: "Typical session", ru: "Обычная сессия" },
"presence.messages": { en: "Messages", ru: "Сообщения" },
"presence.mine": { en: "I reply in", ru: "Я отвечаю" },
"presence.minutes": { en: "{minutes} min", ru: "{minutes} мин" },
"presence.missing": { en: "No data", ru: "Нет данных" },
"presence.noData": { en: "no data", ru: "нет данных" },
"presence.noMessages": {
en: "No messages in this period.",
ru: "За этот период сообщений нет.",
},
"presence.noSessions": {
en: "Not online in this period.",
ru: "За этот период в сети не был(а).",
},
"presence.noStatus": {
en: "No online statuses yet.",
ru: "Статусов пока нет.",
},
"presence.offline": { en: "offline", ru: "не в сети" },
"presence.on": {
en: "last seen {day} at {time}",
ru: "был(а) {day} в {time}",
},
"presence.online": { en: "online", ru: "в сети" },
"presence.recently": { en: "last seen recently", ru: "был(а) недавно" },
"presence.seconds": { en: "{seconds} sec", ru: "{seconds} с" },
"presence.sessions": { en: "Sessions", ru: "Сессии" },
"presence.sessionsFailed": {
en: "Couldn't load sessions.",
ru: "Не удалось загрузить сессии.",
},
"presence.since": { en: "since {time}", ru: "с {time}" },
"presence.theirs": { en: "They reply in", ru: "Мне отвечают" },
"presence.today": { en: "Today", ru: "Сегодня" },
"presence.todayAt": {
en: "last seen today at {time}",
ru: "был(а) сегодня в {time}",
},
"presence.total": {
en: {
one: "online in the last {count} day",
other: "online in the last {count} days",
},
ru: {
few: "в сети за {count} дня",
many: "в сети за {count} дней",
one: "в сети за {count} день",
other: "в сети за {count} дня",
},
},
"presence.volume": {
en: "{day}, {total} in total: {incoming} received, {outgoing} sent",
ru: "{day}, всего {total}: входящих {incoming}, моих {outgoing}",
},
"presence.volumeEmpty": {
en: "{day}, no messages",
ru: "{day}, нет сообщений",
},
"presence.volumeIncoming": { en: "received", ru: "входящие" },
"presence.volumeOutgoing": { en: "sent", ru: "мои" },
"presence.yesterday": { en: "Yesterday", ru: "Вчера" },
"presence.yesterdayAt": {
en: "last seen yesterday at {time}",
ru: "был(а) вчера в {time}",
},
} satisfies Messages;
+26
View File
@@ -13,10 +13,36 @@ export const search = {
"search.clear": { en: "Clear", ru: "Очистить" }, "search.clear": { en: "Clear", ru: "Очистить" },
"search.deleted": { en: "Deleted", ru: "Удалённые" }, "search.deleted": { en: "Deleted", ru: "Удалённые" },
"search.edited": { en: "Edited", 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": { "search.failed": {
en: "Search did not respond", en: "Search did not respond",
ru: "Поиск не ответил", 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.from": { en: "From", ru: "От кого" },
"search.fromName": { en: "Name or @username", ru: "Имя или @username" }, "search.fromName": { en: "Name or @username", ru: "Имя или @username" },
"search.inChat": { en: "Search this chat", ru: "Поиск в чате" }, "search.inChat": { en: "Search this chat", ru: "Поиск в чате" },
+9
View File
@@ -0,0 +1,9 @@
import type { Messages } from "#lib/i18n/types.js";
export const shared = {
"shared.media.empty": { en: "Nothing here yet", ru: "Здесь пока пусто" },
"shared.media.fullscreen": {
en: "Open full screen",
ru: "Открыть на весь экран",
},
} satisfies Messages;
+2 -6
View File
@@ -1,6 +1,7 @@
import type { Messages } from "#lib/i18n/types.js"; import type { Messages } from "#lib/i18n/types.js";
export const shares = { export const shares = {
"shares.accessRevoked": { en: "Access revoked", ru: "Доступ отозван" },
"shares.checkFailed": { "shares.checkFailed": {
en: "Could not check the link", en: "Could not check the link",
ru: "Не удалось проверить ссылку", ru: "Не удалось проверить ссылку",
@@ -79,7 +80,6 @@ export const shares = {
"shares.noChat": { en: "No chat", ru: "Без чата" }, "shares.noChat": { en: "No chat", ru: "Без чата" },
"shares.noExpiry": { en: "No expiry", ru: "Бессрочно" }, "shares.noExpiry": { en: "No expiry", ru: "Бессрочно" },
"shares.noLimit": { en: "No limit", ru: "Без лимита" }, "shares.noLimit": { en: "No limit", ru: "Без лимита" },
"shares.opened": { en: "Link sharing is on", ru: "Доступ по ссылке открыт" },
"shares.openFailed": { "shares.openFailed": {
en: "Could not share the file", en: "Could not share the file",
ru: "Не удалось открыть доступ", ru: "Не удалось открыть доступ",
@@ -115,11 +115,11 @@ export const shares = {
}, },
"shares.revoke": { en: "Revoke", ru: "Отозвать" }, "shares.revoke": { en: "Revoke", ru: "Отозвать" },
"shares.revoked": { en: "revoked", ru: "отозвана" }, "shares.revoked": { en: "revoked", ru: "отозвана" },
"shares.revokedToast": { en: "Access revoked", ru: "Доступ отозван" },
"shares.revokeFailed": { "shares.revokeFailed": {
en: "Could not revoke access", en: "Could not revoke access",
ru: "Не удалось отозвать доступ", ru: "Не удалось отозвать доступ",
}, },
"shares.saved": { en: "Saved", ru: "Сохранено" },
"shares.state.active": { en: "Active", ru: "Открыт" }, "shares.state.active": { en: "Active", ru: "Открыт" },
"shares.state.exhausted": { en: "Limit reached", ru: "Лимит исчерпан" }, "shares.state.exhausted": { en: "Limit reached", ru: "Лимит исчерпан" },
"shares.state.expired": { en: "Expired", ru: "Истёк" }, "shares.state.expired": { en: "Expired", ru: "Истёк" },
@@ -128,10 +128,6 @@ export const shares = {
"shares.tab.all": { en: "All", ru: "Все" }, "shares.tab.all": { en: "All", ru: "Все" },
"shares.toMessage": { en: "Go to message", ru: "К сообщению" }, "shares.toMessage": { en: "Go to message", ru: "К сообщению" },
"shares.until": { en: "until {date}", ru: "до {date}" }, "shares.until": { en: "until {date}", ru: "до {date}" },
"shares.updated": {
en: "Link settings updated",
ru: "Настройки ссылки обновлены",
},
"shares.updateFailed": { "shares.updateFailed": {
en: "Could not update the link", en: "Could not update the link",
ru: "Не удалось обновить ссылку", ru: "Не удалось обновить ссылку",
+1 -4
View File
@@ -11,11 +11,8 @@ export const track = {
ru: "Включите отслеживание, чтобы собирать сообщения, медиа и статистику по этому чату.", ru: "Включите отслеживание, чтобы собирать сообщения, медиа и статистику по этому чату.",
}, },
"track.loadHistory": { en: "Load history", ru: "Загрузить историю" }, "track.loadHistory": { en: "Load history", ru: "Загрузить историю" },
"track.loading": { en: "History is loading", ru: "История загружается" },
"track.start": { en: "Start tracking", ru: "Начать отслеживать" }, "track.start": { en: "Start tracking", ru: "Начать отслеживать" },
"track.started": {
en: "Tracking is on, loading the history",
ru: "Отслеживание включено, история загружается",
},
"track.thisChat": { en: "This chat", ru: "Этот чат" }, "track.thisChat": { en: "This chat", ru: "Этот чат" },
"track.tracked": { "track.tracked": {
en: "{title} is tracked, new messages and stats are collected automatically.", en: "{title} is tracked, new messages and stats are collected automatically.",
+2 -2
View File
@@ -3,7 +3,7 @@ import {
type LiveAlertEvent, type LiveAlertEvent,
unseenAlerts, unseenAlerts,
} from "#lib/api/watches.js"; } 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 { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env"; import { browser } from "$app/env";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -49,7 +49,7 @@ function createAlerts() {
}); });
popup.onclick = () => { popup.onclick = () => {
window.focus(); window.focus();
goto(alert.path); goto(alertHref(alert));
popup.close(); popup.close();
}; };
} }
+20 -3
View File
@@ -8,13 +8,21 @@ import { goto } from "$app/navigation";
const LIVE_DELAY = 600; const LIVE_DELAY = 600;
const KIND_PARAM = "feed"; const KIND_PARAM = "feed";
const CHATS_PARAM = "chats"; const CHATS_PARAM = "chats";
const FOLDER_PARAM = "folder";
const MESSAGE_PARAM = "message"; const MESSAGE_PARAM = "message";
export const MARK_PARAM = "mark";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"]; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; export const FEED_CHAT_KINDS: FeedChatKind[] = [
"dm",
"bot",
"group",
"channel",
];
export interface FeedFilter { export interface FeedFilter {
chats: FeedChatKind | null; chats: FeedChatKind | null;
folder: number | null;
kind: FeedKind; kind: FeedKind;
} }
@@ -29,10 +37,12 @@ export function feedFilterOf(url: UrlLike): FeedFilter | null {
return null; return null;
} }
const chats = searchParams.get(CHATS_PARAM); const chats = searchParams.get(CHATS_PARAM);
const folder = Number(searchParams.get(FOLDER_PARAM));
return { return {
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind) chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
? (chats as FeedChatKind) ? (chats as FeedChatKind)
: null, : null,
folder: Number.isInteger(folder) && folder > 0 ? folder : null,
kind: kind as FeedKind, kind: kind as FeedKind,
}; };
} }
@@ -41,12 +51,16 @@ function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
const next = new URL(url.href); const next = new URL(url.href);
next.searchParams.delete(KIND_PARAM); next.searchParams.delete(KIND_PARAM);
next.searchParams.delete(CHATS_PARAM); next.searchParams.delete(CHATS_PARAM);
next.searchParams.delete(FOLDER_PARAM);
next.searchParams.delete(MESSAGE_PARAM); next.searchParams.delete(MESSAGE_PARAM);
if (filter) { if (filter) {
next.searchParams.set(KIND_PARAM, filter.kind); next.searchParams.set(KIND_PARAM, filter.kind);
if (filter.chats) { if (filter.chats) {
next.searchParams.set(CHATS_PARAM, filter.chats); next.searchParams.set(CHATS_PARAM, filter.chats);
} }
if (filter.folder !== null) {
next.searchParams.set(FOLDER_PARAM, String(filter.folder));
}
} }
return next; return next;
} }
@@ -72,6 +86,7 @@ export function messageHref(
export function withoutMessage(url: UrlLike): string { export function withoutMessage(url: UrlLike): string {
const next = new URL(url.href); const next = new URL(url.href);
next.searchParams.delete(MESSAGE_PARAM); next.searchParams.delete(MESSAGE_PARAM);
next.searchParams.delete(MARK_PARAM);
return href(next); return href(next);
} }
@@ -138,8 +153,10 @@ function createFeed() {
unseen = next; unseen = next;
} }
}, },
async open(url: UrlLike) { async open(url: UrlLike, folder: number | null = null) {
const target = href(withFilter(url, { chats: null, kind: "all" })); const target = href(
withFilter(url, { chats: null, folder, kind: "all" })
);
openedHref = target; openedHref = target;
await goto(target, { reset: false }); await goto(target, { reset: false });
}, },
+19
View File
@@ -0,0 +1,19 @@
const FLASH_MS = 2000;
export function createFlash<T>() {
let current = $state<T | null>(null);
let timer: ReturnType<typeof setTimeout> | undefined;
return {
get current(): T | null {
return current;
},
show(value: T) {
clearTimeout(timer);
current = value;
timer = setTimeout(() => {
current = null;
}, FLASH_MS);
},
};
}
+49 -2
View File
@@ -11,6 +11,7 @@ import type {
SearchQuery, SearchQuery,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { MARK_PARAM } from "#lib/stores/feed.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -30,6 +31,7 @@ const DEBOUNCE_MS = 250;
const REMOTE_DELAY_MS = 450; const REMOTE_DELAY_MS = 450;
const CHAT_LIMIT = 30; const CHAT_LIMIT = 30;
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
export const EVERYWHERE_MIN = 3;
const KINDS = new Set<string>(["private", "group", "channel"]); const KINDS = new Set<string>(["private", "group", "channel"]);
const DAY = /^\d{4}-\d{2}-\d{2}$/; const DAY = /^\d{4}-\d{2}-\d{2}$/;
const PARAMS = [ const PARAMS = [
@@ -41,6 +43,7 @@ const PARAMS = [
"from", "from",
"since", "since",
"until", "until",
"everywhere",
] as const; ] as const;
type Param = (typeof PARAMS)[number]; type Param = (typeof PARAMS)[number];
@@ -90,6 +93,7 @@ export function createHitPager() {
let hits = $state<SearchHit[]>([]); let hits = $state<SearchHit[]>([]);
let terms = $state<string[]>([]); let terms = $state<string[]>([]);
let cursor = $state<string | null>(null); let cursor = $state<string | null>(null);
let scannedTo = $state<string | null>(null);
let loading = $state(false); let loading = $state(false);
let appending = $state(false); let appending = $state(false);
let failed = $state(false); let failed = $state(false);
@@ -106,6 +110,7 @@ export function createHitPager() {
hits = request.cursor ? [...hits, ...result.hits] : result.hits; hits = request.cursor ? [...hits, ...result.hits] : result.hits;
({ terms } = result); ({ terms } = result);
cursor = result.next_cursor; cursor = result.next_cursor;
scannedTo = result.scanned_to;
} catch { } catch {
if (current === seq) { if (current === seq) {
failed = true; failed = true;
@@ -122,6 +127,7 @@ export function createHitPager() {
seq += 1; seq += 1;
base = request; base = request;
cursor = null; cursor = null;
scannedTo = null;
failed = false; failed = false;
appending = false; appending = false;
loading = request !== null; loading = request !== null;
@@ -160,12 +166,21 @@ export function createHitPager() {
run(base); run(base);
}, },
run, run,
get scannedTo() {
return scannedTo;
},
get terms() { get terms() {
return terms; return terms;
}, },
}; };
} }
export type HitPager = ReturnType<typeof createHitPager>;
export function hitKey(hit: SearchHit): string {
return `${hit.chat_id}:${hit.message_id}`;
}
function createSearch() { function createSearch() {
let open = $state(false); let open = $state(false);
let draft = $state(""); let draft = $state("");
@@ -177,7 +192,9 @@ function createSearch() {
let lastRequest = ""; let lastRequest = "";
let lastText = ""; let lastText = "";
let seq = 0; let seq = 0;
let lastEverywhere = "";
const messages = createHitPager(); const messages = createHitPager();
const everywhere = createHitPager();
const params = $derived(page.url.searchParams); const params = $derived(page.url.searchParams);
const urlQuery = $derived(params.get("q") ?? ""); const urlQuery = $derived(params.get("q") ?? "");
@@ -192,6 +209,14 @@ function createSearch() {
} }
return JSON.stringify([accounts.selectedId, toRequest(query, filters)]); 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 peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id)); const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id));
@@ -272,7 +297,9 @@ function createSearch() {
stopTyping(); stopTyping();
lastRequest = ""; lastRequest = "";
lastText = ""; lastText = "";
lastEverywhere = "";
messages.run(null); messages.run(null);
everywhere.run(null);
runText(""); runText("");
} }
@@ -288,14 +315,21 @@ function createSearch() {
const update = Object.fromEntries(PARAMS.map((key) => [key, null])); const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
write(update).catch(() => undefined); write(update).catch(() => undefined);
}, },
everywhere,
get everywhereRequest() {
return everywhereRequest;
},
get filtered() { get filtered() {
return filtered; return filtered;
}, },
get filters() { get filters() {
return filters; return filters;
}, },
hitHref(chatId: number, messageId: number): string { hitHref(chatId: number, messageId: number, marks: string[]): string {
const next = new URLSearchParams({ message: String(messageId) }); const next = new URLSearchParams({ message: String(messageId) });
for (const mark of marks) {
next.append(MARK_PARAM, mark);
}
for (const key of PARAMS) { for (const key of PARAMS) {
const value = params.get(key); const value = params.get(key);
if (value) { if (value) {
@@ -315,6 +349,9 @@ function createSearch() {
return request; return request;
}, },
reset, reset,
searchEverywhere() {
write({ everywhere: "1" }).catch(() => undefined);
},
setFilter(key: "from" | "kind" | "since" | "until", value: string | null) { setFilter(key: "from" | "kind" | "since" | "until", value: string | null) {
write({ [key]: value }).catch(() => undefined); write({ [key]: value }).catch(() => undefined);
}, },
@@ -329,7 +366,11 @@ function createSearch() {
} }
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; 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) .catch(() => undefined)
.finally(() => { .finally(() => {
if (timer === null) { if (timer === null) {
@@ -352,6 +393,12 @@ function createSearch() {
runText(value); runText(value);
} }
}, },
syncEverywhere(key: string) {
if (key !== lastEverywhere) {
lastEverywhere = key;
everywhere.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
}
},
get text() { get text() {
return text; return text;
}, },
+1 -4
View File
@@ -1,4 +1,4 @@
type ToastType = "info" | "error" | "success"; type ToastType = "info" | "error";
interface Toast { interface Toast {
id: number; id: number;
@@ -39,9 +39,6 @@ function createToasts() {
return items; return items;
}, },
show, show,
success(message: string) {
return show(message, "success");
},
}; };
} }
+3 -2
View File
@@ -36,6 +36,7 @@ export interface PanelState {
interface JumpTarget { interface JumpTarget {
chatId: number; chatId: number;
marks: string[];
messageId: number; messageId: number;
} }
@@ -141,8 +142,8 @@ function createUi() {
: null; : null;
}, },
record, record,
requestJump(chatId: number, messageId: number) { requestJump(chatId: number, messageId: number, marks: string[] = []) {
jumpTarget = { chatId, messageId }; jumpTarget = { chatId, marks, messageId };
}, },
resetPanel() { resetPanel() {
panel = null; panel = null;
+7
View File
@@ -159,11 +159,18 @@ html.theme-transition * {
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
input,
textarea,
select {
font-size: max(1em, 1rem) !important;
}
} }
.bg-menu-content { .bg-menu-content {
z-index: var(--z-portal-menu); z-index: var(--z-portal-menu);
min-width: 12rem; min-width: 12rem;
max-width: calc(100vw - 1rem);
padding: 0.375rem; padding: 0.375rem;
border-radius: 0.75rem; border-radius: 0.75rem;
color: var(--color-text); color: var(--color-text);
@@ -4,7 +4,7 @@
import MessageList from "#lib/components/MessageList.svelte"; import MessageList from "#lib/components/MessageList.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.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 { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -27,7 +27,11 @@
const messageId = Number(linkedMessage); const messageId = Number(linkedMessage);
untrack(() => { untrack(() => {
if (Number.isSafeInteger(messageId)) { 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 }); goto(withoutMessage(page.url), { replace: true, reset: false });
}); });