Compare commits
12
Commits
543e6eaa50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e8a96774 | ||
|
|
d660079c02 | ||
|
|
acfa726e61 | ||
|
|
d9378056f4 | ||
|
|
c307426f4e | ||
|
|
977dee3cb2 | ||
|
|
bca0b34636 | ||
|
|
e228d999dd | ||
|
|
aff8a9e443 | ||
|
|
25364f4523 | ||
|
|
0cd34f3037 | ||
|
|
cff493f5dd |
No files matched your search
+1
-1
@@ -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,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,
|
||||||
)
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<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 { 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";
|
||||||
|
|
||||||
@@ -10,7 +11,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,
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{#if queued.current === messageId}
|
||||||
|
<Icon name="check" />
|
||||||
|
{t("chat.media.queued")}
|
||||||
|
{:else}
|
||||||
{t("media.downloadToArchive")}
|
{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">
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{#if started.current === "sync"}
|
||||||
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
<Icon name="reload" />
|
<Icon name="reload" />
|
||||||
<span>{t("jobs.syncDialogs")}</span>
|
<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"
|
||||||
>
|
>
|
||||||
|
{#if started.current === "contacts"}
|
||||||
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
<Icon name="user" />
|
<Icon name="user" />
|
||||||
<span>{t("jobs.syncContacts")}</span>
|
<span>{t("jobs.syncContacts")}</span>
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -188,8 +200,13 @@
|
|||||||
onclick={start}
|
onclick={start}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
|
{#if started.current === "backfill"}
|
||||||
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
<Icon name="cloud-download" />
|
<Icon name="cloud-download" />
|
||||||
<span>{t("jobs.startBackfill")}</span>
|
<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>
|
||||||
|
{#if saved.current === share.id}
|
||||||
|
<Icon name="check" />
|
||||||
|
{t("shares.saved")}
|
||||||
|
{:else}
|
||||||
{t("common.save")}
|
{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"
|
||||||
>
|
>
|
||||||
|
{#if started.current === peerId}
|
||||||
|
<Icon name="check" />{t("stories.backfillStarted")}
|
||||||
|
{:else}
|
||||||
<Icon name="cloud-download" />{t("stories.loadOlder")}
|
<Icon name="cloud-download" />{t("stories.loadOlder")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("ui.refresh")}
|
aria-label={t("ui.refresh")}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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: "Войти по номеру телефона",
|
||||||
|
|||||||
@@ -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: "Не удалось поставить в очередь",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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: "Поиск в чате" },
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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: "Не удалось обновить ссылку",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const LIVE_DELAY = 600;
|
|||||||
const KIND_PARAM = "feed";
|
const KIND_PARAM = "feed";
|
||||||
const CHATS_PARAM = "chats";
|
const CHATS_PARAM = "chats";
|
||||||
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", "group", "channel"];
|
||||||
@@ -72,6 +73,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 +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");
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user