Compare commits

69 changed files with 2740 additions and 293 deletions

No files matched your search

+7 -3
View File
@@ -1,6 +1,6 @@
# beavergram # beavergram
A read-only Telegram client that keeps everything. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents. A Telegram client that keeps everything and writes nowhere but your own Saved Messages. A userbot on your own account logs every incoming message into Postgres, including the ones later deleted or edited, along with media, reactions, presence and stories. A web client reads that archive back, and an MCP server hands it to agents.
Useful on its own: a Telegram where nothing disappears. In the [beaver](https://git.kotikot.com/beaver) setup it is one of the agent's hands - the dispatcher reads chat history by the `chat_id` stored in a person's card. Useful on its own: a Telegram where nothing disappears. In the [beaver](https://git.kotikot.com/beaver) setup it is one of the agent's hands - the dispatcher reads chat history by the `chat_id` stored in a person's card.
@@ -8,7 +8,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Messages** with edits as versions and deletions as flags, so a message removed in Telegram is still in the archive with its history. - **Messages** with edits as versions and deletions as flags, so a message removed in Telegram is still in the archive with its history.
- **Media** on disk, content-addressed, with the versions of a replaced photo. - **Media** on disk, content-addressed, with the versions of a replaced photo.
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. - **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out: your own and the played ones by themselves, any voice or round video on request.
- **Presence** as a time series (TimescaleDB), read receipts, stories. - **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
@@ -24,7 +24,11 @@ Nothing is ever deleted from the archive.
## MCP ## MCP
Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be. Read: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`.
Act: `transcribe_message` asks Telegram for the transcript of any voice or round video, `set_watch` writes a rule, `download_media` pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. `send_to_saved` is the one thing that sends to Telegram, and only to your own Saved Messages: text, a file by URL (as a voice message when asked), or an archived message, forwarded while it lives and copied from the archive once it is gone. Send any OGG/Opus there as a voice and `transcribe_message` turns Telegram Premium into a free transcriber. There is no tool that edits, deletes or sends anywhere else.
The same over HTTP: `POST /api/media/transcribe` (`wait` seconds to hold for the text), `GET /api/media/lookup?sha256=` (where these exact bytes appear in the archive), `POST /api/saved` (multipart `account_id`, `text`, `file`, `voice`) and `POST /api/saved/forward`.
## Run ## Run
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:bd45432c1376a243b6761b3546b344caff2d6e2a2b9530354f99b609b1f10ef1 FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:e586c1c65b7071aec78f02c5587c76c5011159cd76b3d6f1255f40e48e850abc
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/*
@@ -0,0 +1,27 @@
"""media storage_key index for content lookup
Revision ID: b7d2e9f4a1c6
Revises: f1c4a8e2d6b3
Create Date: 2026-10-28 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "b7d2e9f4a1c6"
down_revision: str | None = "f1c4a8e2d6b3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"CREATE INDEX ix_media_storage_key ON media (storage_key) "
"WHERE storage_key IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_media_storage_key")
+2
View File
@@ -34,6 +34,7 @@ from api.routers import (
policy, policy,
presence, presence,
profile, profile,
saved,
search, search,
shares, shares,
social, social,
@@ -113,6 +114,7 @@ app.include_router(annotations.router)
app.include_router(watches.router) app.include_router(watches.router)
app.include_router(webhooks.router) app.include_router(webhooks.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(saved.router)
app.include_router(storage.router) app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(capture.router) app.include_router(capture.router)
+78 -7
View File
@@ -19,6 +19,8 @@ from api.mcp.download import (
share_media, share_media,
) )
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from api.saved import FetchError, fetch_file, forward, send_new
from api.transcribe import request_transcript
from dependencies.container import container from dependencies.container import container
from utils.files import resolve_mime from utils.files import resolve_mime
from utils.read import ( from utils.read import (
@@ -45,7 +47,8 @@ from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize
INSTRUCTIONS = """\ INSTRUCTIONS = """\
beavergram archives Telegram data (chats, messages, media, presence, stories, beavergram archives Telegram data (chats, messages, media, presence, stories,
peer history) into Postgres and exposes it read-only over these tools. peer history) into Postgres and exposes it over these tools, read-only except
for the few writes listed at the end.
Account scoping: Account scoping:
- Every tool takes `account_id`. It selects which archived Telegram account to - Every tool takes `account_id`. It selects which archived Telegram account to
@@ -87,6 +90,9 @@ Typical flows:
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
the content itself for small images and text files. `get_media` returns the content itself for small images and text files. `get_media` returns
metadata only. metadata only.
- Voice: transcripts of played voices and round videos are already in the
history ("STT Content"). For "not transcribed" ones call
`transcribe_message`.
- Monitoring: `set_watch` creates a local rule (kinds `keyword`, - Monitoring: `set_watch` creates a local rule (kinds `keyword`,
`new_message`, `message_deleted`, `message_edited`, `peer_online`; params in `new_message`, `message_deleted`, `message_edited`, `peer_online`; params in
its description), `list_watches` lists rules, and `list_alerts` reads what its description), `list_watches` lists rules, and `list_alerts` reads what
@@ -97,8 +103,10 @@ Watched bots:
own `account_id` and its chats, messages and media read through the same own `account_id` and its chats, messages and media read through the same
tools as a user account. Nothing bot-specific to call. tools as a user account. Nothing bot-specific to call.
Writes: everything is read-only except `set_watch` and the share links Writes: everything is read-only except `set_watch`, the share links
`download_media` creates (visible and revocable in the web UI). `download_media` creates (visible and revocable in the web UI), and
`send_to_saved`, the one tool that sends to Telegram: only to the user's own
Saved Messages.
Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
`next_cursor` to pass back as `cursor`. `next_cursor` to pass back as `cursor`.
@@ -298,10 +306,11 @@ async def get_feed(
"""Deleted and edited messages and profile changes across chats, newest first. """Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all; `kind` picks deleted, edited, profile, messages (deleted and edited) or all;
`since` keeps items at or after that time; `chat_id` or `chat_kind` (dm, `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm
group, channel) narrow it down. Edited items carry `before` and `after` with people, bot, group, channel) narrow it down. Edited items carry
texts. A deleted item with `message_count` above 1 is a bulk deletion in `before` and `after` texts. A deleted item with `message_count` above 1 is
one chat; read its messages with `get_deleted_messages` for that chat. a bulk deletion in one chat; read its messages with `get_deleted_messages`
for that chat.
Profile items cover people in private chats or contacts and chats in the Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass `title` to `[old, new]`, changes a day apart or closer are merged. Pass
@@ -368,6 +377,68 @@ async def download_media(
] ]
@mcp.tool
async def transcribe_message(
account_id: int, chat_id: int, message_id: int, wait_seconds: int = 60
) -> dict[str, Any]:
"""Transcribe a voice message or round video through Telegram, any of them.
Returns the archived transcript at once when there is one; otherwise asks
Telegram (Premium transcription of the archived account) and waits up to
`wait_seconds`. `status` is `done` with `text`, `pending` (call again
later) or `failed` with `error`: `too_long`, `no_premium`, `not_voice`,
`empty`, `bot_account` or `failed`. Telegram may mark an unplayed voice as
listened for its sender.
"""
result = await request_transcript(
await _pool(), account_id, chat_id, message_id, max(wait_seconds, 0)
)
return result.model_dump(mode="json", exclude_none=True)
@mcp.tool
async def send_to_saved(
account_id: int,
text: str | None = None,
url: str | None = None,
voice: bool = False,
chat_id: int | None = None,
message_id: int | None = None,
) -> dict[str, Any]:
"""Send something to the account's own Saved Messages.
The only tool that writes to Telegram, and only to the user's own chat.
- `chat_id` + `message_id`: an archived message. It is forwarded while it
still exists in Telegram; a deleted, self-destructing or unforwardable
one goes as a copy from the archive. `mode` says which.
- `text` and/or `url`: a new message. The server fetches `url` (http or
https, up to 200 MB) and sends it as a file with `text` as the caption;
`voice=true` sends it as a voice message exactly as given, so pass
OGG/Opus. A local file can skip the URL: `POST /api/saved` (multipart
`account_id`, `text`, `voice`, `file`) with this server's bearer.
Returns `status` (`done`, `pending`, `failed` with `error`) and the
`chat_id`/`message_id` of the new message. Any audio can be transcribed
through Telegram Premium: send it with `voice=true`, then call
`transcribe_message` on the returned ids.
"""
pool = await _pool()
if chat_id is not None and message_id is not None:
result = await forward(pool, account_id, chat_id, message_id)
return result.model_dump(mode="json", exclude_none=True)
upload = None
if url is not None:
try:
upload = await fetch_file(url)
except FetchError as exc:
raise ToolError(str(exc)) from exc
if upload is None and not text:
msg = "pass text, url, or chat_id with message_id"
raise ToolError(msg)
storage = await container.get(ContentAddressedStorage)
result = await send_new(pool, storage, account_id, text, upload, voice=voice)
return result.model_dump(mode="json", exclude_none=True)
@mcp.tool @mcp.tool
async def get_callbacks( async def get_callbacks(
account_id: int, chat_id: int, message_id: int account_id: int, chat_id: int, message_id: int
+7 -9
View File
@@ -7,6 +7,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from api.transcribe import TranscribeResult, request_transcript
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.read.models import DEFAULT_LIMIT, Page from utils.read.models import DEFAULT_LIMIT, Page
@@ -35,6 +36,7 @@ class TranscribeRequest(BaseModel):
account_id: int account_id: int
chat_id: int chat_id: int
message_id: int message_id: int
wait: float = 0
class SyncDialogsRequest(BaseModel): class SyncDialogsRequest(BaseModel):
@@ -105,17 +107,13 @@ async def enqueue_fetch_media(
return EnqueueResponse(job_id=job_id) return EnqueueResponse(job_id=job_id)
@router.post("/media/transcribe", status_code=201) @router.post("/media/transcribe")
async def enqueue_transcribe( async def transcribe(
pool: FromDishka[asyncpg.Pool], body: TranscribeRequest pool: FromDishka[asyncpg.Pool], body: TranscribeRequest
) -> EnqueueResponse: ) -> TranscribeResult:
job_id = await enqueue( return await request_transcript(
pool, pool, body.account_id, body.chat_id, body.message_id, body.wait
body.account_id,
"transcribe",
{"chat_id": body.chat_id, "message_id": body.message_id},
) )
return EnqueueResponse(job_id=job_id)
@router.post("/dialogs/sync", status_code=201) @router.post("/dialogs/sync", status_code=201)
+8 -1
View File
@@ -29,6 +29,7 @@ async def list_feed(
kind: Annotated[FeedKind, Query()] = FeedKind.ALL, kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None, chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None, chat_kind: Annotated[FeedChatKind | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None, since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None, cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT, limit: Limit = DEFAULT_LIMIT,
@@ -36,7 +37,13 @@ async def list_feed(
return await feed.list_feed( return await feed.list_feed(
pool, pool,
account_id, account_id,
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since), FeedFilter(
kind=kind,
chat_id=chat_id,
chat_kind=chat_kind,
folder_id=folder_id,
since=since,
),
limit=limit, limit=limit,
cursor=cursor, cursor=cursor,
) )
+12 -1
View File
@@ -8,17 +8,19 @@ from fastapi.responses import FileResponse
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS
from utils.files import content_disposition, media_file_name, resolve_mime from utils.files import content_disposition, media_file_name, resolve_mime
from utils.read.media import ( from utils.read.media import (
find_by_content,
get_media, get_media,
get_media_version, get_media_version,
get_media_versions, get_media_versions,
get_message_media, get_message_media,
) )
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute) router = APIRouter(prefix="/api/media", tags=["media"], route_class=DishkaRoute)
Download = Annotated[bool, Query()] Download = Annotated[bool, Query()]
Sha256 = Annotated[str, Query(pattern="^[0-9a-f]{64}$")]
def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]: def _attachment(headers: dict[str, str], file_name: str) -> dict[str, str]:
@@ -81,6 +83,15 @@ async def message_media(
return media return media
@router.get("/lookup")
async def lookup_media(
pool: FromDishka[asyncpg.Pool],
sha256: Sha256,
account_id: Annotated[int | None, Query()] = None,
) -> list[MediaPlaceView]:
return await find_by_content(pool, sha256, account_id)
@router.get("/{media_id}") @router.get("/{media_id}")
async def serve_media( async def serve_media(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+44
View File
@@ -0,0 +1,44 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from api.saved import MAX_UPLOAD_BYTES, SavedResult, forward, send_new
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/saved", tags=["saved"], route_class=DishkaRoute)
class ForwardRequest(BaseModel):
account_id: int
chat_id: int
message_id: int
@router.post("")
async def send_to_saved(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: Annotated[int, Form()],
text: Annotated[str | None, Form()] = None,
voice: Annotated[bool, Form()] = False,
file: Annotated[UploadFile | None, File()] = None,
) -> SavedResult:
upload = None
if file is not None:
if file.size is not None and file.size > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "file too large")
data = await file.read()
upload = (data, file.filename or "file", file.content_type)
if upload is None and not text:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "text or file is required")
return await send_new(pool, storage, account_id, text, upload, voice=voice)
@router.post("/forward")
async def forward_to_saved(
pool: FromDishka[asyncpg.Pool], body: ForwardRequest
) -> SavedResult:
return await forward(pool, body.account_id, body.chat_id, body.message_id)
+19 -2
View File
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from utils.files import content_disposition, story_file_name from utils.files import content_disposition, sniff_media_kind, story_file_name
from utils.read import peers from utils.read import peers
from utils.read.models import DEFAULT_LIMIT, Page, StoryView from utils.read.models import DEFAULT_LIMIT, Page, StoryView
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"} _STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
async def _with_kind(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
story: StoryView,
) -> StoryView:
if story.media_kind or not story.storage_key:
return story
if not storage.exists(story.storage_key):
return story
kind = sniff_media_kind(storage.head(story.storage_key))
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
return story.model_copy(update={"media_kind": kind})
@router.get("/stories") @router.get("/stories")
async def list_stories( async def list_stories(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
account_id: AccountId, account_id: AccountId,
peer_id: Annotated[int | None, Query()] = None, peer_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StoryView]: ) -> list[StoryView]:
return await peers.get_stories( stories = await peers.get_stories(
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
) )
return [await _with_kind(pool, storage, account_id, story) for story in stories]
@router.get("/stories/{peer_id}/{story_id}/media") @router.get("/stories/{peer_id}/{story_id}/media")
+131
View File
@@ -0,0 +1,131 @@
import asyncio
import json
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlsplit
import asyncpg
import httpx2
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import accounts
from utils.storage import ContentAddressedStorage
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
WAIT_SECONDS = 90.0
FETCH_TIMEOUT_SECONDS = 120.0
_POLL_SECONDS = 0.5
SavedMode = Literal["sent", "forwarded", "copied"]
class SavedResult(BaseModel):
status: Literal["done", "pending", "failed"]
chat_id: int | None = None
message_id: int | None = None
mode: SavedMode | None = None
error: str | None = None
job_id: int | None = None
class FetchError(Exception):
pass
async def fetch_file(url: str) -> tuple[bytes, str, str | None]:
if urlsplit(url).scheme not in ("http", "https"):
msg = "url must be http or https"
raise FetchError(msg)
chunks: list[bytes] = []
size = 0
try:
async with (
httpx2.AsyncClient(
timeout=FETCH_TIMEOUT_SECONDS, follow_redirects=True
) as client,
client.stream("GET", url) as response,
):
response.raise_for_status()
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
msg = "file is over 200 MB"
raise FetchError(msg)
chunks.append(chunk)
except httpx2.HTTPError as exc:
msg = f"could not fetch {url}: {exc}"
raise FetchError(msg) from exc
name = Path(urlsplit(str(response.url)).path).name or "file"
mime = response.headers.get("content-type", "").split(";")[0].strip() or None
return b"".join(chunks), name, mime
async def _refusal(pool: asyncpg.Pool, account_id: int) -> SavedResult | None:
account = await accounts.get_account(pool, account_id)
if account is None or account.is_bot:
return SavedResult(status="failed", error="bot_account")
return None
async def _outcome(pool: asyncpg.Pool, job_id: int, mode: SavedMode) -> SavedResult:
loop = asyncio.get_running_loop()
deadline = loop.time() + WAIT_SECONDS
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
row = await pool.fetchrow(
"SELECT status, error, progress FROM jobs WHERE id = $1", job_id
)
if row is None or row["status"] in ("failed", "canceled"):
error = (row and row["error"]) or "failed"
return SavedResult(status="failed", error=error, job_id=job_id)
if row["status"] == "done":
sent: dict[str, Any] = json.loads(row["progress"])
return SavedResult(
status="done",
chat_id=sent.get("chat_id"),
message_id=sent.get("message_id"),
mode=sent.get("mode", mode),
job_id=job_id,
)
return SavedResult(status="pending", job_id=job_id)
async def send_new( # noqa: PLR0913
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
text: str | None,
file: tuple[bytes, str, str | None] | None,
*,
voice: bool = False,
) -> SavedResult:
refusal = await _refusal(pool, account_id)
if refusal is not None:
return refusal
params: dict[str, Any] = {"text": text}
if file is not None:
data, name, mime = file
params |= {
"upload": storage.stage(data),
"file_name": name,
"mime": mime,
"voice": voice,
}
job_id = await enqueue(pool, account_id, "send_saved", params)
return await _outcome(pool, job_id, "sent")
async def forward(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> SavedResult:
refusal = await _refusal(pool, account_id)
if refusal is not None:
return refusal
job_id = await enqueue(
pool,
account_id,
"forward_saved",
{"chat_id": chat_id, "message_id": message_id},
)
return await _outcome(pool, job_id, "forwarded")
+64
View File
@@ -0,0 +1,64 @@
import asyncio
from typing import Literal
import asyncpg
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import accounts, media
MAX_WAIT_SECONDS = 60.0
_POLL_SECONDS = 1.0
_TRANSCRIBABLE = ("voice", "video_note")
TranscribeStatus = Literal["done", "pending", "failed"]
class TranscribeResult(BaseModel):
status: TranscribeStatus
text: str | None = None
error: str | None = None
job_id: int | None = None
async def _text(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> str | None:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
return item.extracted_text if item else None
async def _job_error(pool: asyncpg.Pool, job_id: int) -> str | None:
row = await pool.fetchrow("SELECT status, error FROM jobs WHERE id = $1", job_id)
if row is None or row["status"] == "canceled":
return "failed"
if row["status"] == "failed":
return row["error"] or "failed"
return None
async def request_transcript(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int, wait: float = 0
) -> TranscribeResult:
item = await media.get_message_media(pool, account_id, chat_id, message_id)
if item is None or item.kind not in _TRANSCRIBABLE:
return TranscribeResult(status="failed", error="not_voice")
if item.extracted_text:
return TranscribeResult(status="done", text=item.extracted_text)
account = await accounts.get_account(pool, account_id)
if account is None or account.is_bot:
return TranscribeResult(status="failed", error="bot_account")
job_id = await enqueue(
pool, account_id, "transcribe", {"chat_id": chat_id, "message_id": message_id}
)
loop = asyncio.get_running_loop()
deadline = loop.time() + min(wait, MAX_WAIT_SECONDS)
while loop.time() < deadline:
await asyncio.sleep(_POLL_SECONDS)
text = await _text(pool, account_id, chat_id, message_id)
if text:
return TranscribeResult(status="done", text=text, job_id=job_id)
error = await _job_error(pool, job_id)
if error is not None:
return TranscribeResult(status="failed", error=error, job_id=job_id)
return TranscribeResult(status="pending", job_id=job_id)
+2 -21
View File
@@ -1,12 +1,7 @@
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture import capture_message from userbot.modules.capture.ingest import ingest_message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.client import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
@PyroClient.on_message() @PyroClient.on_message()
@@ -20,21 +15,7 @@ async def on_message(client: PyroClient, message: Message) -> None:
or message.date is None or message.date is None
): ):
return return
meta = meta_from_chat(message.chat, ctx.contacts.ids) await ingest_message(client, ctx, message)
await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.messages:
return
await capture_message(client, message, ctx, toggles)
await notify_bg_event(
ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id
)
if (
toggles.stt
and is_transcribable(message)
and (message.outgoing or message.unread_media is False)
):
await safe_transcribe(client, ctx, meta.chat_id, message.id)
handlers = on_message.handlers # ty: ignore[unresolved-attribute] handlers = on_message.handlers # ty: ignore[unresolved-attribute]
@@ -0,0 +1,30 @@
from pyrogram import Client
from pyrogram.types import Message
from userbot.modules.capture.chat_meta import meta_from_chat
from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.message import capture_message
from userbot.modules.stt import is_transcribable
from userbot.modules.stt.gate import safe_transcribe
from userbot.modules.watches.seen import seen_message
from utils.events import notify_bg_event
async def ingest_message(client: Client, ctx: CaptureContext, message: Message) -> None:
if message.chat is None:
return
meta = meta_from_chat(message.chat, ctx.contacts.ids)
await ctx.watches.on_message(seen_message(meta.chat_id, message))
toggles = ctx.resolve(meta)
if not toggles.messages:
return
await capture_message(client, message, ctx, toggles)
await notify_bg_event(
ctx.pool, "message", ctx.account_id, chat_id=meta.chat_id, message_id=message.id
)
if (
toggles.stt
and is_transcribable(message)
and (message.outgoing or message.unread_media is False)
):
await safe_transcribe(client, ctx, meta.chat_id, message.id)
@@ -7,6 +7,7 @@ from userbot.modules.jobs.handlers import (
fetch_media, fetch_media,
fetch_read_info, fetch_read_info,
search_peers, search_peers,
send_saved,
sync_contacts, sync_contacts,
sync_dialogs, sync_dialogs,
transcribe, transcribe,
@@ -21,6 +22,7 @@ __all__ = [
"fetch_media", "fetch_media",
"fetch_read_info", "fetch_read_info",
"search_peers", "search_peers",
"send_saved",
"sync_contacts", "sync_contacts",
"sync_dialogs", "sync_dialogs",
"transcribe", "transcribe",
@@ -0,0 +1,32 @@
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.saved import forward, send_new
@register("send_saved")
async def send_saved(ctx: JobContext) -> None:
client = ctx.client
capture = getattr(client, "capture", None)
if client is None or capture is None:
return
sent = await send_new(client, capture, ctx.job.params)
await ctx.report_progress(
{"chat_id": sent.chat.id if sent.chat else None, "message_id": sent.id}
)
@register("forward_saved")
async def forward_saved(ctx: JobContext) -> None:
client = ctx.client
capture = getattr(client, "capture", None)
if client is None or capture is None:
return
params = ctx.job.params
sent, mode = await forward(client, capture, params["chat_id"], params["message_id"])
await ctx.report_progress(
{
"chat_id": sent.chat.id if sent.chat else None,
"message_id": sent.id,
"mode": mode,
}
)
+158
View File
@@ -0,0 +1,158 @@
from io import BytesIO
from typing import Any
from pyrogram import Client, enums
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message, MessageEntity
from userbot.modules.capture.context import CaptureContext
from userbot.modules.capture.ingest import ingest_message
from utils.files import media_file_name
from utils.logging import logger
from utils.read import chats, media
from utils.read.models import EntityView, MessageView
SAVED = "me"
PLAIN = enums.ParseMode.DISABLED
PHOTO_MIMES = ("image/jpeg", "image/png", "image/webp")
class SavedError(Exception):
pass
def _file(data: bytes, name: str) -> BytesIO:
file = BytesIO(data)
file.name = name
return file
def _entity(view: EntityView) -> MessageEntity | None:
kind = enums.MessageEntityType.__members__.get(view.type.upper())
if kind is None or kind is enums.MessageEntityType.TEXT_MENTION:
return None
return MessageEntity(
type=kind,
offset=view.offset,
length=view.length,
url=view.url,
language=view.language,
custom_emoji_id=view.custom_emoji_id,
)
def _entities(view: MessageView) -> list[MessageEntity] | None:
found = [entity for entity in map(_entity, view.entities) if entity is not None]
return found or None
async def _send_file( # noqa: PLR0911
client: Client,
kind: str,
file: BytesIO,
caption: str | None,
entities: list[MessageEntity] | None,
) -> Message | None:
text: dict[str, Any] = {
"caption": caption or "",
"caption_entities": entities,
"parse_mode": PLAIN,
}
match kind:
case "photo":
return await client.send_photo(SAVED, file, **text)
case "video":
return await client.send_video(SAVED, file, **text)
case "voice":
return await client.send_voice(SAVED, file, **text)
case "audio":
return await client.send_audio(SAVED, file, **text)
case "animation":
return await client.send_animation(SAVED, file, **text)
case "video_note":
return await client.send_video_note(SAVED, file)
case "sticker":
return await client.send_sticker(SAVED, file)
return await client.send_document(SAVED, file, file_name=file.name, **text)
def _upload_kind(mime: str | None, *, voice: bool) -> str:
if voice:
return "voice"
if mime in PHOTO_MIMES:
return "photo"
if mime and mime.startswith("video/"):
return "video"
return "document"
async def send_new(
client: Client, ctx: CaptureContext, params: dict[str, Any]
) -> Message:
text = params.get("text") or None
upload = params.get("upload")
if upload is None:
if not text:
msg = "nothing_to_send"
raise SavedError(msg)
sent = await client.send_message(SAVED, text, parse_mode=PLAIN)
else:
data = ctx.storage.staged(upload).read_bytes()
kind = _upload_kind(params.get("mime"), voice=bool(params.get("voice")))
sent = await _send_file(
client, kind, _file(data, params.get("file_name") or upload), text, None
)
ctx.storage.unstage(upload)
return await _keep(client, ctx, sent)
async def _copy(
client: Client, ctx: CaptureContext, view: MessageView
) -> Message | None:
entities = _entities(view)
item = None
if view.has_media and view.web_page is None:
item = await media.get_message_media(
ctx.pool, ctx.account_id, view.chat_id, view.message_id
)
if item is None:
if not view.text:
msg = "nothing_to_copy"
raise SavedError(msg)
return await client.send_message(
SAVED, view.text, entities=entities, parse_mode=PLAIN
)
key = item.storage_key
if not (item.downloaded and key and ctx.storage.exists(key)):
msg = "media_missing"
raise SavedError(msg)
name = media_file_name(item.kind, item.mime, view.message_id, item.file_name)
return await _send_file(
client, item.kind, _file(ctx.storage.get(key), name), view.text, entities
)
async def forward(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> tuple[Message, str]:
view = await chats.get_message(ctx.pool, ctx.account_id, chat_id, message_id)
if view is None:
msg = "not_archived"
raise SavedError(msg)
if view.deleted_at is None and not view.is_self_destruct:
try:
sent = await client.forward_messages(SAVED, chat_id, message_id)
return await _keep(client, ctx, sent), "forwarded"
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
logger.info(f"[yellow]Forward {chat_id}/{message_id} refused: {exc}[/]")
return await _keep(client, ctx, await _copy(client, ctx, view)), "copied"
async def _keep(client: Client, ctx: CaptureContext, sent: Message | None) -> Message:
if sent is None:
msg = "not_sent"
raise SavedError(msg)
await ingest_message(client, ctx, sent)
return sent
@@ -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,
) )
+2 -2
View File
@@ -2,7 +2,7 @@ from pyrogram import Client
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
from userbot.modules.stt.service import transcribe_message from userbot.modules.stt.service import TranscribeError, transcribe_message
from utils.logging import logger from utils.logging import logger
@@ -20,5 +20,5 @@ async def safe_transcribe(
"transcribe", "transcribe",
{"chat_id": chat_id, "message_id": message_id}, {"chat_id": chat_id, "message_id": message_id},
) )
except RPCError as exc: except (TranscribeError, RPCError) as exc:
logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]") logger.warning(f"[yellow]STT failed for {chat_id}/{message_id}: {exc}[/]")
+36 -15
View File
@@ -1,4 +1,5 @@
from pyrogram import Client, raw from pyrogram import Client, raw
from pyrogram.errors import FloodPremiumWait, FloodWait, RPCError
from pyrogram.types import Message from pyrogram.types import Message
from userbot.modules.capture.context import CaptureContext from userbot.modules.capture.context import CaptureContext
@@ -6,6 +7,16 @@ from userbot.modules.media import self_destruct_ttl
from userbot.modules.stt import repository from userbot.modules.stt import repository
from utils.logging import logger from utils.logging import logger
REFUSALS = {
"MSG_VOICE_TOO_LONG": "too_long",
"MSG_VOICE_MISSING": "not_voice",
"PREMIUM_ACCOUNT_REQUIRED": "no_premium",
}
class TranscribeError(Exception):
pass
def is_transcribable(message: Message) -> bool: def is_transcribable(message: Message) -> bool:
if self_destruct_ttl(message) is not None: if self_destruct_ttl(message) is not None:
@@ -26,27 +37,37 @@ def should_transcribe_on_backfill(message: Message, self_id: int | None) -> bool
return not message.unread_media return not message.unread_media
async def _ask(
client: Client, chat_id: int, message_id: int
) -> raw.types.messages.TranscribedAudio:
try:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = "failed"
raise TranscribeError(msg)
return await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
except (FloodWait, FloodPremiumWait):
raise
except RPCError as exc:
logger.warning(f"[yellow]STT refused {chat_id}/{message_id}: {exc}[/]")
raise TranscribeError(REFUSALS.get(exc.ID or "", "failed")) from exc
async def transcribe_message( async def transcribe_message(
client: Client, ctx: CaptureContext, chat_id: int, message_id: int client: Client, ctx: CaptureContext, chat_id: int, message_id: int
) -> None: ) -> None:
peer = await client.resolve_peer(chat_id) result = await _ask(client, chat_id, message_id)
if peer is None:
return
result = await client.invoke(
raw.functions.messages.TranscribeAudio(peer=peer, msg_id=message_id)
)
if result.pending: if result.pending:
logger.info( logger.info(
f"[yellow]STT pending {chat_id}/{message_id} " f"[yellow]STT pending {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]" f"(trial_remains={result.trial_remains_num})[/]"
) )
return return
if result.text: if not result.text:
await repository.set_extracted_text( msg = "empty"
ctx.pool, ctx.account_id, chat_id, message_id, result.text raise TranscribeError(msg)
) await repository.set_extracted_text(
else: ctx.pool, ctx.account_id, chat_id, message_id, result.text
logger.info( )
f"[yellow]STT empty {chat_id}/{message_id} "
f"(trial_remains={result.trial_remains_num})[/]"
)
+5
View File
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}" return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
def sniff_media_kind(head: bytes) -> str:
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
return "video" if is_video else "photo"
def resolve_mime( def resolve_mime(
kind: str | None, mime: str | None, file_name: str | None = None kind: str | None, mime: str | None, file_name: str | None = None
) -> str: ) -> str:
+41 -4
View File
@@ -5,6 +5,7 @@ from enum import StrEnum
import asyncpg import asyncpg
from utils.policy import repository as policy_repository
from utils.read import chats, peers from utils.read import chats, peers
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ( from utils.read.models import (
@@ -45,6 +46,13 @@ LEFT JOIN LATERAL (
) p ON true ) p ON true
""" """
_IS_BOT = """EXISTS (
SELECT 1 FROM peers p
WHERE p.account_id = feed_items.account_id AND p.peer_id = feed_items.chat_id
AND COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
p.raw->>'type' = 'ChatType.BOT', false)
)"""
_UNSEEN = """ _UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted, SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited count(*) FILTER (WHERE kind = 'edited') AS edited
@@ -69,15 +77,25 @@ class FeedKind(StrEnum):
class FeedChatKind(StrEnum): class FeedChatKind(StrEnum):
DM = "dm" DM = "dm"
BOT = "bot"
GROUP = "group" GROUP = "group"
CHANNEL = "channel" CHANNEL = "channel"
_CHAT_KIND_CLAUSES = {
FeedChatKind.DM: f"chat_kind = 'dm' AND NOT {_IS_BOT}",
FeedChatKind.BOT: f"chat_kind = 'dm' AND {_IS_BOT}",
FeedChatKind.GROUP: "chat_kind = 'group'",
FeedChatKind.CHANNEL: "chat_kind = 'channel'",
}
@dataclass(slots=True) @dataclass(slots=True)
class FeedFilter: class FeedFilter:
kind: FeedKind = FeedKind.ALL kind: FeedKind = FeedKind.ALL
chat_id: int | None = None chat_id: int | None = None
chat_kind: FeedChatKind | None = None chat_kind: FeedChatKind | None = None
folder_id: int | None = None
since: datetime | None = None since: datetime | None = None
@@ -90,18 +108,34 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
return _EPOCH + int(micros) * _MICROSECOND, int(key) return _EPOCH + int(micros) * _MICROSECOND, int(key)
async def _folder_chat_ids(
pool: asyncpg.Pool, account_id: int, folder_id: int | None
) -> list[int] | None:
if folder_id is None:
return None
folder = await policy_repository.get_folder(pool, account_id, folder_id)
if folder is None:
return []
return await chats.folder_chat_ids(pool, account_id, folder)
def _where( def _where(
account_id: int, feed: FeedFilter, cursor: str | None account_id: int,
feed: FeedFilter,
folder_chat_ids: list[int] | None,
cursor: str | None,
) -> tuple[str, list[object]]: ) -> tuple[str, list[object]]:
params: list[object] = [account_id] params: list[object] = [account_id]
clauses = ["account_id = $1"] clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES: if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'") clauses.append("kind <> 'profile'")
if feed.chat_kind is not None:
clauses.append(_CHAT_KIND_CLAUSES[feed.chat_kind])
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES} exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = ( conditions = (
("kind = {}", feed.kind.value if exact else None), ("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id), ("chat_id = {}", feed.chat_id),
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None), ("chat_id = ANY({}::bigint[])", folder_chat_ids),
("at >= {}", feed.since), ("at >= {}", feed.since),
) )
for template, value in conditions: for template, value in conditions:
@@ -191,7 +225,9 @@ async def _build_items(
chat=FeedChat( chat=FeedChat(
chat_id=row["chat_id"], chat_id=row["chat_id"],
title=chat.title if chat else None, title=chat.title if chat else None,
kind=row["chat_kind"], kind=FeedChatKind.BOT
if chat and chat.is_bot and row["chat_kind"] == FeedChatKind.DM
else row["chat_kind"],
has_avatar=chat.has_avatar if chat else False, has_avatar=chat.has_avatar if chat else False,
), ),
message_id=row["message_id"], message_id=row["message_id"],
@@ -218,7 +254,8 @@ async def list_feed(
cursor: str | None = None, cursor: str | None = None,
) -> FeedPage: ) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT)) size = max(1, min(limit, MAX_LIMIT))
where, params = _where(account_id, feed, cursor) folder_chat_ids = await _folder_chat_ids(pool, account_id, feed.folder_id)
where, params = _where(account_id, feed, folder_chat_ids, cursor)
params.append(size + 1) params.append(size + 1)
rows = await pool.fetch( rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608 f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
+14 -1
View File
@@ -3,7 +3,7 @@ import json
import asyncpg import asyncpg
from utils.files import media_file_name from utils.files import media_file_name
from utils.read.models import MediaVersionView, MediaView from utils.read.models import MediaPlaceView, MediaVersionView, MediaView
MEDIA_COLS = ( MEDIA_COLS = (
"m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, " "m.id, m.account_id, m.chat_id, m.message_id, m.kind, m.storage_key, "
@@ -23,6 +23,12 @@ LEFT JOIN LATERAL (
_VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at" _VERSION_COLS = "id, kind, storage_key, file_size, mime, observed_at"
_PLACES = """
SELECT account_id, chat_id, message_id, kind, extracted_text FROM media
WHERE storage_key = $1 AND ($2::bigint IS NULL OR account_id = $2)
ORDER BY created_at DESC LIMIT 100
"""
_WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio") _WEB_PAGE_MEDIA_KINDS = ("photo", "video", "animation", "document", "audio")
@@ -100,6 +106,13 @@ async def get_message_media(
return await _web_page_media_stub(pool, account_id, chat_id, message_id) return await _web_page_media_stub(pool, account_id, chat_id, message_id)
async def find_by_content(
pool: asyncpg.Pool, storage_key: str, account_id: int | None = None
) -> list[MediaPlaceView]:
rows = await pool.fetch(_PLACES, storage_key, account_id)
return [MediaPlaceView(**dict(row)) for row in rows]
async def get_media_versions( async def get_media_versions(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> list[MediaVersionView]: ) -> list[MediaVersionView]:
+8
View File
@@ -275,6 +275,14 @@ class MediaView(BaseModel):
file_name: str | None = None file_name: str | None = None
class MediaPlaceView(BaseModel):
account_id: int
chat_id: int
message_id: int
kind: str
extracted_text: str | None
class MediaVersionView(BaseModel): class MediaVersionView(BaseModel):
id: int id: int
kind: str kind: str
+14 -1
View File
@@ -76,7 +76,7 @@ async def get_stories(
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608 "SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
"storage_key, downloaded, views, pinned, deleted FROM stories " "storage_key, downloaded, views, pinned, deleted FROM stories "
f"WHERE {where} " f"WHERE {where} "
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} " f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
f"OFFSET ${len(params)}", f"OFFSET ${len(params)}",
*params, *params,
) )
@@ -95,3 +95,16 @@ async def get_story(
story_id, story_id,
) )
return StoryView(**dict(row)) if row else None return StoryView(**dict(row)) if row else None
async def set_story_kind(
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
) -> None:
await pool.execute(
"UPDATE stories SET media_kind = $4 "
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
account_id,
peer_id,
story_id,
kind,
)
+65
View File
@@ -0,0 +1,65 @@
import re
from collections.abc import Iterator
from typing import Any
from utils.search.models import Found, FoundWhere
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
_HEADS: dict[str, FoundWhere] = {
"text": "text",
"caption": "text",
"web_page": "preview",
}
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
type Path = tuple[str | int, ...]
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
if isinstance(node, dict):
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
elif isinstance(node, list):
items = enumerate(node)
else:
return
for key, value in items:
if isinstance(value, dict | list):
yield from _scalars(value, (*path, key))
elif isinstance(value, str | int | float) and not isinstance(value, bool):
yield (*path, key), str(value), node
def _where(path: Path) -> FoundWhere:
head, leaf = str(path[0]), str(path[-1])
if head == "reply_markup":
return "button"
if leaf in _LEAVES:
return _LEAVES[leaf]
if head.startswith("forward"):
return "forward"
if head.startswith("reply_to"):
return "reply"
return _HEADS.get(head, "data")
def find(raw: dict[str, Any], needle: str) -> Found | None:
folded = needle.casefold()
fallback = None
for path, value, owner in _scalars(raw):
if folded not in value.casefold() or ENUM.match(value):
continue
where = _where(path)
button = owner.get("text") if isinstance(owner, dict) else None
found = Found(
where=where,
path=".".join(map(str, path)),
value=value,
button=button if where == "button" else None,
)
if where != "text":
return found
fallback = fallback or found
return fallback
+17 -4
View File
@@ -10,7 +10,10 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
_MICROSECOND = timedelta(microseconds=1) _MICROSECOND = timedelta(microseconds=1)
ChatKind = Literal["private", "group", "channel"] ChatKind = Literal["private", "group", "channel"]
HitSource = Literal["text", "stt", "version"] HitSource = Literal["text", "stt", "version", "data"]
FoundWhere = Literal[
"text", "button", "link", "forward", "reply", "file", "preview", "data"
]
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str: def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
@@ -34,6 +37,7 @@ class SearchFilters(BaseModel):
date_from: datetime | None = None date_from: datetime | None = None
date_to: datetime | None = None date_to: datetime | None = None
regex: str | None = None regex: str | None = None
everywhere: bool = False
cursor: str | None = None cursor: str | None = None
limit: int = DEFAULT_LIMIT limit: int = DEFAULT_LIMIT
@@ -45,6 +49,13 @@ class SearchFilters(BaseModel):
return value return value
class Found(BaseModel):
where: FoundWhere
path: str
value: str
button: str | None = None
class SearchHit(BaseModel): class SearchHit(BaseModel):
chat_id: int chat_id: int
message_id: int message_id: int
@@ -56,12 +67,14 @@ class SearchHit(BaseModel):
edited_at: datetime | None edited_at: datetime | None
deleted_at: datetime | None deleted_at: datetime | None
source: HitSource source: HitSource
extracted_text: str | None extracted_text: str | None = None
version_text: str | None version_text: str | None = None
version_at: datetime | None version_at: datetime | None = None
found: Found | None = None
class SearchPage(BaseModel): class SearchPage(BaseModel):
hits: list[SearchHit] hits: list[SearchHit]
next_cursor: str | None next_cursor: str | None
terms: list[str] terms: list[str]
scanned_to: datetime | None = None
+95 -3
View File
@@ -1,5 +1,12 @@
import json
import re
from datetime import UTC, datetime, timedelta
from time import monotonic
import asyncpg import asyncpg
from utils.read.message_view import load_raw
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
from utils.search.models import ( from utils.search.models import (
MAX_LIMIT, MAX_LIMIT,
ChatKind, ChatKind,
@@ -10,6 +17,12 @@ from utils.search.models import (
encode_cursor, encode_cursor,
) )
MIN_NEEDLE = 3
_WINDOW = timedelta(days=7)
_BUDGET_SECONDS = 2.5
_BOTTOM = -(2**63)
_LIKE_SPECIAL = re.compile(r"([\\%_])")
_PARSE = """ _PARSE = """
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text, SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery '''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
@@ -76,6 +89,24 @@ _WITH_MEDIA = (
_SOURCES = ("text", "stt", "version") _SOURCES = ("text", "stt", "version")
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
_VALUE_MATCH = (
'strict $.** ? (@.type() == "object").keyvalue() ? ('
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
+ '(@.value.type() == "string" || @.value.type() == "number") && '
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
'@.value.string() like_regex {needle} flag "iq")'
)
_EVERYWHERE = (
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
"FROM messages s WHERE {where} "
"AND CASE WHEN s.raw::text ILIKE {like} "
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
)
class _Binder: class _Binder:
def __init__(self) -> None: def __init__(self) -> None:
@@ -130,9 +161,72 @@ def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage: async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
await conn.execute("SET LOCAL jit = off") await conn.execute("SET LOCAL jit = off")
if filters.everywhere:
return await _everywhere(conn, filters)
return await _search(conn, filters) return await _search(conn, filters)
async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
if filters.kind in {"group", "channel"}:
return await conn.fetchval(_CHANNELS, filters.account_id)
return []
def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
data = dict(row)
raw = load_raw(data.pop("raw"))
return SearchHit(**data, source="data", found=find(raw, needle))
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
needle = (filters.query or "").strip()
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
if len(needle) < MIN_NEEDLE or floor is None:
return SearchPage(hits=[], next_cursor=None, terms=[])
channels = await _channels(conn, filters)
quoted = json.dumps(needle, ensure_ascii=False)
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
path = _VALUE_MATCH.format(needle=quoted)
size = min(filters.limit, MAX_LIMIT)
cursor = filters.cursor
top = decode_cursor(cursor)[0] if cursor else None
hits: list[SearchHit] = []
started = monotonic()
while True:
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
bind = _Binder()
conds = _conditions(
filters.model_copy(update={"cursor": cursor}), channels, bind
)
conds.append(f"s.date >= {bind(lower)}")
if top is not None:
conds.append(f"s.date <= {bind(top)}")
limit = size - len(hits)
sql = _EVERYWHERE.format(
where=" AND ".join(conds),
hidden=bind(list(HIDDEN_KEYS)),
like=bind(f"%{like}%"),
path=bind(path),
limit=bind(limit),
)
account = bind(filters.account_id)
rows = await conn.fetch(
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
)
hits += [_data_hit(row, needle) for row in rows]
if len(rows) >= limit:
last = rows[-1]
top = last["date"]
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
elif lower > floor:
top = lower
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
else:
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage: async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
query = (filters.query or "").strip() query = (filters.query or "").strip()
terms: list[str] = [] terms: list[str] = []
@@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag
return SearchPage(hits=[], next_cursor=None, terms=terms) return SearchPage(hits=[], next_cursor=None, terms=terms)
tsquery = parsed["tsquery"] tsquery = parsed["tsquery"]
channels: list[int] = [] channels = await _channels(conn, filters)
if filters.kind in {"group", "channel"}:
channels = await conn.fetchval(_CHANNELS, filters.account_id)
bind = _Binder() bind = _Binder()
where = " AND ".join(_conditions(filters, channels, bind)) where = " AND ".join(_conditions(filters, channels, bind))
size = min(filters.limit, MAX_LIMIT) size = min(filters.limit, MAX_LIMIT)
+19
View File
@@ -1,7 +1,9 @@
import hashlib import hashlib
from pathlib import Path from pathlib import Path
from uuid import uuid4
TRASH_DIR = ".trash" TRASH_DIR = ".trash"
UPLOADS_DIR = ".uploads"
class ContentAddressedStorage: class ContentAddressedStorage:
@@ -24,12 +26,29 @@ 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()
def url(self, key: str) -> str: def url(self, key: str) -> str:
return str(self._path(key)) return str(self._path(key))
def staged(self, name: str) -> Path:
return self._root / UPLOADS_DIR / Path(name).name
def stage(self, data: bytes) -> str:
name = uuid4().hex
path = self.staged(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return name
def unstage(self, name: str) -> None:
self.staged(name).unlink(missing_ok=True)
def _trash_path(self, key: str) -> Path: def _trash_path(self, key: str) -> Path:
return self._root / TRASH_DIR / key return self._root / TRASH_DIR / key
+12
View File
@@ -95,6 +95,18 @@ export async function request<T>(
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export async function requestForm<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
body: form,
headers: authHeaders(),
method: "POST",
});
if (!response.ok) {
return handleError(response);
}
return response.json() as Promise<T>;
}
function streamUrl(path: string): string { function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : ""; const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`; return `${BASE}${path}${query}`;
+53 -3
View File
@@ -1,4 +1,4 @@
import { request } from "#lib/api/client.js"; import { request, requestForm } from "#lib/api/client.js";
import type { import type {
Account, Account,
Annotation, Annotation,
@@ -284,6 +284,7 @@ export interface FeedQuery {
chat_id?: number; chat_id?: number;
chat_kind?: FeedChatKind; chat_kind?: FeedChatKind;
cursor?: string; cursor?: string;
folder_id?: number;
kind?: FeedKind; kind?: FeedKind;
limit?: number; limit?: number;
} }
@@ -574,11 +575,60 @@ export function fetchMedia(
}); });
} }
export interface TranscribeResult {
error: string | null;
status: "done" | "failed" | "pending";
text: string | null;
}
export function transcribeMedia( export function transcribeMedia(
chatId: number,
messageId: number,
wait: number
): Promise<TranscribeResult> {
return request<TranscribeResult>("/media/transcribe", {
body: {
account_id: accounts.selectedId,
chat_id: chatId,
message_id: messageId,
wait,
},
method: "POST",
});
}
export interface SavedResult {
chat_id: number | null;
error: string | null;
message_id: number | null;
mode: "copied" | "forwarded" | "sent" | null;
status: "done" | "failed" | "pending";
}
export interface SavedDraft {
file?: File;
text?: string;
voice?: boolean;
}
export function sendToSaved(draft: SavedDraft): Promise<SavedResult> {
const form = new FormData();
form.set("account_id", String(accounts.selectedId));
if (draft.text) {
form.set("text", draft.text);
}
if (draft.file) {
form.set("file", draft.file);
form.set("voice", String(Boolean(draft.voice)));
}
return requestForm<SavedResult>("/saved", form);
}
export function forwardToSaved(
chatId: number, chatId: number,
messageId: number messageId: number
): Promise<{ job_id: number }> { ): Promise<SavedResult> {
return request<{ job_id: number }>("/media/transcribe", { return request<SavedResult>("/saved/forward", {
body: { body: {
account_id: accounts.selectedId, account_id: accounts.selectedId,
chat_id: chatId, chat_id: chatId,
+45 -23
View File
@@ -3,8 +3,8 @@ import { getMessageMedia, transcribeMedia } from "#lib/api/endpoints.js";
import type { MediaRef } from "#lib/api/types.js"; import type { MediaRef } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
const TRANSCRIBE_TRIES = 10; const TRANSCRIBE_WAIT = 25;
const TRANSCRIBE_DELAY = 2000; const TRANSCRIBE_TRIES = 3;
export type InlineMedia = export type InlineMedia =
| { | {
@@ -15,7 +15,12 @@ export type InlineMedia =
url: string; url: string;
transcript: string | null; transcript: string | null;
} }
| { state: "not-downloaded"; mediaId: number; kind: string } | {
state: "not-downloaded";
mediaId: number;
kind: string;
transcript: string | null;
}
| { state: "missing" }; | { state: "missing" };
export interface ViewerItem { export interface ViewerItem {
@@ -173,7 +178,12 @@ async function resolve(
return { state: "missing" }; return { state: "missing" };
} }
if (!meta.downloaded) { if (!meta.downloaded) {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
const blob = await requestMedia(meta.id); const blob = await requestMedia(meta.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -187,7 +197,12 @@ async function resolve(
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: meta.kind, mediaId: meta.id, state: "not-downloaded" }; return {
kind: meta.kind,
mediaId: meta.id,
state: "not-downloaded",
transcript: meta.extracted_text,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -200,7 +215,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
return { state: "missing" }; return { state: "missing" };
} }
if (!media.downloaded) { if (!media.downloaded) {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
const blob = await requestMedia(media.id); const blob = await requestMedia(media.id);
if (blob.state === "ready") { if (blob.state === "ready") {
@@ -214,7 +234,12 @@ async function resolveById(media: MediaRef): Promise<InlineMedia> {
}; };
} }
if (blob.state === "not-downloaded") { if (blob.state === "not-downloaded") {
return { kind: media.kind, mediaId: media.id, state: "not-downloaded" }; return {
kind: media.kind,
mediaId: media.id,
state: "not-downloaded",
transcript: null,
};
} }
return { state: "missing" }; return { state: "missing" };
} }
@@ -257,32 +282,29 @@ function patchTranscript(
return; return;
} }
const cached = ready.get(cacheKey(account, chatId, messageId)); const cached = ready.get(cacheKey(account, chatId, messageId));
if (cached?.state === "ready") { if (cached && cached.state !== "missing") {
cached.transcript = text; cached.transcript = text;
} }
} }
export type TranscriptOutcome = { text: string } | { error: string };
export async function requestTranscription( export async function requestTranscription(
chatId: number, chatId: number,
messageId: number messageId: number
): Promise<string | null> { ): Promise<TranscriptOutcome> {
await transcribeMedia(chatId, messageId);
for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) { for (let i = 0; i < TRANSCRIBE_TRIES; i += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose // biome-ignore lint/performance/noAwaitInLoops: each try waits on the server
await new Promise((done) => { const result = await transcribeMedia(chatId, messageId, TRANSCRIBE_WAIT);
setTimeout(done, TRANSCRIBE_DELAY); if (result.status === "done" && result.text) {
}); patchTranscript(chatId, messageId, result.text);
try { return { text: result.text };
const meta = await getMessageMedia(chatId, messageId); }
if (meta.extracted_text) { if (result.status === "failed") {
patchTranscript(chatId, messageId, meta.extracted_text); return { error: result.error ?? "failed" };
return meta.extracted_text;
}
} catch {
return null;
} }
} }
return null; return { error: "pending" };
} }
export function loadInlineMedia( export function loadInlineMedia(
+22 -2
View File
@@ -7,7 +7,24 @@ export type PolicyScopeType =
| "default_channel" | "default_channel"
| "folder" | "folder"
| "chat"; | "chat";
export type SearchSource = "text" | "stt" | "version"; export type SearchSource = "text" | "stt" | "version" | "data";
export type FoundWhere =
| "text"
| "button"
| "link"
| "forward"
| "reply"
| "file"
| "preview"
| "data";
export interface SearchFound {
button: string | null;
path: string;
value: string;
where: FoundWhere;
}
export type SearchChatKind = "private" | "group" | "channel"; export type SearchChatKind = "private" | "group" | "channel";
export type JobStatus = export type JobStatus =
@@ -228,7 +245,7 @@ export interface MessageVersion {
} }
export type FeedKind = "all" | "deleted" | "edited" | "profile"; export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel"; export type FeedChatKind = "dm" | "bot" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
chat_id: number; chat_id: number;
@@ -289,6 +306,7 @@ export interface SearchHit {
deleted_at: string | null; deleted_at: string | null;
edited_at: string | null; edited_at: string | null;
extracted_text: string | null; extracted_text: string | null;
found: SearchFound | null;
has_media: boolean; has_media: boolean;
media_kind: string | null; media_kind: string | null;
message_id: number; message_id: number;
@@ -302,6 +320,7 @@ export interface SearchHit {
export interface SearchPage { export interface SearchPage {
hits: SearchHit[]; hits: SearchHit[];
next_cursor: string | null; next_cursor: string | null;
scanned_to: string | null;
terms: string[]; terms: string[];
} }
@@ -312,6 +331,7 @@ export interface SearchQuery {
date_to?: string; date_to?: string;
deleted?: boolean; deleted?: boolean;
edited?: boolean; edited?: boolean;
everywhere?: boolean;
has_media?: boolean; has_media?: boolean;
kind?: SearchChatKind; kind?: SearchChatKind;
limit?: number; limit?: number;
+49 -2
View File
@@ -8,14 +8,16 @@
jumboEmojiCount, jumboEmojiCount,
linkHref, linkHref,
} from "#lib/format/entities.js"; } from "#lib/format/entities.js";
import { markText } from "#lib/format/search.js";
interface Props { interface Props {
entities: EntityView[]; entities: EntityView[];
marks?: string[];
own?: boolean; own?: boolean;
text: string; text: string;
} }
let { text, entities, own = false }: Props = $props(); let { text, entities, marks = [], own = false }: Props = $props();
const nodes = $derived(buildEntityTree(text, entities)); const nodes = $derived(buildEntityTree(text, entities));
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0); const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
@@ -32,13 +34,29 @@
)} )}
{#each items as node, i (i)} {#each items as node, i (i)}
{#if node.kind === "text"} {#if node.kind === "text"}
{node.text} {@render plain(node.text)}
{:else} {:else}
{@render entity(node)} {@render entity(node)}
{/if} {/if}
{/each} {/each}
{/snippet} {/snippet}
{#snippet plain(
value: string
)}
{#if marks.length === 0}
{value}
{:else}
{#each markText(value, marks) as segment, i (i)}
{#if segment.hit}
<mark class="found" class:own>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}
{/if}
{/snippet}
{#snippet entity( {#snippet entity(
node: EntityNode node: EntityNode
)} )}
@@ -186,6 +204,35 @@
background-color: var(--color-primary-tint); background-color: var(--color-primary-tint);
} }
.found {
padding: 0;
border-radius: 0.25rem;
color: inherit;
background-color: transparent;
animation: found-flash 3s ease;
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
&.own {
--found-color: color-mix(
in srgb,
var(--color-own-links) 35%,
transparent
);
}
}
@keyframes found-flash {
0%,
70% {
background-color: var(--found-color);
}
100% {
background-color: transparent;
}
}
.spoiler { .spoiler {
cursor: pointer; cursor: pointer;
@@ -2,8 +2,10 @@
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 { t } from "#lib/i18n/locale.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { page } from "$app/state";
interface Tab { interface Tab {
id: number | null; id: number | null;
@@ -93,6 +95,12 @@
</button> </button>
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem
icon="recent"
onselect={() => feed.open(page.url, tab.id)}
>
{t("feed.folder")}
</ContextMenuItem>
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}> <ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
{t("settings.policy")} {t("settings.policy")}
</ContextMenuItem> </ContextMenuItem>
@@ -1,13 +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"; 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;
@@ -27,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>
@@ -42,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 ? t("inline.copied") : 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}
@@ -104,6 +109,27 @@
} }
} }
.found {
animation: found-flash 3s ease;
}
@keyframes found-flash {
0%,
70% {
background-color: color-mix(
in srgb,
var(--color-primary) 30%,
var(--color-message-reaction)
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
100% {
background-color: var(--color-message-reaction);
box-shadow: inset 0 0 0 1px transparent;
}
}
.label { .label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -12,6 +12,7 @@
import Reactions from "#lib/components/Reactions.svelte"; import Reactions from "#lib/components/Reactions.svelte";
import ReplyHeader from "#lib/components/ReplyHeader.svelte"; import ReplyHeader from "#lib/components/ReplyHeader.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte"; import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import SavedItem from "#lib/components/SavedItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.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";
@@ -19,11 +20,13 @@
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";
import { notes } from "#lib/stores/notes.svelte.js"; import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js"; import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -31,7 +34,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 +50,7 @@
isGroupChat, isGroupChat,
firstInGroup, firstInGroup,
lastInGroup, lastInGroup,
highlighted, highlight,
animate, animate,
onjump, onjump,
onmedia, onmedia,
@@ -55,6 +58,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 +173,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 +250,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 +282,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">
@@ -321,6 +333,13 @@
>{t("thread.toReply")}</ContextMenuItem >{t("thread.toReply")}</ContextMenuItem
> >
{/if} {/if}
{#if savedUi.available && !savedUi.isSavedChat(message.chat_id)}
<SavedItem
chatId={message.chat_id}
copy={Boolean(message.deleted_at || message.is_self_destruct)}
messageId={message.message_id}
/>
{/if}
<CopyItem <CopyItem
done={t("chat.menu.linkCopied")} done={t("chat.menu.linkCopied")}
icon="link" icon="link"
+17 -10
View File
@@ -38,6 +38,7 @@
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360; const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400; const SETTLE_DELAY = 400;
const HIGHLIGHT_MS = 3000;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -57,7 +58,7 @@
let mediaIndex = $state(0); let mediaIndex = $state(0);
let versionsOpen = $state(false); let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null); let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null); let highlight = $state<{ id: number; marks: string[] } | null>(null);
let calendarOpen = $state(false); let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null); let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null); let calendarMonth = $state<{ month: number; year: number } | null>(null);
@@ -229,7 +230,11 @@
return messages.find((m) => holds(m, messageId))?.message_id ?? null; return messages.find((m) => holds(m, messageId))?.message_id ?? null;
} }
function jumpToMessage(messageId: number, smooth = true) { function jumpToMessage(
messageId: number,
smooth = true,
marks: string[] = []
) {
const owner = ownerOf(messageId) ?? messageId; const owner = ownerOf(messageId) ?? messageId;
const target = container?.querySelector<HTMLElement>( const target = container?.querySelector<HTMLElement>(
`[data-message-id="${owner}"]` `[data-message-id="${owner}"]`
@@ -242,13 +247,13 @@
behavior: smooth ? "smooth" : "auto", behavior: smooth ? "smooth" : "auto",
block: "center", block: "center",
}); });
highlightId = owner; highlight = { id: owner, marks };
if (highlightTimer) { if (highlightTimer) {
clearTimeout(highlightTimer); clearTimeout(highlightTimer);
} }
highlightTimer = setTimeout(() => { highlightTimer = setTimeout(() => {
highlightId = null; highlight = null;
}, 1600); }, HIGHLIGHT_MS);
} }
function scrollToBottom() { function scrollToBottom() {
@@ -283,7 +288,7 @@
ui.clearJump(); ui.clearJump();
await loadAround(pending.messageId); await loadAround(pending.messageId);
await tick(); await tick();
jumpToMessage(pending.messageId, false); jumpToMessage(pending.messageId, false, pending.marks);
return; return;
} }
const page = await listMessages(chatId, { const page = await listMessages(chatId, {
@@ -574,14 +579,14 @@
} }
} }
async function goTo(messageId: number) { async function goTo(messageId: number, marks: string[] = []) {
try { try {
const loaded = ownerOf(messageId) !== null; const loaded = ownerOf(messageId) !== null;
if (!loaded) { if (!loaded) {
await loadAround(messageId); await loadAround(messageId);
} }
await tick(); await tick();
jumpToMessage(messageId, loaded); jumpToMessage(messageId, loaded, marks);
} catch { } catch {
toasts.error(t("thread.loadFailed")); toasts.error(t("thread.loadFailed"));
} }
@@ -593,7 +598,7 @@
return; return;
} }
ui.clearJump(); ui.clearJump();
goTo(target.messageId); goTo(target.messageId, target.marks);
}); });
$effect(() => { $effect(() => {
@@ -663,7 +668,9 @@
<MessageBubble <MessageBubble
animate={!suppressAppear} animate={!suppressAppear}
firstInGroup={row.firstInGroup} firstInGroup={row.firstInGroup}
highlighted={highlightId === row.message.message_id} highlight={highlight?.id === row.message.message_id
? highlight.marks
: null}
{isGroupChat} {isGroupChat}
lastInGroup={row.lastInGroup} lastInGroup={row.lastInGroup}
message={row.message} message={row.message}
@@ -167,7 +167,12 @@
url={ready.url} url={ready.url}
/> />
{:else if ready && kind === "video_note"} {:else if ready && kind === "video_note"}
<VideoNote transcript={ready.transcript} url={ready.url} /> <VideoNote
chatId={message.chat_id}
messageId={message.message_id}
transcript={ready.transcript}
url={ready.url}
/>
{:else if ready && kind === "audio"} {:else if ready && kind === "audio"}
<AudioFile {own} title={fileName} url={ready.url} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
@@ -210,6 +215,27 @@
<Icon name="document" size="1.25rem" /> <Icon name="document" size="1.25rem" />
<span>{label}</span> <span>{label}</span>
</button> </button>
{:else if media?.state === "not-downloaded" && kind === "voice"}
<VoiceMessage
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
{own}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && kind === "video_note"}
<VideoNote
chatId={message.chat_id}
length={ref?.duration}
messageId={message.message_id}
ondownload={queue}
transcript={media.transcript}
url={null}
{waiting}
/>
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={waiting ? "timer" : "download"} size="1.5rem" /> <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
@@ -0,0 +1,464 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
const VOICE_NAME = /\.(ogg|oga|opus)$/i;
const MAX_INPUT_HEIGHT = 240;
let text = $state("");
let sending = $state(false);
let failure = $state<string | null>(null);
let file = $state<File | null>(null);
let caption = $state("");
let voice = $state(false);
let picker = $state<HTMLInputElement>();
let input = $state<HTMLTextAreaElement>();
const dialogOpen = $derived(file !== null);
const isAudio = $derived(
file !== null &&
(file.type.startsWith("audio/") || VOICE_NAME.test(file.name))
);
const preview = $derived(
file?.type.startsWith("image/") ? URL.createObjectURL(file) : null
);
$effect(() => {
const url = preview;
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
});
function fit() {
if (!input) {
return;
}
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
async function sendText() {
const message = text.trim();
if (!message || sending) {
return;
}
sending = true;
const result = await savedUi.send({ text: message });
sending = false;
failure = result.ok ? null : t(result.label);
if (result.ok) {
text = "";
requestAnimationFrame(fit);
}
}
async function sendFile() {
if (!file || sending) {
return;
}
sending = true;
const result = await savedUi.send({
file,
text: caption.trim(),
voice: isAudio && voice,
});
sending = false;
failure = result.ok ? null : t(result.label);
if (result.ok) {
file = null;
caption = "";
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
sendText();
}
}
function onpick() {
const picked = picker?.files?.[0] ?? null;
if (picker) {
picker.value = "";
}
if (!picked) {
return;
}
file = picked;
caption = text.trim();
text = "";
voice = VOICE_NAME.test(picked.name) || picked.type === "audio/ogg";
requestAnimationFrame(fit);
}
function onOpenChange(open: boolean) {
if (!(open || sending)) {
failure = null;
if (!text) {
text = caption;
}
file = null;
caption = "";
requestAnimationFrame(fit);
}
}
</script>
<div class="SavedComposer">
{#if failure && !file}
<p class="failure">{failure}</p>
{/if}
<div class="field">
<textarea
aria-label={t("saved.message")}
oninput={fit}
{onkeydown}
placeholder={t("saved.message")}
rows="1"
bind:this={input}
bind:value={text}
></textarea>
<button
aria-label={t("saved.attach")}
class="attach"
onclick={() => picker?.click()}
type="button"
>
<Icon name="attach" size="1.5rem" />
</button>
<input hidden onchange={onpick} type="file" bind:this={picker}>
</div>
<Button
aria-label={t("saved.send")}
class="send"
disabled={!text.trim()}
loading={sending && !file}
onclick={sendText}
round
>
<Icon name="send" size="1.5rem" />
</Button>
</div>
<Dialog.Root {onOpenChange} open={dialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">{t("saved.sendFile")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if preview}
<img alt={file?.name} class="preview" src={preview}>
{/if}
<div class="file">
<span class="tile">
<Icon name={isAudio ? "microphone" : "document"} size="1.5rem" />
</span>
<span class="meta">
<span class="name">{file?.name}</span>
<span class="size">{formatBytes(file?.size ?? null)}</span>
</span>
</div>
<textarea
aria-label={t("saved.caption")}
class="caption"
placeholder={t("saved.caption")}
rows="2"
bind:value={caption}
></textarea>
{#if isAudio}
<button
aria-checked={voice}
class="toggle-row"
onclick={() => {
voice = !voice;
}}
role="switch"
type="button"
>
<span class="label">
<span>{t("saved.asVoice")}</span>
<small>{t("saved.asVoiceHint")}</small>
</span>
<span class="switch" class:on={voice}>
<span class="knob"></span>
</span>
</button>
{/if}
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button disabled={sending} loading={sending} onclick={sendFile} pill>
{t("saved.send")}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.SavedComposer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem max(1rem, calc((100% - var(--messages-container-width)) / 2))
1.25rem;
:global(.send) {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
}
}
.failure {
flex-basis: 100%;
margin: 0;
padding: 0 0.25rem;
font-size: 0.875rem;
color: var(--color-error);
}
.dialog-body .failure {
margin-top: 0.75rem;
padding: 0;
}
.field {
display: flex;
flex: 1;
align-items: flex-end;
min-width: 0;
min-height: 3.5rem;
padding: 0 0.5rem 0 1rem;
border-radius: var(--border-radius-messages);
background-color: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
textarea {
resize: none;
flex: 1;
min-width: 0;
margin: 0;
padding: 1rem 0;
border: 0;
font: inherit;
font-size: 1rem;
line-height: 1.375;
color: var(--color-text);
background: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.attach {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
border: 0;
border-radius: 50%;
color: var(--color-composer-button);
background: transparent;
transition: color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.preview {
display: block;
max-width: 100%;
max-height: 16rem;
margin: 0 auto 1rem;
border-radius: var(--border-radius-default-small);
object-fit: contain;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tile {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.caption {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
&:focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: calc(100% + 2.5rem);
margin: 0.75rem -1.25rem 0;
padding: 0.5rem 1.25rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
display: flex;
flex: 1;
flex-direction: column;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
@media (pointer: coarse) {
.SavedComposer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { menuCloser } from "#lib/components/ui/ContextMenu.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { type SavedOutcome, savedUi } from "#lib/stores/saved.svelte.js";
interface Props {
chatId: number;
copy: boolean;
messageId: number;
}
const { chatId, copy, messageId }: Props = $props();
const DONE_DELAY = 900;
const FAILED_DELAY = 2400;
const close = menuCloser();
let busy = $state(false);
let result = $state<SavedOutcome | null>(null);
const icon = $derived.by(() => {
if (result === null) {
return "forward";
}
return result.ok ? "check" : "warning";
});
async function send() {
if (busy || result !== null) {
return;
}
busy = true;
result = await savedUi.forward(chatId, messageId);
busy = false;
setTimeout(close, result.ok ? DONE_DELAY : FAILED_DELAY);
}
</script>
<ContextMenu.Item class="bg-menu-item" closeOnSelect={false} onSelect={send}>
{#if busy}
<Spinner color="gray" size="1.125rem" />
{:else}
<Icon name={icon} size="1.125rem" />
{/if}
{#if result}
{t(result.label)}
{:else}
{copy ? t("saved.copy") : t("saved.forward")}
{/if}
</ContextMenu.Item>
@@ -16,6 +16,7 @@
import { formatFull, formatListDate } from "#lib/format/datetime.js"; import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { import {
alertAvatar, alertAvatar,
alertHref,
alertLabel, alertLabel,
alertPreview, alertPreview,
alertTitle, alertTitle,
@@ -131,7 +132,7 @@
if (isMobile()) { if (isMobile()) {
ui.resetPanel(); ui.resetPanel();
} }
await goto(alert.path); await goto(alertHref(alert));
} }
async function seeAll() { async function seeAll() {
@@ -1,16 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js"; import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js"; import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
interface Props { interface Props {
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folder?: number | null;
kind: FeedKind; kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void; onchats?: (chats: FeedChatKind | null) => void;
onfolder?: (folder: number | null) => void;
onkind: (kind: FeedKind) => void; onkind: (kind: FeedKind) => void;
} }
let { kind, chats = null, onkind, onchats }: Props = $props(); let {
kind,
chats = null,
folder = null,
onkind,
onchats,
onfolder,
}: Props = $props();
const folderTitle = $derived(
folders.list.find((item) => item.folder_id === folder)?.title ??
t("feed.folder")
);
const KIND_KEYS = { const KIND_KEYS = {
all: "feed.filter.all", all: "feed.filter.all",
@@ -20,6 +36,7 @@
} as const; } as const;
const CHAT_KEYS = { const CHAT_KEYS = {
bot: "feed.chats.bot",
channel: "feed.chats.channel", channel: "feed.chats.channel",
dm: "feed.chats.dm", dm: "feed.chats.dm",
group: "feed.chats.group", group: "feed.chats.group",
@@ -55,6 +72,20 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if folder !== null && onfolder}
<div class="group">
<button
aria-pressed="true"
class="chip on"
onclick={() => onfolder(null)}
type="button"
>
<Icon name="folder" size="0.875rem" />
{folderTitle}
<Icon name="close" size="0.875rem" />
</button>
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -78,6 +109,7 @@
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
gap: 0.25rem;
align-items: center; align-items: center;
height: 1.75rem; height: 1.75rem;
@@ -14,11 +14,12 @@
interface Props { interface Props {
chatId?: number; chatId?: number;
chats?: FeedChatKind | null; chats?: FeedChatKind | null;
folderId?: number | null;
kind: FeedKind; kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void; onopen: (item: FeedItem, messageId: number) => void;
} }
let { kind, chats = null, chatId, onopen }: Props = $props(); let { kind, chats = null, folderId = null, chatId, onopen }: Props = $props();
const PAGE = 30; const PAGE = 30;
const EMPTY_KEYS = { const EMPTY_KEYS = {
@@ -38,6 +39,7 @@
const filter = $derived<FeedQuery>({ const filter = $derived<FeedQuery>({
chat_id: chatId, chat_id: chatId,
chat_kind: chats ?? undefined, chat_kind: chats ?? undefined,
folder_id: folderId ?? undefined,
kind, kind,
limit: PAGE, limit: PAGE,
}); });
@@ -55,12 +55,19 @@
</header> </header>
<FeedFilters <FeedFilters
chats={filter.chats} chats={filter.chats}
folder={filter.folder}
kind={filter.kind} kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })} onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onfolder={(folder) => feed.filter(page.url, { ...filter, folder })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })} onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/> />
<div class="feed-body custom-scroll"> <div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} /> <FeedList
chats={filter.chats}
folderId={filter.folder}
kind={filter.kind}
onopen={open}
/>
</div> </div>
</div> </div>
@@ -88,7 +88,7 @@
padding: 0.1875rem 0; padding: 0.1875rem 0;
&.own { &.own {
--active: var(--color-white); --active: var(--color-accent-own);
--toggle-fg: var(--color-background-own); --toggle-fg: var(--color-background-own);
} }
} }
@@ -1,18 +1,37 @@
<script lang="ts"> <script lang="ts">
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 { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { POSTER_TIME, poster } from "#lib/media/poster.js"; import { POSTER_TIME, poster } from "#lib/media/poster.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
interface Props { interface Props {
chatId: number;
length?: number | null;
messageId: number;
ondownload?: () => void;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, transcript = null }: Props = $props(); let {
url,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let showTranscript = $state(false); const stt = new Transcript(
() => chatId,
() => messageId,
() => transcript
);
const RADIUS = 94; const RADIUS = 94;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
@@ -24,8 +43,21 @@
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const remaining = $derived(Math.max(0, duration - currentTime)); const remaining = $derived(Math.max(0, duration - currentTime));
const shownTime = $derived(
paused && currentTime <= POSTER_TIME ? duration || (length ?? 0) : remaining
);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playRound") : t("ui.pause");
});
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -39,60 +71,71 @@
<div class="RoundVideoWrap"> <div class="RoundVideoWrap">
<button <button
aria-label={paused ? t("media.playRound") : t("ui.pause")} aria-label={toggleLabel}
class="RoundVideo" class="RoundVideo"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
<!-- svelte-ignore a11y_media_has_caption --> {#if url}
<!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions --> <!-- svelte-ignore a11y_media_has_caption -->
<video <!-- biome-ignore lint/a11y/useMediaCaption: archived round video has no captions -->
onended={() => element && releasePlayback(element)} <video
onplay={() => element && claimPlayback(element)} onended={() => element && releasePlayback(element)}
playsinline onplay={() => element && claimPlayback(element)}
preload="auto" playsinline
src={url} preload="auto"
bind:this={element} src={url}
bind:currentTime bind:this={element}
bind:duration bind:currentTime
bind:paused bind:duration
use:poster bind:paused
></video> use:poster
<svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200"> ></video>
<circle <svg aria-hidden="true" class="RoundVideoRing" viewBox="0 0 200 200">
class="RoundVideoProgress" <circle
cx="100" class="RoundVideoProgress"
cy="100" cx="100"
r={RADIUS} cy="100"
stroke-dasharray={CIRCUMFERENCE} r={RADIUS}
stroke-dashoffset={CIRCUMFERENCE * (1 - progress)} stroke-dasharray={CIRCUMFERENCE}
/> stroke-dashoffset={CIRCUMFERENCE * (1 - progress)}
</svg> />
{#if paused} </svg>
<span class="play"><Icon name="large-play" size="2.75rem" /></span> {#if paused}
<span class="play"><Icon name="large-play" size="2.75rem" /></span>
{/if}
{:else}
<span class="blank"></span>
<span class="play fetch">
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
</span>
{/if} {/if}
<span class="badge"> <span class="badge">
<Icon name="microphone" size="0.875rem" /> <Icon name="microphone" size="0.875rem" />
{formatDuration( {formatDuration(shownTime)}
paused && currentTime <= POSTER_TIME ? duration : remaining
)}
</span> </span>
</button> </button>
{#if transcript} <button
<button aria-label={stt.open
class="transcribe" ? t("media.hideTranscription")
onclick={() => { : t("media.showTranscription")}
showTranscript = !showTranscript; class="transcribe"
}} disabled={stt.pending}
type="button" onclick={() => stt.toggle()}
class:active={showTranscript} type="button"
> class:active={stt.open}
>
{#if stt.pending}
<Spinner color="gray" size="1rem" />
{:else}
<Icon name="transcribe" size="1rem" /> <Icon name="transcribe" size="1rem" />
{t("media.transcription")}
</button>
{#if showTranscript}
<div class="transcript">{transcript}</div>
{/if} {/if}
{t("media.transcription")}
</button>
{#if stt.open}
<div class="transcript">{stt.text}</div>
{:else if stt.failure}
<div class="transcript failed">{stt.failure}</div>
{/if} {/if}
</div> </div>
@@ -123,6 +166,10 @@
&.active { &.active {
color: var(--color-primary); color: var(--color-primary);
} }
&:disabled {
cursor: default;
}
} }
.transcript { .transcript {
@@ -133,6 +180,11 @@
color: var(--color-text); color: var(--color-text);
white-space: pre-wrap; white-space: pre-wrap;
} }
.failed {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.RoundVideo { .RoundVideo {
cursor: pointer; cursor: pointer;
@@ -150,6 +202,16 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.blank {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-default-shadow);
}
video { video {
display: block; display: block;
@@ -191,6 +253,13 @@
color: var(--color-white); color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5)); filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
&.fetch {
padding: 0.875rem;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.45);
filter: none;
}
} }
.badge { .badge {
@@ -1,52 +1,39 @@
<script lang="ts"> <script lang="ts">
import { requestTranscription } from "#lib/api/media.js";
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 { formatDuration } from "#lib/format/duration.js"; import { formatDuration } from "#lib/format/duration.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { claimPlayback, releasePlayback } from "#lib/media/playback.js"; import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
import { Transcript } from "#lib/media/transcript.svelte.js";
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js"; import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
interface Props { interface Props {
chatId: number; chatId: number;
length?: number | null;
messageId: number; messageId: number;
ondownload?: () => void;
own: boolean; own: boolean;
transcript?: string | null; transcript?: string | null;
url: string; url: string | null;
waiting?: boolean;
} }
let { url, own, transcript = null, chatId, messageId }: Props = $props(); let {
url,
own,
transcript = null,
chatId,
messageId,
length = null,
ondownload,
waiting = false,
}: Props = $props();
let fetched = $state<string | null>(null); const stt = new Transcript(
let pending = $state(false); () => chatId,
const text = $derived(fetched ?? transcript); () => messageId,
() => transcript
let showTranscript = $state(false); );
async function onTranscribe() {
if (text) {
showTranscript = !showTranscript;
return;
}
if (pending) {
return;
}
pending = true;
try {
const result = await requestTranscription(chatId, messageId);
if (result) {
fetched = result;
showTranscript = true;
} else {
toasts.error(t("media.transcribeFailed"));
}
} catch {
toasts.error(t("media.transcribeFailed"));
} finally {
pending = false;
}
}
let element = $state<HTMLAudioElement>(); let element = $state<HTMLAudioElement>();
let peaks = $state<number[]>(flatWaveform()); let peaks = $state<number[]>(flatWaveform());
let currentTime = $state(0); let currentTime = $state(0);
@@ -54,11 +41,20 @@
let paused = $state(true); let paused = $state(true);
const progress = $derived(duration > 0 ? currentTime / duration : 0); const progress = $derived(duration > 0 ? currentTime / duration : 0);
const toggleLabel = $derived.by(() => {
if (!url) {
return t("media.downloadToArchive");
}
return paused ? t("media.playVoice") : t("ui.pause");
});
const elapsed = $derived( const elapsed = $derived(
paused && currentTime === 0 ? duration : currentTime paused && currentTime === 0 ? duration || (length ?? 0) : currentTime
); );
$effect(() => { $effect(() => {
if (!url) {
return;
}
let active = true; let active = true;
computeWaveform(url) computeWaveform(url)
.then((result) => { .then((result) => {
@@ -73,6 +69,10 @@
}); });
function toggle() { function toggle() {
if (!url) {
ondownload?.();
return;
}
if (!element) { if (!element) {
return; return;
} }
@@ -97,16 +97,20 @@
<div class="VoiceWrap" class:own> <div class="VoiceWrap" class:own>
<div class="Voice"> <div class="Voice">
<button <button
aria-label={paused ? t("media.playVoice") : t("ui.pause")} aria-label={toggleLabel}
class="toggle" class="toggle"
onclick={toggle} onclick={toggle}
type="button" type="button"
> >
<Icon {#if !url}
class={paused ? "nudge" : ""} <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
name={paused ? "play" : "pause"} {:else}
size="1.5rem" <Icon
/> class={paused ? "nudge" : ""}
name={paused ? "play" : "pause"}
size="1.5rem"
/>
{/if}
</button> </button>
<div class="body"> <div class="body">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -122,35 +126,39 @@
<div class="time">{formatDuration(elapsed)}</div> <div class="time">{formatDuration(elapsed)}</div>
</div> </div>
<button <button
aria-label={showTranscript && text aria-label={stt.open
? t("media.hideTranscription") ? t("media.hideTranscription")
: t("media.showTranscription")} : t("media.showTranscription")}
class="transcribe" class="transcribe"
disabled={pending} disabled={stt.pending}
onclick={onTranscribe} onclick={() => stt.toggle()}
type="button" type="button"
class:active={showTranscript && Boolean(text)} class:active={stt.open}
> >
{#if pending} {#if stt.pending}
<Spinner color={own ? "white" : "gray"} size="1rem" /> <Spinner color={own ? "white" : "gray"} size="1rem" />
{:else} {:else}
<Icon name="transcribe" size="1.125rem" /> <Icon name="transcribe" size="1.125rem" />
{/if} {/if}
</button> </button>
<!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions --> {#if url}
<audio <!-- biome-ignore lint/a11y/useMediaCaption: archived voice note has no captions -->
onended={() => element && releasePlayback(element)} <audio
onplay={() => element && claimPlayback(element)} onended={() => element && releasePlayback(element)}
preload="metadata" onplay={() => element && claimPlayback(element)}
src={url} preload="metadata"
bind:this={element} src={url}
bind:currentTime bind:this={element}
bind:duration bind:currentTime
bind:paused bind:duration
></audio> bind:paused
></audio>
{/if}
</div> </div>
{#if text && showTranscript} {#if stt.open}
<div class="transcript">{text}</div> <div class="transcript">{stt.text}</div>
{:else if stt.failure}
<div class="transcript failed">{stt.failure}</div>
{/if} {/if}
</div> </div>
@@ -163,11 +171,11 @@
--transcribe-bg: var(--color-primary-tint); --transcribe-bg: var(--color-primary-tint);
&.own { &.own {
--active: var(--color-white); --active: var(--color-accent-own);
--inactive: rgba(255, 255, 255, 0.45); --inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent);
--toggle-fg: var(--color-background-own); --toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75); --voice-meta: var(--color-message-meta-own);
--transcribe-bg: rgba(255, 255, 255, 0.2); --transcribe-bg: var(--color-voice-transcribe-button-own);
} }
} }
@@ -217,6 +225,11 @@
color: var(--color-text); color: var(--color-text);
white-space: pre-wrap; white-space: pre-wrap;
} }
.failed {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.toggle { .toggle {
cursor: pointer; cursor: pointer;
@@ -131,6 +131,7 @@
kind: item.kind, kind: item.kind,
mediaId: item.id, mediaId: item.id,
state: "not-downloaded", state: "not-downloaded",
transcript: null,
}; };
loadMediaItem({ loadMediaItem({
downloaded: item.downloaded, downloaded: item.downloaded,
@@ -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}
+84 -27
View File
@@ -1,8 +1,12 @@
import type { InlineButton } from "#lib/api/types.js";
export interface Segment { export interface Segment {
hit: boolean; hit: boolean;
text: string; text: string;
} }
type Range = [number, number];
const WORD = /[\p{L}\p{N}_]+/gu; const WORD = /[\p{L}\p{N}_]+/gu;
const SPACE = /\s/; const SPACE = /\s/;
const PREFIX_MIN = 3; const PREFIX_MIN = 3;
@@ -20,39 +24,92 @@ function matches(word: string, terms: string[]): boolean {
); );
} }
function windowed(text: string, terms: string[]): string { function wordRanges(text: string, terms: string[]): Range[] {
for (const match of text.matchAll(WORD)) { return [...text.matchAll(WORD)]
if (!matches(match[0], terms)) { .filter((match) => matches(match[0], terms))
continue; .map((match) => [match.index, match.index + match[0].length]);
}
if (match.index <= REACH) {
return text;
}
let start = match.index - LEAD;
while (start > 0 && !SPACE.test(text[start - 1])) {
start -= 1;
}
return `…${text.slice(start)}`;
}
return text;
} }
export function highlight(text: string, terms: string[]): Segment[] { function markRanges(text: string, marks: string[]): Range[] {
const shown = windowed(text, terms); const folded = fold(text);
const segments: Segment[] = []; if (folded.length !== text.length) {
let last = 0; return [];
for (const match of shown.matchAll(WORD)) { }
if (!matches(match[0], terms)) { const ranges: Range[] = [];
for (const mark of marks.map(fold)) {
if (mark.length < PREFIX_MIN) {
ranges.push(...wordRanges(text, [mark]));
continue; continue;
} }
if (match.index > last) { for (let at = folded.indexOf(mark); at !== -1; ) {
segments.push({ hit: false, text: shown.slice(last, match.index) }); ranges.push([at, at + mark.length]);
at = folded.indexOf(mark, at + mark.length);
} }
segments.push({ hit: true, text: match[0] });
last = match.index + match[0].length;
} }
if (last < shown.length) { ranges.sort((a, b) => a[0] - b[0]);
segments.push({ hit: false, text: shown.slice(last) }); const merged: Range[] = [];
for (const [start, end] of ranges) {
const last = merged.at(-1);
if (last && start <= last[1]) {
last[1] = Math.max(last[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
function split(text: string, ranges: Range[]): Segment[] {
const segments: Segment[] = [];
let last = 0;
for (const [start, end] of ranges) {
if (start > last) {
segments.push({ hit: false, text: text.slice(last, start) });
}
segments.push({ hit: true, text: text.slice(start, end) });
last = end;
}
if (last < text.length) {
segments.push({ hit: false, text: text.slice(last) });
} }
return segments; return segments;
} }
function windowed(text: string, ranges: Range[]): Segment[] {
const first = ranges[0]?.[0];
if (first === undefined || first <= REACH) {
return split(text, ranges);
}
const cut = first - LEAD;
let start = cut;
while (start > 0 && !SPACE.test(text[start - 1])) {
start -= 1;
}
if (start === 0) {
start = cut;
}
const shifted = ranges.map(([a, b]): Range => [a - start, b - start]);
return [{ hit: false, text: "…" }, ...split(text.slice(start), shifted)];
}
export function highlight(text: string, terms: string[]): Segment[] {
return windowed(text, wordRanges(text, terms));
}
export function highlightMarks(text: string, marks: string[]): Segment[] {
return windowed(text, markRanges(text, marks));
}
export function markText(text: string, marks: string[]): Segment[] {
return split(text, markRanges(text, marks));
}
export function hasMark(text: string | null, marks: string[]): boolean {
return text !== null && markRanges(text, marks).length > 0;
}
export function buttonHasMark(button: InlineButton, marks: string[]): boolean {
return [button.text, button.data, button.url].some((value) =>
hasMark(value, marks)
);
}
+27
View File
@@ -9,6 +9,7 @@ import type {
} from "#lib/api/watches.js"; } from "#lib/api/watches.js";
import { formatTime } from "#lib/format/datetime.js"; import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js"; import { locale, t } from "#lib/i18n/locale.svelte.js";
import { MARK_PARAM } from "#lib/stores/feed.svelte.js";
export const KIND_ICONS: Record<WatchKind, string> = { export const KIND_ICONS: Record<WatchKind, string> = {
keyword: "search", keyword: "search",
@@ -162,6 +163,32 @@ export function alertTitle(alert: AlertItem): string {
return personName(alert.peer); return personName(alert.peer);
} }
function alertMarks(alert: AlertItem): string[] {
const pattern = patternOf(alert.watch.params);
if (alert.watch.kind !== "keyword" || !pattern) {
return [];
}
if (!alert.watch.params.regex) {
return [pattern];
}
try {
const found = (alert.message?.text ?? "").matchAll(
new RegExp(pattern, "gi")
);
return [...new Set([...found].map((match) => match[0]).filter(Boolean))];
} catch {
return [];
}
}
export function alertHref(alert: AlertItem): string {
const url = new URL(alert.path, "http://app");
for (const mark of alertMarks(alert)) {
url.searchParams.append(MARK_PARAM, mark);
}
return `${url.pathname}${url.search}`;
}
export function alertLabel(alert: AlertItem): string { export function alertLabel(alert: AlertItem): string {
if (alert.watch.name) { if (alert.watch.name) {
return alert.watch.name; return alert.watch.name;
+2
View File
@@ -15,6 +15,7 @@ import { policy } from "#lib/i18n/messages/policy.js";
import { presence } from "#lib/i18n/messages/presence.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 { saved } from "#lib/i18n/messages/saved.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";
@@ -45,6 +46,7 @@ export const messages = {
...presence, ...presence,
...profile, ...profile,
...read, ...read,
...saved,
...search, ...search,
...service, ...service,
...settings, ...settings,
+2
View File
@@ -59,6 +59,7 @@ export const feed = {
}, },
}, },
"feed.changes": { en: "Changes", ru: "Изменения" }, "feed.changes": { en: "Changes", ru: "Изменения" },
"feed.chats.bot": { en: "Bots", ru: "Боты" },
"feed.chats.channel": { en: "Channels", ru: "Каналы" }, "feed.chats.channel": { en: "Channels", ru: "Каналы" },
"feed.chats.dm": { en: "Private", ru: "Личка" }, "feed.chats.dm": { en: "Private", ru: "Личка" },
"feed.chats.group": { en: "Groups", ru: "Группы" }, "feed.chats.group": { en: "Groups", ru: "Группы" },
@@ -92,6 +93,7 @@ export const feed = {
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" }, "feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.filter.profile": { en: "Profiles", ru: "Профили" }, "feed.filter.profile": { en: "Profiles", ru: "Профили" },
"feed.folder": { en: "Folder feed", ru: "Лента папки" },
"feed.lostText": { "feed.lostText": {
en: "The earlier text wasn't saved", en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился", ru: "Прежний текст не сохранился",
+16
View File
@@ -35,10 +35,26 @@ export const media = {
en: "Show transcription", en: "Show transcription",
ru: "Показать расшифровку", ru: "Показать расшифровку",
}, },
"media.transcribeEmpty": {
en: "No speech in the recording",
ru: "В записи не слышно речи",
},
"media.transcribeFailed": { "media.transcribeFailed": {
en: "Could not transcribe", en: "Could not transcribe",
ru: "Не удалось расшифровать", ru: "Не удалось расшифровать",
}, },
"media.transcribeNoPremium": {
en: "Transcription needs Telegram Premium",
ru: "Для расшифровки нужен Telegram Premium",
},
"media.transcribePending": {
en: "Telegram is still transcribing, try again shortly",
ru: "Telegram ещё расшифровывает, нажмите чуть позже",
},
"media.transcribeTooLong": {
en: "Too long for Telegram to transcribe",
ru: "Слишком длинное для расшифровки в Telegram",
},
"media.transcription": { en: "Transcription", ru: "Расшифровка" }, "media.transcription": { en: "Transcription", ru: "Расшифровка" },
"media.unit.B": { en: "B", ru: "Б" }, "media.unit.B": { en: "B", ru: "Б" },
"media.unit.GB": { en: "GB", ru: "ГБ" }, "media.unit.GB": { en: "GB", ru: "ГБ" },
+36
View File
@@ -0,0 +1,36 @@
import type { Messages } from "#lib/i18n/types.js";
export const saved = {
"saved.asVoice": { en: "As a voice message", ru: "Голосовым сообщением" },
"saved.asVoiceHint": {
en: "Sent exactly as is, so give it OGG/Opus",
ru: "Уходит как есть, нужен OGG/Opus",
},
"saved.attach": { en: "Attach a file", ru: "Прикрепить файл" },
"saved.caption": { en: "Caption", ru: "Подпись" },
"saved.copied": {
en: "Copy sent to Saved Messages",
ru: "Копия в Избранном",
},
"saved.copy": { en: "Copy to Saved Messages", ru: "Копию в Избранное" },
"saved.failed": {
en: "Could not send to Saved Messages",
ru: "Не удалось отправить в Избранное",
},
"saved.forward": { en: "To Saved Messages", ru: "В Избранное" },
"saved.forwarded": {
en: "Forwarded to Saved Messages",
ru: "Переслано в Избранное",
},
"saved.mediaMissing": {
en: "The file is not in the archive, there is nothing to copy",
ru: "Файла нет в архиве, копировать нечего",
},
"saved.message": { en: "Message", ru: "Сообщение" },
"saved.pending": {
en: "Telegram is still sending, it will show up shortly",
ru: "Telegram ещё отправляет, сообщение скоро появится",
},
"saved.send": { en: "Send", ru: "Отправить" },
"saved.sendFile": { en: "Send a file", ru: "Отправить файл" },
} satisfies Messages;
+26
View File
@@ -13,10 +13,36 @@ export const search = {
"search.clear": { en: "Clear", ru: "Очистить" }, "search.clear": { en: "Clear", ru: "Очистить" },
"search.deleted": { en: "Deleted", ru: "Удалённые" }, "search.deleted": { en: "Deleted", ru: "Удалённые" },
"search.edited": { en: "Edited", ru: "Изменённые" }, "search.edited": { en: "Edited", ru: "Изменённые" },
"search.everywhere": { en: "Search everywhere", ru: "Искать везде" },
"search.everywhereHint": {
en: "In buttons, links and message data",
ru: "В кнопках, ссылках и данных сообщений",
},
"search.everywhereNothing": {
en: "Nothing else anywhere",
ru: "Больше нигде не нашлось",
},
"search.everywhereScanned": {
en: "Searching, back to {date}",
ru: "Ищу, проверено до {date}",
},
"search.everywhereScanning": {
en: "Searching everywhere",
ru: "Ищу везде",
},
"search.everywhereSection": { en: "Everywhere", ru: "Везде" },
"search.failed": { "search.failed": {
en: "Search did not respond", en: "Search did not respond",
ru: "Поиск не ответил", ru: "Поиск не ответил",
}, },
"search.found.button": { en: "Button “{text}”", ru: "Кнопка «{text}»" },
"search.found.data": { en: "Message data", ru: "Данные сообщения" },
"search.found.file": { en: "File name", ru: "Имя файла" },
"search.found.forward": { en: "Forwarded from", ru: "Переслано от" },
"search.found.link": { en: "Link", ru: "Ссылка" },
"search.found.preview": { en: "Link preview", ru: "Превью ссылки" },
"search.found.reply": { en: "Reply", ru: "Ответ" },
"search.found.text": { en: "Text", ru: "Текст" },
"search.from": { en: "From", ru: "От кого" }, "search.from": { en: "From", ru: "От кого" },
"search.fromName": { en: "Name or @username", ru: "Имя или @username" }, "search.fromName": { en: "Name or @username", ru: "Имя или @username" },
"search.inChat": { en: "Search this chat", ru: "Поиск в чате" }, "search.inChat": { en: "Search this chat", ru: "Поиск в чате" },
@@ -0,0 +1,66 @@
import { requestTranscription } from "#lib/api/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
const REFUSALS: Record<string, MessageKey> = {
empty: "media.transcribeEmpty",
no_premium: "media.transcribeNoPremium",
pending: "media.transcribePending",
too_long: "media.transcribeTooLong",
};
export class Transcript {
readonly #chatId: () => number;
readonly #messageId: () => number;
readonly #initial: () => string | null;
failure = $state<string | null>(null);
fetched = $state<string | null>(null);
pending = $state(false);
shown = $state(false);
constructor(
chatId: () => number,
messageId: () => number,
initial: () => string | null
) {
this.#chatId = chatId;
this.#messageId = messageId;
this.#initial = initial;
}
get text(): string | null {
return this.fetched ?? this.#initial();
}
get open(): boolean {
return this.shown && Boolean(this.text);
}
async toggle(): Promise<void> {
if (this.text) {
this.shown = !this.shown;
return;
}
if (this.pending) {
return;
}
this.pending = true;
this.failure = null;
try {
const outcome = await requestTranscription(
this.#chatId(),
this.#messageId()
);
if ("text" in outcome) {
this.fetched = outcome.text;
this.shown = true;
} else {
this.failure = t(REFUSALS[outcome.error] ?? "media.transcribeFailed");
}
} catch {
this.failure = t("media.transcribeFailed");
} finally {
this.pending = false;
}
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import {
type LiveAlertEvent, type LiveAlertEvent,
unseenAlerts, unseenAlerts,
} from "#lib/api/watches.js"; } from "#lib/api/watches.js";
import { alertLine, alertTitle } from "#lib/format/watches.js"; import { alertHref, alertLine, alertTitle } from "#lib/format/watches.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env"; import { browser } from "$app/env";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -49,7 +49,7 @@ function createAlerts() {
}); });
popup.onclick = () => { popup.onclick = () => {
window.focus(); window.focus();
goto(alert.path); goto(alertHref(alert));
popup.close(); popup.close();
}; };
} }
+20 -3
View File
@@ -8,13 +8,21 @@ import { goto } from "$app/navigation";
const LIVE_DELAY = 600; const LIVE_DELAY = 600;
const KIND_PARAM = "feed"; const KIND_PARAM = "feed";
const CHATS_PARAM = "chats"; const CHATS_PARAM = "chats";
const FOLDER_PARAM = "folder";
const MESSAGE_PARAM = "message"; const MESSAGE_PARAM = "message";
export const MARK_PARAM = "mark";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"]; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; export const FEED_CHAT_KINDS: FeedChatKind[] = [
"dm",
"bot",
"group",
"channel",
];
export interface FeedFilter { export interface FeedFilter {
chats: FeedChatKind | null; chats: FeedChatKind | null;
folder: number | null;
kind: FeedKind; kind: FeedKind;
} }
@@ -29,10 +37,12 @@ export function feedFilterOf(url: UrlLike): FeedFilter | null {
return null; return null;
} }
const chats = searchParams.get(CHATS_PARAM); const chats = searchParams.get(CHATS_PARAM);
const folder = Number(searchParams.get(FOLDER_PARAM));
return { return {
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind) chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
? (chats as FeedChatKind) ? (chats as FeedChatKind)
: null, : null,
folder: Number.isInteger(folder) && folder > 0 ? folder : null,
kind: kind as FeedKind, kind: kind as FeedKind,
}; };
} }
@@ -41,12 +51,16 @@ function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
const next = new URL(url.href); const next = new URL(url.href);
next.searchParams.delete(KIND_PARAM); next.searchParams.delete(KIND_PARAM);
next.searchParams.delete(CHATS_PARAM); next.searchParams.delete(CHATS_PARAM);
next.searchParams.delete(FOLDER_PARAM);
next.searchParams.delete(MESSAGE_PARAM); next.searchParams.delete(MESSAGE_PARAM);
if (filter) { if (filter) {
next.searchParams.set(KIND_PARAM, filter.kind); next.searchParams.set(KIND_PARAM, filter.kind);
if (filter.chats) { if (filter.chats) {
next.searchParams.set(CHATS_PARAM, filter.chats); next.searchParams.set(CHATS_PARAM, filter.chats);
} }
if (filter.folder !== null) {
next.searchParams.set(FOLDER_PARAM, String(filter.folder));
}
} }
return next; return next;
} }
@@ -72,6 +86,7 @@ export function messageHref(
export function withoutMessage(url: UrlLike): string { export function withoutMessage(url: UrlLike): string {
const next = new URL(url.href); const next = new URL(url.href);
next.searchParams.delete(MESSAGE_PARAM); next.searchParams.delete(MESSAGE_PARAM);
next.searchParams.delete(MARK_PARAM);
return href(next); return href(next);
} }
@@ -138,8 +153,10 @@ function createFeed() {
unseen = next; unseen = next;
} }
}, },
async open(url: UrlLike) { async open(url: UrlLike, folder: number | null = null) {
const target = href(withFilter(url, { chats: null, kind: "all" })); const target = href(
withFilter(url, { chats: null, folder, kind: "all" })
);
openedHref = target; openedHref = target;
await goto(target, { reset: false }); await goto(target, { reset: false });
}, },
+57
View File
@@ -0,0 +1,57 @@
import {
forwardToSaved,
type SavedDraft,
type SavedResult,
sendToSaved,
} from "#lib/api/endpoints.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
export interface SavedOutcome {
label: MessageKey;
ok: boolean;
}
const REFUSALS: Record<string, MessageKey> = {
media_missing: "saved.mediaMissing",
};
function outcome(result: SavedResult): SavedOutcome {
if (result.status === "pending") {
return { label: "saved.pending", ok: true };
}
if (result.status === "failed") {
return { label: REFUSALS[result.error ?? ""] ?? "saved.failed", ok: false };
}
return {
label: result.mode === "copied" ? "saved.copied" : "saved.forwarded",
ok: true,
};
}
export const savedUi = {
get available(): boolean {
const account = accounts.selected;
return account !== null && !account.is_bot && account.tg_user_id !== null;
},
async forward(chatId: number, messageId: number): Promise<SavedOutcome> {
try {
return outcome(await forwardToSaved(chatId, messageId));
} catch {
return { label: "saved.failed", ok: false };
}
},
isSavedChat(chatId: number): boolean {
return this.available && accounts.selected?.tg_user_id === chatId;
},
async send(draft: SavedDraft): Promise<SavedOutcome> {
try {
return outcome(await sendToSaved(draft));
} catch {
return { label: "saved.failed", ok: false };
}
},
};
+49 -2
View File
@@ -11,6 +11,7 @@ import type {
SearchQuery, SearchQuery,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { MARK_PARAM } from "#lib/stores/feed.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -30,6 +31,7 @@ const DEBOUNCE_MS = 250;
const REMOTE_DELAY_MS = 450; const REMOTE_DELAY_MS = 450;
const CHAT_LIMIT = 30; const CHAT_LIMIT = 30;
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
export const EVERYWHERE_MIN = 3;
const KINDS = new Set<string>(["private", "group", "channel"]); const KINDS = new Set<string>(["private", "group", "channel"]);
const DAY = /^\d{4}-\d{2}-\d{2}$/; const DAY = /^\d{4}-\d{2}-\d{2}$/;
const PARAMS = [ const PARAMS = [
@@ -41,6 +43,7 @@ const PARAMS = [
"from", "from",
"since", "since",
"until", "until",
"everywhere",
] as const; ] as const;
type Param = (typeof PARAMS)[number]; type Param = (typeof PARAMS)[number];
@@ -90,6 +93,7 @@ export function createHitPager() {
let hits = $state<SearchHit[]>([]); let hits = $state<SearchHit[]>([]);
let terms = $state<string[]>([]); let terms = $state<string[]>([]);
let cursor = $state<string | null>(null); let cursor = $state<string | null>(null);
let scannedTo = $state<string | null>(null);
let loading = $state(false); let loading = $state(false);
let appending = $state(false); let appending = $state(false);
let failed = $state(false); let failed = $state(false);
@@ -106,6 +110,7 @@ export function createHitPager() {
hits = request.cursor ? [...hits, ...result.hits] : result.hits; hits = request.cursor ? [...hits, ...result.hits] : result.hits;
({ terms } = result); ({ terms } = result);
cursor = result.next_cursor; cursor = result.next_cursor;
scannedTo = result.scanned_to;
} catch { } catch {
if (current === seq) { if (current === seq) {
failed = true; failed = true;
@@ -122,6 +127,7 @@ export function createHitPager() {
seq += 1; seq += 1;
base = request; base = request;
cursor = null; cursor = null;
scannedTo = null;
failed = false; failed = false;
appending = false; appending = false;
loading = request !== null; loading = request !== null;
@@ -160,12 +166,21 @@ export function createHitPager() {
run(base); run(base);
}, },
run, run,
get scannedTo() {
return scannedTo;
},
get terms() { get terms() {
return terms; return terms;
}, },
}; };
} }
export type HitPager = ReturnType<typeof createHitPager>;
export function hitKey(hit: SearchHit): string {
return `${hit.chat_id}:${hit.message_id}`;
}
function createSearch() { function createSearch() {
let open = $state(false); let open = $state(false);
let draft = $state(""); let draft = $state("");
@@ -177,7 +192,9 @@ function createSearch() {
let lastRequest = ""; let lastRequest = "";
let lastText = ""; let lastText = "";
let seq = 0; let seq = 0;
let lastEverywhere = "";
const messages = createHitPager(); const messages = createHitPager();
const everywhere = createHitPager();
const params = $derived(page.url.searchParams); const params = $derived(page.url.searchParams);
const urlQuery = $derived(params.get("q") ?? ""); const urlQuery = $derived(params.get("q") ?? "");
@@ -192,6 +209,14 @@ function createSearch() {
} }
return JSON.stringify([accounts.selectedId, toRequest(query, filters)]); return JSON.stringify([accounts.selectedId, toRequest(query, filters)]);
}); });
const everywhereRequest = $derived.by(() => {
const query = urlQuery.trim();
if (!params.has("everywhere") || query.length < EVERYWHERE_MIN) {
return "";
}
const deep = { ...toRequest(query, filters), everywhere: true };
return JSON.stringify([accounts.selectedId, deep]);
});
const peerHits = $derived.by(() => { const peerHits = $derived.by(() => {
const shown = new Set(chatHits.map((chat) => chat.chat_id)); const shown = new Set(chatHits.map((chat) => chat.chat_id));
return peerResults.filter((item) => !shown.has(item.chat_id)); return peerResults.filter((item) => !shown.has(item.chat_id));
@@ -272,7 +297,9 @@ function createSearch() {
stopTyping(); stopTyping();
lastRequest = ""; lastRequest = "";
lastText = ""; lastText = "";
lastEverywhere = "";
messages.run(null); messages.run(null);
everywhere.run(null);
runText(""); runText("");
} }
@@ -288,14 +315,21 @@ function createSearch() {
const update = Object.fromEntries(PARAMS.map((key) => [key, null])); const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
write(update).catch(() => undefined); write(update).catch(() => undefined);
}, },
everywhere,
get everywhereRequest() {
return everywhereRequest;
},
get filtered() { get filtered() {
return filtered; return filtered;
}, },
get filters() { get filters() {
return filters; return filters;
}, },
hitHref(chatId: number, messageId: number): string { hitHref(chatId: number, messageId: number, marks: string[]): string {
const next = new URLSearchParams({ message: String(messageId) }); const next = new URLSearchParams({ message: String(messageId) });
for (const mark of marks) {
next.append(MARK_PARAM, mark);
}
for (const key of PARAMS) { for (const key of PARAMS) {
const value = params.get(key); const value = params.get(key);
if (value) { if (value) {
@@ -315,6 +349,9 @@ function createSearch() {
return request; return request;
}, },
reset, reset,
searchEverywhere() {
write({ everywhere: "1" }).catch(() => undefined);
},
setFilter(key: "from" | "kind" | "since" | "until", value: string | null) { setFilter(key: "from" | "kind" | "since" | "until", value: string | null) {
write({ [key]: value }).catch(() => undefined); write({ [key]: value }).catch(() => undefined);
}, },
@@ -329,7 +366,11 @@ function createSearch() {
} }
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; timer = null;
write({ q: draft.trim() ? draft : null }) const same = draft.trim() === urlQuery.trim();
write({
everywhere: same ? params.get("everywhere") : null,
q: draft.trim() ? draft : null,
})
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
if (timer === null) { if (timer === null) {
@@ -352,6 +393,12 @@ function createSearch() {
runText(value); runText(value);
} }
}, },
syncEverywhere(key: string) {
if (key !== lastEverywhere) {
lastEverywhere = key;
everywhere.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
}
},
get text() { get text() {
return text; return text;
}, },
+3 -2
View File
@@ -36,6 +36,7 @@ export interface PanelState {
interface JumpTarget { interface JumpTarget {
chatId: number; chatId: number;
marks: string[];
messageId: number; messageId: number;
} }
@@ -141,8 +142,8 @@ function createUi() {
: null; : null;
}, },
record, record,
requestJump(chatId: number, messageId: number) { requestJump(chatId: number, messageId: number, marks: string[] = []) {
jumpTarget = { chatId, messageId }; jumpTarget = { chatId, marks, messageId };
}, },
resetPanel() { resetPanel() {
panel = null; panel = null;
+6
View File
@@ -159,6 +159,12 @@ 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 {
+11 -2
View File
@@ -2,9 +2,11 @@
import { untrack } from "svelte"; import { untrack } from "svelte";
import ChatHeader from "#lib/components/ChatHeader.svelte"; import ChatHeader from "#lib/components/ChatHeader.svelte";
import MessageList from "#lib/components/MessageList.svelte"; import MessageList from "#lib/components/MessageList.svelte";
import SavedComposer from "#lib/components/SavedComposer.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 { savedUi } from "#lib/stores/saved.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 +29,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 });
}); });
@@ -40,6 +46,9 @@
<div class="chat-body"> <div class="chat-body">
<MessageList {chatId} /> <MessageList {chatId} />
</div> </div>
{#if savedUi.isSavedChat(chatId)}
<SavedComposer />
{/if}
{/key} {/key}
</div> </div>