Compare commits

..
6 Commits
46 changed files with 2900 additions and 408 deletions

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
## Run
@@ -0,0 +1,51 @@
"""message reads
Revision ID: d7a3c5e9f2b1
Revises: f8c2d6e1a4b7
Create Date: 2026-10-19 01:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d7a3c5e9f2b1"
down_revision: str | None = "f8c2d6e1a4b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_READS = """
CREATE TABLE message_reads (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
reader_id bigint NOT NULL,
read_at timestamptz NOT NULL,
source text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, chat_id, message_id, reader_id)
)
"""
_FETCHES = """
CREATE TABLE read_fetches (
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
outcome text NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
retry_at timestamptz NOT NULL,
PRIMARY KEY (account_id, chat_id, message_id)
)
"""
def upgrade() -> None:
op.execute(_READS)
op.execute(_FETCHES)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS read_fetches")
op.execute("DROP TABLE IF EXISTS message_reads")
@@ -0,0 +1,50 @@
"""capture uptime
Revision ID: d8f4a1c6e2b7
Revises: d7a3c5e9f2b1
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f4a1c6e2b7"
down_revision: str | None = "d7a3c5e9f2b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UPTIME = """
CREATE TABLE capture_uptime (
account_id integer NOT NULL,
started_at timestamptz NOT NULL,
seen_at timestamptz NOT NULL,
PRIMARY KEY (account_id, started_at)
)
"""
_BACKFILL = """
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT account_id, min(ts), max(ts)
FROM (
SELECT account_id, ts,
count(*) FILTER (WHERE gap > interval '30 minutes')
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
FROM (
SELECT account_id, ts,
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
FROM presence
) gaps
) stretches
GROUP BY account_id, stretch
"""
def upgrade() -> None:
op.execute(_UPTIME)
op.execute(_BACKFILL)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS capture_uptime")
+60 -1
View File
@@ -20,9 +20,20 @@ from api.mcp.download import (
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from dependencies.container import container
from utils.files import resolve_mime
from utils.read import annotations, chats, media, peers, presence, social, watches
from utils.read import (
annotations,
chats,
media,
peers,
presence,
presence_stats,
social,
watches,
)
from utils.read.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, Page
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters
from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage
@@ -295,6 +306,23 @@ async def get_callbacks(
return _dump(items)
@mcp.tool
async def get_read_info(
account_id: int, chat_id: int, message_id: int
) -> dict[str, Any] | None:
"""When the user's own message was read, from the archive only.
`observed_at` is when the archive saw the chat read up to this message,
`exact_at` the read date Telegram reported for a private chat, `readers`
who read it in a group and when, `listened_at` when a voice or round
video was played. `outcome` is the last Telegram answer: ok, not_read_yet,
user_privacy (they hide read time), your_privacy (the user hides theirs),
chat_too_big, too_old, unavailable, flood or error.
"""
info = await read_info(await _pool(), account_id, chat_id, message_id)
return info.model_dump(mode="json") if info else None
@mcp.tool
async def presence_history(
account_id: int,
@@ -317,6 +345,37 @@ async def presence_history(
)
@mcp.tool(name="presence_stats")
async def presence_stats_tool(
account_id: int,
peer_id: int,
days: int = 30,
tz: str = DEFAULT_TZ,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
"""Time a peer spent online, from status pushes turned into online sessions.
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
the average online minutes for each weekday (0 is Monday) and hour, null
where the archive has no data for that hour. `days` gives online seconds
per day, `gaps` the stretches when capture was down, `sessions` the latest
sessions (online stretches with breaks under 5 minutes), paged by
`limit`/`offset`. `hidden` means the peer hides their last seen time.
"""
pool = await _pool()
if not 1 <= days <= MAX_DAYS:
msg = f"days must be between 1 and {MAX_DAYS}"
raise ToolError(msg)
if not await known_timezone(pool, tz):
msg = f"Unknown time zone: {tz}"
raise ToolError(msg)
stats = await presence_stats.presence_stats(
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
)
return stats.model_dump(mode="json")
@mcp.tool
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
"""Get name/username/avatar change history of a contact."""
+4 -6
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Any
import asyncpg
@@ -58,16 +59,12 @@ class EventHub:
return
account_id = event.get("account_id")
chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence"
account_wide = event.get("kind") == "presence"
targets = [
sub
for sub in self._subscribers
if sub.account_id == account_id
and (
sub.chat_id == chat_id
if scoped
else sub.chat_id is None or sub.chat_id == chat_id
)
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
]
if not targets:
return
@@ -114,6 +111,7 @@ class EventHub:
"type": "receipt",
"chat_id": event["chat_id"],
"read_up_to": event["message_id"],
"read_at": datetime.now(UTC).isoformat(),
}
return None
+7 -2
View File
@@ -4,8 +4,10 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from api.timezone import checked_tz
from utils.read import analytics
from utils.read.models import ResponseStats, VolumeBucket
from utils.read.period import DEFAULT_TZ, MAX_DAYS
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
@@ -18,9 +20,12 @@ async def volume(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
chat_id: ChatId,
days: Annotated[int, Query()] = 90,
days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
tz: Annotated[str, Query()] = DEFAULT_TZ,
) -> list[VolumeBucket]:
return await analytics.message_volume(pool, account_id, chat_id, days=days)
return await analytics.message_volume(
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
)
@router.get("/response-time")
+53 -2
View File
@@ -5,8 +5,17 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import presence
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
from api.timezone import checked_tz
from utils.read import presence, presence_stats
from utils.read.models import (
DEFAULT_LIMIT,
Page,
PresenceHourly,
PresenceSample,
PresenceSession,
PresenceStats,
)
from utils.read.period import DEFAULT_TZ, MAX_DAYS
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
@@ -14,6 +23,8 @@ AccountId = Annotated[int, Query()]
PeerId = Annotated[int, Query()]
DateFrom = Annotated[datetime | None, Query()]
DateTo = Annotated[datetime | None, Query()]
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
Tz = Annotated[str, Query()]
@router.get("")
@@ -54,3 +65,43 @@ async def presence_hourly(
return await presence.presence_hourly(
pool, account_id, peer_id, date_from=date_from, date_to=date_to
)
@router.get("/stats")
async def stats(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: PeerId,
days: Days = 30,
tz: Tz = DEFAULT_TZ,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> PresenceStats:
return await presence_stats.presence_stats(
pool,
account_id,
peer_id,
Page(limit=limit, offset=offset),
days=days,
tz=await checked_tz(pool, tz),
)
@router.get("/sessions")
async def sessions(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_id: PeerId,
days: Days = 30,
tz: Tz = DEFAULT_TZ,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[PresenceSession]:
return await presence_stats.presence_sessions(
pool,
account_id,
peer_id,
Page(limit=limit, offset=offset),
days=days,
tz=await checked_tz(pool, tz),
)
+35 -2
View File
@@ -2,10 +2,13 @@ from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.jobs import enqueue
from utils.read import social
from utils.read.models import CallbackView, LinkView, ReactionView
from utils.read.models import CallbackView, LinkView, ReactionView, ReadInfoView
from utils.read.read_receipts import read_info
router = APIRouter(
prefix="/api/messages/{chat_id}/{message_id}",
@@ -16,6 +19,10 @@ router = APIRouter(
AccountId = Annotated[int, Query()]
class RefreshRequest(BaseModel):
account_id: int
@router.get("/callbacks")
async def message_callbacks(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
@@ -35,3 +42,29 @@ async def message_links(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
) -> list[LinkView]:
return await social.get_links(pool, account_id, chat_id, message_id)
@router.get("/read")
async def message_read(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
) -> ReadInfoView:
info = await read_info(pool, account_id, chat_id, message_id)
if info is None:
raise HTTPException(status_code=404, detail="message not found")
return info
@router.post("/read/refresh")
async def refresh_message_read(
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, body: RefreshRequest
) -> dict[str, int | None]:
info = await read_info(pool, body.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return {"job_id": None}
job_id = await enqueue(
pool,
body.account_id,
"fetch_read_info",
{"chat_id": chat_id, "message_id": message_id},
)
return {"job_id": job_id}
+10
View File
@@ -0,0 +1,10 @@
import asyncpg
from fastapi import HTTPException
from utils.read.period import known_timezone
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
if not await known_timezone(pool, tz):
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
return tz
@@ -7,25 +7,35 @@ from userbot.modules.raw_json import raw_json
from userbot.modules.read_receipts import repository
from utils.events import notify_bg_event
HANDLES = (raw.types.UpdateReadHistoryOutbox,)
HANDLES = (raw.types.UpdateReadHistoryOutbox, raw.types.UpdateReadChannelOutbox)
def _watermark(update: raw.base.Update) -> tuple[int, int] | None:
if isinstance(update, raw.types.UpdateReadHistoryOutbox):
return utils.get_peer_id(update.peer), update.max_id
if isinstance(update, raw.types.UpdateReadChannelOutbox):
peer = raw.types.PeerChannel(channel_id=update.channel_id)
return utils.get_peer_id(peer), update.max_id
return None
async def handle(
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
) -> None:
ctx = client.capture
if ctx is None or not isinstance(update, raw.types.UpdateReadHistoryOutbox):
watermark = _watermark(update)
if ctx is None or watermark is None:
return
chat_id = utils.get_peer_id(update.peer)
chat_id, max_id = watermark
await repository.insert_read(
ctx.pool,
ctx.account_id,
chat_id,
chat_id,
datetime.now(UTC),
update.max_id,
max_id,
raw_json(update),
)
await notify_bg_event(
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=update.max_id
ctx.pool, "receipt", ctx.account_id, chat_id=chat_id, message_id=max_id
)
@@ -5,6 +5,7 @@ from userbot.modules.jobs.handlers import (
fetch_avatar,
fetch_custom_emoji,
fetch_media,
fetch_read_info,
search_peers,
sync_contacts,
sync_dialogs,
@@ -18,6 +19,7 @@ __all__ = [
"fetch_avatar",
"fetch_custom_emoji",
"fetch_media",
"fetch_read_info",
"search_peers",
"sync_contacts",
"sync_dialogs",
@@ -0,0 +1,124 @@
from datetime import UTC, datetime, timedelta
from pyrogram import Client, raw
from pyrogram.errors import (
ChannelInvalid,
ChannelPrivate,
ChatTooBig,
FloodPremiumWait,
FloodWait,
MessageIdInvalid,
MessageNotReadYet,
MessageTooOld,
MsgIdInvalid,
MsgTooOld,
PeerIdInvalid,
RPCError,
UserPrivacyRestricted,
YourPrivacyRestricted,
)
from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register
from userbot.modules.read_receipts.repository import save_fetch, save_reads
from utils.jobs import enqueue
from utils.logging import logger
from utils.policy.models import ChatKind
from utils.read.peers import get_peers
from utils.read.read_receipts import read_info
RETRY = timedelta(seconds=60)
LATER = timedelta(days=1)
Refusal = tuple[tuple[type[Exception], ...], str, timedelta]
REFUSALS: tuple[Refusal, ...] = (
((MessageNotReadYet,), "not_read_yet", RETRY),
((UserPrivacyRestricted,), "user_privacy", RETRY),
((YourPrivacyRestricted,), "your_privacy", LATER),
((ChatTooBig,), "chat_too_big", LATER),
((MsgTooOld, MessageTooOld), "too_old", LATER),
(
(
PeerIdInvalid,
ChannelInvalid,
ChannelPrivate,
MessageIdInvalid,
MsgIdInvalid,
KeyError,
ValueError,
),
"unavailable",
RETRY,
),
)
def _refusal(exc: Exception) -> tuple[str, timedelta]:
for kinds, outcome, retry in REFUSALS:
if isinstance(exc, kinds):
return outcome, retry
return "error", RETRY
def _at(timestamp: int) -> datetime:
return datetime.fromtimestamp(timestamp, UTC)
async def ask_telegram(
client: Client, chat_id: int, message_id: int, *, group: bool
) -> list[tuple[int, datetime]]:
peer = await client.resolve_peer(chat_id)
if peer is None:
msg = f"unknown peer {chat_id}"
raise ValueError(msg)
if group:
participants = await client.invoke(
raw.functions.messages.GetMessageReadParticipants(
peer=peer, msg_id=message_id
)
)
return [(item.user_id, _at(item.date)) for item in participants]
outbox = await client.invoke(
raw.functions.messages.GetOutboxReadDate(peer=peer, msg_id=message_id)
)
return [(chat_id, _at(outbox.date))]
async def _enrich_unknown(ctx: JobContext, chat_id: int, reader_ids: list[int]) -> None:
known = {
peer.peer_id for peer in await get_peers(ctx.pool, ctx.account_id, reader_ids)
}
if set(reader_ids) - known:
await enqueue(ctx.pool, ctx.account_id, "enrich_chat", {"chat_id": chat_id})
@register("fetch_read_info")
async def fetch_read_info(ctx: JobContext) -> None:
client = ctx.client
if client is None:
return
chat_id = ctx.job.params["chat_id"]
message_id = ctx.job.params["message_id"]
info = await read_info(ctx.pool, ctx.account_id, chat_id, message_id)
if info is None or not info.can_fetch:
return
group = info.chat_kind == ChatKind.GROUP
try:
reads = await ask_telegram(client, chat_id, message_id, group=group)
except (FloodWait, FloodPremiumWait) as exc:
wait = exc.value if isinstance(exc.value, int) else 0
retry = max(RETRY, timedelta(seconds=wait))
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "flood", retry)
return
except (RPCError, KeyError, ValueError) as exc:
outcome, retry = _refusal(exc)
if outcome == "error":
logger.warning(f"[yellow]Read info {chat_id}/{message_id}: {exc!r}[/]")
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, outcome, retry)
return
source = "participants" if group else "outbox_date"
await save_reads(ctx.pool, ctx.account_id, chat_id, message_id, source, reads)
await save_fetch(ctx.pool, ctx.account_id, chat_id, message_id, "ok", RETRY)
if group and reads:
await _enrich_unknown(ctx, chat_id, [reader_id for reader_id, _ in reads])
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
import asyncpg
@@ -9,6 +9,26 @@ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (account_id, chat_id, message_id, ts) DO NOTHING
"""
_UPSERT_READ = """
INSERT INTO message_reads
(account_id, chat_id, message_id, reader_id, read_at, source, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (account_id, chat_id, message_id, reader_id) DO UPDATE SET
read_at = EXCLUDED.read_at,
source = EXCLUDED.source,
fetched_at = EXCLUDED.fetched_at
"""
_UPSERT_FETCH = """
INSERT INTO read_fetches
(account_id, chat_id, message_id, outcome, fetched_at, retry_at)
VALUES ($1, $2, $3, $4, now(), now() + $5::interval)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
outcome = EXCLUDED.outcome,
fetched_at = EXCLUDED.fetched_at,
retry_at = EXCLUDED.retry_at
"""
async def insert_read( # noqa: PLR0913
pool: asyncpg.Pool,
@@ -34,3 +54,33 @@ async def insert_listened( # noqa: PLR0913
await pool.execute(
_INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw
)
async def save_reads( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
source: str,
reads: list[tuple[int, datetime]],
) -> None:
await pool.executemany(
_UPSERT_READ,
[
(account_id, chat_id, message_id, reader_id, read_at, source)
for reader_id, read_at in reads
],
)
async def save_fetch( # noqa: PLR0913
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
message_id: int,
outcome: str,
retry_after: timedelta,
) -> None:
await pool.execute(
_UPSERT_FETCH, account_id, chat_id, message_id, outcome, retry_after
)
Whitespace-only changes.
@@ -0,0 +1,16 @@
import asyncpg
_HEARTBEAT = """
WITH extended AS (
UPDATE capture_uptime SET seen_at = now()
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
RETURNING account_id
)
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT $1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM extended)
"""
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_HEARTBEAT, account_id)
+13
View File
@@ -14,6 +14,7 @@ from userbot.modules.capture import CaptureContext, build_capture_context
from userbot.modules.catchup import SavedState, catch_up, read_saved_state
from userbot.modules.client import PyroClient
from userbot.modules.jobs import JobConsumer
from userbot.modules.uptime import repository as uptime
from utils.env import env
from utils.jobs import enqueue
from utils.listen import Listener
@@ -31,6 +32,7 @@ setup_logging()
SESSION_FLUSH_INTERVAL = 60
SESSION_RETRY_INTERVAL = 60
UPTIME_INTERVAL = 60
@dataclass
@@ -171,6 +173,15 @@ async def _retry_sessions(registry: AccountRegistry) -> None:
logger.exception("[red]Session sync failed[/]")
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
while True:
for client in registry.clients:
if client.capture is not None and client.is_connected:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, client.capture.account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
def spawn(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
@@ -220,6 +231,7 @@ async def runner() -> None:
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
flush = asyncio.create_task(_flush_sessions(registry))
retry = asyncio.create_task(_retry_sessions(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try:
await registry.sync()
if not registry.clients:
@@ -230,6 +242,7 @@ async def runner() -> None:
finally:
await _cancel(flush)
await _cancel(retry)
await _cancel(beat)
await listener.stop()
await registry.close()
await container.close()
+20 -12
View File
@@ -4,23 +4,31 @@ import asyncpg
from utils.read.accounts import self_user_id
from utils.read.models import ResponseStats, VolumeBucket
from utils.read.period import period_bounds
_VOLUME = """
WITH counts AS (
SELECT (date AT TIME ZONE $5)::date AS day, count(*) AS total,
count(*) FILTER (WHERE sender_id = $3) AS outgoing
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND date >= $4
GROUP BY 1
)
SELECT local_day::date AS bucket, coalesce(total, 0) AS total,
coalesce(outgoing, 0) AS outgoing
FROM generate_series(($4 AT TIME ZONE $5)::date, (now() AT TIME ZONE $5)::date,
interval '1 day') AS local_day
LEFT JOIN counts ON counts.day = local_day::date
ORDER BY 1
"""
async def message_volume(
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
) -> list[VolumeBucket]:
self_id = await self_user_id(pool, account_id)
rows = await pool.fetch(
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, "
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
"FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
"GROUP BY bucket ORDER BY bucket",
account_id,
chat_id,
self_id,
datetime.now(UTC) - timedelta(days=days),
)
since, _ = await period_bounds(pool, days, tz)
rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
return [
VolumeBucket(
bucket=row["bucket"],
+12 -6
View File
@@ -10,7 +10,7 @@ from utils.read.models import (
MessageView,
Page,
)
from utils.read.read_receipts import read_up_to
from utils.read.read_receipts import read_times, read_up_to
_MESSAGE_COLS = (
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
@@ -285,12 +285,18 @@ async def _apply_read_status(
self_id = await self_user_id(pool, account_id)
if self_id is None:
return
marker = await read_up_to(pool, account_id, chat_id)
if marker is None:
own = [view for view in views if view.sender_id == self_id]
if not own:
return
for view in views:
if view.sender_id == self_id and view.message_id <= marker:
view.read = True
marker = await read_up_to(pool, account_id, chat_id)
times = await read_times(
pool, account_id, chat_id, [(view.message_id, view.date) for view in own]
)
for view in own:
view.read_at = times.get(view.message_id)
view.read = view.read_at is not None or (
marker is not None and view.message_id <= marker
)
def _build_album(
+79 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel
@@ -203,6 +203,7 @@ class MessageView(BaseModel):
is_sticker: bool = False
is_animated_emoji: bool = False
read: bool = False
read_at: datetime | None = None
class MessageVersionView(BaseModel):
@@ -361,8 +362,61 @@ class PresenceHourly(BaseModel):
last_seen: datetime | None
class PresenceSession(BaseModel):
started: datetime
ended: datetime
seconds: float
online_seconds: float
ongoing: bool
class PresenceHour(BaseModel):
weekday: int
hour: int
hours: int
online_minutes: float | None
covered_hours: float
class PresenceDay(BaseModel):
day: date
online_seconds: float
covered_seconds: float
class CoverageGap(BaseModel):
started: datetime
ended: datetime
class PresenceSummary(BaseModel):
online_seconds: float
daily_seconds: float | None
covered_seconds: float
session_count: int
median_session_seconds: float | None
first_online: int | None
last_offline: int | None
last_seen: datetime | None
class PresenceStats(BaseModel):
peer_id: int
tz: str
since: datetime
until: datetime
current: PresenceSample | None
online: bool
hidden: bool
summary: PresenceSummary
week: list[PresenceHour]
days: list[PresenceDay]
gaps: list[CoverageGap]
sessions: list[PresenceSession]
class VolumeBucket(BaseModel):
bucket: datetime
bucket: date
total: int
outgoing: int
incoming: int
@@ -387,6 +441,29 @@ class PeerView(BaseModel):
updated_at: datetime
class ReaderView(BaseModel):
peer_id: int
read_at: datetime
peer: PeerView | None
class ReadInfoView(BaseModel):
chat_id: int
message_id: int
chat_kind: str
own: bool
voice: bool
observed_at: datetime | None
listened_at: datetime | None
exact_at: datetime | None
readers: list[ReaderView]
read: bool
unread: bool
outcome: str | None
fetched_at: datetime | None
can_fetch: bool
class PeerHistoryView(BaseModel):
observed_at: datetime
first_name: str | None
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import asyncpg
DEFAULT_TZ = "UTC"
MAX_DAYS = 365
_timezones: set[str] = set()
async def known_timezone(pool: asyncpg.Pool, tz: str) -> bool:
if not _timezones:
rows = await pool.fetch("SELECT name FROM pg_timezone_names")
_timezones.update(row["name"] for row in rows)
return tz in _timezones
async def period_bounds(
pool: asyncpg.Pool, days: int, tz: str
) -> tuple[datetime, datetime]:
row = await pool.fetchrow(
"SELECT (date_trunc('day', now() AT TIME ZONE $1) "
"- make_interval(days => $2 - 1)) AT TIME ZONE $1 AS since, now() AS until",
tz,
days,
)
return row["since"], row["until"]
+289
View File
@@ -0,0 +1,289 @@
import json
import asyncpg
from utils.read.models import (
CoverageGap,
Page,
PresenceDay,
PresenceHour,
PresenceSession,
PresenceStats,
PresenceSummary,
)
from utils.read.period import period_bounds
from utils.read.presence import current_presence
SECONDS_IN_HOUR = 3600
SECONDS_IN_DAY = 86_400
MIN_CELL_COVERAGE = 1800
MIN_CELL_SHARE = 0.5
STATS_WORK_MEM = "32MB"
FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"}
_SESSIONS = """
marks AS (
SELECT ts, status,
least(next_offline_date, ts + interval '5 minutes',
lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended,
lag(CASE WHEN status = 'online'
THEN least(next_offline_date, ts + interval '5 minutes')
ELSE coalesce(last_online_date, ts) END) OVER w AS previous
FROM presence
WHERE account_id = $1 AND peer_id = $2
AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4
WINDOW w AS (ORDER BY ts)
),
online AS (
SELECT greatest(ts, $3) AS started, ended,
count(*) FILTER (WHERE previous IS NULL
OR ts > previous + interval '5 minutes')
OVER (ORDER BY ts) AS visit
FROM marks
WHERE status = 'online' AND ended > greatest(ts, $3)
),
sessions AS (
SELECT min(started) AS started, max(ended) AS ended,
extract(epoch FROM sum(ended - started))::float8 AS online_seconds
FROM online
GROUP BY visit
)
"""
_SESSION_FIELDS = """
started, ended, extract(epoch FROM ended - started)::float8 AS seconds,
online_seconds, ended >= $4 AS ongoing
"""
_SESSION_PAGE = f"""
WITH {_SESSIONS}
SELECT {_SESSION_FIELDS}
FROM sessions
ORDER BY started DESC
LIMIT $5 OFFSET $6
""" # noqa: S608
_COVERED_HOURS = """
SELECT local_hour,
extract(epoch FROM sum(
least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5)
- greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds
FROM uptime,
generate_series(date_trunc('hour', started AT TIME ZONE $5),
date_trunc('hour', ended AT TIME ZONE $5),
interval '1 hour') AS local_hour
GROUP BY local_hour
"""
_STATS = f"""
WITH {_SESSIONS},
uptime AS (
SELECT greatest(started_at, $3) AS started,
least(seen_at + interval '90 seconds', $4) AS ended
FROM capture_uptime
WHERE account_id = $1 AND started_at < $4
AND seen_at + interval '90 seconds' > $3
),
quarters AS (
SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill
FROM (SELECT started, ended, quarter,
greatest(ended - quarter - interval '15 minutes',
interval '0') AS spill
FROM (SELECT started, ended,
date_bin('15 minutes', started, timestamptz 'epoch') AS quarter
FROM online) binned) split
GROUP BY quarter
),
online_hours AS (
SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour,
extract(epoch FROM sum(share))::float8 AS seconds
FROM (SELECT quarter, inside AS share FROM quarters
UNION ALL
SELECT quarter + interval '15 minutes', spill FROM quarters) shares
GROUP BY 1
),
covered_hours AS ({_COVERED_HOURS}),
hours AS (
SELECT local_hour,
extract(epoch FROM
least((local_hour + interval '1 hour') AT TIME ZONE $5, $4)
- greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span,
coalesce(online.seconds, 0) AS online,
least(3600, greatest(coalesce(covered.seconds, 0),
coalesce(online.seconds, 0))) AS covered
FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5),
date_trunc('hour', $4 AT TIME ZONE $5),
interval '1 hour') AS local_hour
LEFT JOIN online_hours AS online USING (local_hour)
LEFT JOIN covered_hours AS covered USING (local_hour)
),
week AS (
SELECT extract(isodow FROM local_hour)::int - 1 AS weekday,
extract(hour FROM local_hour)::int AS hour,
count(*) AS hours, sum(span) AS span,
sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1, 2
),
days AS (
SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1
),
uptime_reach AS (
SELECT started,
coalesce(max(ended) OVER (ORDER BY started
ROWS BETWEEN UNBOUNDED PRECEDING
AND 1 PRECEDING), $3) AS reached
FROM uptime
),
gaps AS (
SELECT reached AS started, started AS ended FROM uptime_reach
UNION ALL
SELECT coalesce(max(ended), $3), $4 FROM uptime
),
quiet AS (
SELECT hour
FROM week
GROUP BY hour
ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour
LIMIT 1
),
activity_days AS (
SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS started,
(sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS ended,
(($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today
FROM sessions, quiet
),
daily AS (
SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online,
extract(epoch FROM max(ended - started::date))::float8 AS last_offline
FROM activity_days
WHERE started::date < today
GROUP BY started::date
)
SELECT
(SELECT count(*) FROM sessions) AS session_count,
(SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds,
(SELECT sum(covered) FROM hours) AS covered_seconds,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session,
(SELECT max(ended) FROM sessions) AS last_seen,
(SELECT hour FROM quiet) AS day_start,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online)
FROM daily) AS first_online,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline)
FROM daily) AS last_offline,
(SELECT json_agg(json_build_object(
'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span,
'online', online, 'covered', covered))
FROM week) AS week,
(SELECT json_agg(json_build_object(
'day', day, 'online_seconds', online, 'covered_seconds', covered)
ORDER BY day)
FROM days) AS days,
(SELECT json_agg(json_build_object('started', started, 'ended', ended)
ORDER BY started)
FROM gaps WHERE ended - started > interval '5 minutes') AS gaps,
(SELECT json_agg(row_to_json(page) ORDER BY page.started DESC)
FROM (SELECT {_SESSION_FIELDS} FROM sessions
ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions
""" # noqa: S608
def _time_of_day(shifted: float | None, day_start: int | None) -> int | None:
if shifted is None or day_start is None:
return None
return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY
def _week(rows: list[dict]) -> list[PresenceHour]:
cells = {(row["weekday"], row["hour"]): row for row in rows}
week: list[PresenceHour] = []
for weekday in range(7):
for hour in range(24):
row = cells.get(
(weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0}
)
covered = row["covered"]
minutes = (
min(60.0, row["online"] / covered * 60)
if covered > 0
and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE)
else None
)
week.append(
PresenceHour(
weekday=weekday,
hour=hour,
hours=row["hours"],
online_minutes=minutes,
covered_hours=covered / SECONDS_IN_HOUR,
)
)
return week
async def presence_stats( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> PresenceStats:
since, until = await period_bounds(pool, days, tz)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'")
row = await conn.fetchrow(
_STATS,
account_id,
peer_id,
since,
until,
tz,
page.capped_limit,
page.offset,
)
current = await current_presence(pool, account_id, peer_id)
sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")]
covered = row["covered_seconds"] or 0
online = row["online_seconds"]
return PresenceStats(
peer_id=peer_id,
tz=tz,
since=since,
until=until,
current=current,
online=current is not None
and current.status == "online"
and current.next_offline_date is not None
and current.next_offline_date > until,
hidden=row["session_count"] == 0
and current is not None
and current.status in FUZZY_STATUSES,
summary=PresenceSummary(
online_seconds=online,
daily_seconds=online / covered * SECONDS_IN_DAY
if covered >= SECONDS_IN_HOUR
else None,
covered_seconds=covered,
session_count=row["session_count"],
median_session_seconds=row["median_session"],
first_online=_time_of_day(row["first_online"], row["day_start"]),
last_offline=_time_of_day(row["last_offline"], row["day_start"]),
last_seen=row["last_seen"]
or (current.last_online_date if current else None),
),
week=_week(json.loads(row["week"] or "[]")),
days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")],
gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")],
sessions=sessions,
)
async def presence_sessions( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> list[PresenceSession]:
since, until = await period_bounds(pool, days, tz)
rows = await pool.fetch(
_SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset
)
return [PresenceSession(**dict(row)) for row in rows]
+178
View File
@@ -1,5 +1,89 @@
from datetime import UTC, datetime, timedelta
import asyncpg
from utils.policy.models import ChatKind
from utils.read.discover import chat_kind
from utils.read.models import ReaderView, ReadInfoView
from utils.read.peers import get_peers
FETCH_WINDOW = timedelta(days=7)
CLOCK_SKEW = timedelta(minutes=1)
VOICE_KINDS = ["voice", "video_note"]
_READ_TIMES = """
SELECT m.id,
COALESCE(
(SELECT min(mr.read_at) FROM message_reads mr
WHERE mr.account_id = $1 AND mr.chat_id = $2 AND mr.message_id = m.id),
CASE WHEN m.date >= $5 THEN (
SELECT r.ts FROM read_receipts r
WHERE r.account_id = $1 AND r.chat_id = $2 AND r.kind = 'read'
AND r.message_id >= m.id AND r.ts >= m.date - $6::interval
ORDER BY r.ts LIMIT 1
) END
) AS read_at
FROM unnest($3::bigint[], $4::timestamptz[]) AS m(id, date)
"""
_MESSAGE = """
SELECT m.sender_id, m.date, a.tg_user_id, a.is_bot,
EXISTS (
SELECT 1 FROM media md
WHERE md.account_id = m.account_id AND md.chat_id = m.chat_id
AND md.message_id = m.message_id AND md.kind = ANY($4::text[])
) AS voice
FROM messages m
JOIN accounts a ON a.account_id = m.account_id
WHERE m.account_id = $1 AND m.chat_id = $2 AND m.message_id = $3
LIMIT 1
"""
_RECEIPTS = """
SELECT
(SELECT ts FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read'
AND message_id >= $3 AND ts >= $4
ORDER BY ts LIMIT 1) AS observed_at,
(SELECT min(ts) FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'listened'
AND message_id = $3) AS listened_at,
(SELECT max(message_id) FROM read_receipts
WHERE account_id = $1 AND chat_id = $2 AND kind = 'read') AS watermark
"""
_READS = """
SELECT reader_id, read_at, source FROM message_reads
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY read_at
"""
_FETCH = """
SELECT outcome, fetched_at, retry_at FROM read_fetches
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
"""
_INHERITED = """
SELECT outcome FROM read_fetches
WHERE account_id = $1 AND retry_at > now()
AND ((chat_id = $2 AND outcome = 'chat_too_big') OR outcome = 'your_privacy')
LIMIT 1
"""
_capture_starts: dict[int, datetime] = {}
async def capture_start(pool: asyncpg.Pool, account_id: int) -> datetime | None:
cached = _capture_starts.get(account_id)
if cached is not None:
return cached
start = await pool.fetchval(
"SELECT min(ts) FROM read_receipts WHERE account_id = $1", account_id
)
if start is not None:
_capture_starts[account_id] = start
return start
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
return await pool.fetchval(
@@ -8,3 +92,97 @@ async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int |
account_id,
chat_id,
)
async def read_times(
pool: asyncpg.Pool,
account_id: int,
chat_id: int,
messages: list[tuple[int, datetime]],
) -> dict[int, datetime]:
if not messages:
return {}
start = await capture_start(pool, account_id) or datetime.max.replace(tzinfo=UTC)
rows = await pool.fetch(
_READ_TIMES,
account_id,
chat_id,
[message_id for message_id, _ in messages],
[date for _, date in messages],
start,
CLOCK_SKEW,
)
return {row["id"]: row["read_at"] for row in rows if row["read_at"] is not None}
async def read_info(
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
) -> ReadInfoView | None:
message = await pool.fetchrow(
_MESSAGE, account_id, chat_id, message_id, VOICE_KINDS
)
if message is None:
return None
kind = await chat_kind(pool, account_id, chat_id)
date: datetime = message["date"]
receipts = await pool.fetchrow(
_RECEIPTS, account_id, chat_id, message_id, date - CLOCK_SKEW
)
reads = await pool.fetch(_READS, account_id, chat_id, message_id)
fetch = await pool.fetchrow(_FETCH, account_id, chat_id, message_id)
inherited = await pool.fetchval(_INHERITED, account_id, chat_id)
start = await capture_start(pool, account_id)
observed_at = receipts["observed_at"] if start and date >= start else None
exact_at = next(
(row["read_at"] for row in reads if row["source"] == "outbox_date"), None
)
reader_rows = [row for row in reads if row["source"] == "participants"]
known = {
peer.peer_id: peer
for peer in await get_peers(
pool, account_id, [row["reader_id"] for row in reader_rows]
)
}
readers = [
ReaderView(
peer_id=row["reader_id"],
read_at=row["read_at"],
peer=known.get(row["reader_id"]),
)
for row in reader_rows
]
outcome = fetch["outcome"] if fetch else inherited
now = datetime.now(UTC)
own = (
message["sender_id"] is not None
and message["sender_id"] == (message["tg_user_id"])
)
applicable = own and kind != ChatKind.CHANNEL and chat_id != message["tg_user_id"]
watermark = receipts["watermark"]
covered = watermark is not None and message_id <= watermark
seen = covered or any((exact_at, readers, receipts["listened_at"]))
evidence = watermark is not None or outcome in {"ok", "not_read_yet"}
due = fetch is None or fetch["retry_at"] <= now
return ReadInfoView(
chat_id=chat_id,
message_id=message_id,
chat_kind=kind.value,
own=own,
voice=message["voice"],
observed_at=observed_at,
listened_at=receipts["listened_at"],
exact_at=exact_at,
readers=readers,
read=applicable and seen,
unread=applicable and not seen and evidence,
outcome=outcome,
fetched_at=fetch["fetched_at"] if fetch else None,
can_fetch=applicable
and not message["is_bot"]
and now - date < FETCH_WINDOW
and exact_at is None
and inherited is None
and due,
)
+39 -10
View File
@@ -28,9 +28,11 @@ import type {
PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly,
PresenceSample,
PresenceSession,
PresenceStats,
ReactionView,
ReadInfo,
ResponseStats,
ScopeTarget,
SearchHit,
@@ -205,30 +207,38 @@ export function getCurrentPresence(
});
}
export function getPresenceHistory(
export function getPresenceStats(
peerId: number,
days: number,
tz: string,
page: Page = {}
): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence", {
): Promise<PresenceStats> {
return request<PresenceStats>("/presence/stats", {
account: true,
query: { peer_id: peerId, ...page },
query: { days, peer_id: peerId, tz, ...page },
});
}
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
return request<PresenceHourly[]>("/presence/hourly", {
export function getPresenceSessions(
peerId: number,
days: number,
tz: string,
page: Page = {}
): Promise<PresenceSession[]> {
return request<PresenceSession[]>("/presence/sessions", {
account: true,
query: { peer_id: peerId },
query: { days, peer_id: peerId, tz, ...page },
});
}
export function getMessageVolume(
chatId: number,
days = 90
days: number,
tz: string
): Promise<VolumeBucket[]> {
return request<VolumeBucket[]>("/analytics/volume", {
account: true,
query: { chat_id: chatId, days },
query: { chat_id: chatId, days, tz },
});
}
@@ -304,6 +314,25 @@ export function getChatLinks(
});
}
export function getReadInfo(
chatId: number,
messageId: number
): Promise<ReadInfo> {
return request<ReadInfo>(`/messages/${chatId}/${messageId}/read`, {
account: true,
});
}
export function refreshReadInfo(
chatId: number,
messageId: number
): Promise<{ job_id: number | null }> {
return request<{ job_id: number | null }>(
`/messages/${chatId}/${messageId}/read/refresh`,
{ body: { account_id: accounts.selectedId }, method: "POST" }
);
}
export function getMessageReactions(
chatId: number,
messageId: number
+85 -4
View File
@@ -204,6 +204,7 @@ export interface MessageView {
quote: string | null;
reactions: ReactionCount[];
read: boolean;
read_at: string | null;
reply: ReplyView | null;
sender_id: number | null;
service: ServiceView | null;
@@ -335,12 +336,57 @@ export interface PresenceSample {
ts: string;
}
export interface PresenceHourly {
bucket: string;
export interface PresenceSession {
ended: string;
ongoing: boolean;
online_seconds: number;
seconds: number;
started: string;
}
export interface PresenceHour {
covered_hours: number;
hour: number;
hours: number;
online_minutes: number | null;
weekday: number;
}
export interface PresenceDay {
covered_seconds: number;
day: string;
online_seconds: number;
}
export interface CoverageGap {
ended: string;
started: string;
}
export interface PresenceSummary {
covered_seconds: number;
daily_seconds: number | null;
first_online: number | null;
last_offline: number | null;
last_seen: string | null;
online_samples: number;
median_session_seconds: number | null;
online_seconds: number;
session_count: number;
}
export interface PresenceStats {
current: PresenceSample | null;
days: PresenceDay[];
gaps: CoverageGap[];
hidden: boolean;
online: boolean;
peer_id: number;
samples: number;
sessions: PresenceSession[];
since: string;
summary: PresenceSummary;
tz: string;
until: string;
week: PresenceHour[];
}
export interface VolumeBucket {
@@ -369,6 +415,40 @@ export interface PeerView {
username: string | null;
}
export interface ReaderView {
peer: PeerView | null;
peer_id: number;
read_at: string;
}
export type ReadOutcome =
| "ok"
| "not_read_yet"
| "user_privacy"
| "your_privacy"
| "chat_too_big"
| "too_old"
| "unavailable"
| "flood"
| "error";
export interface ReadInfo {
can_fetch: boolean;
chat_id: number;
chat_kind: "dm" | "group" | "channel";
exact_at: string | null;
fetched_at: string | null;
listened_at: string | null;
message_id: number;
observed_at: string | null;
outcome: ReadOutcome | null;
own: boolean;
read: boolean;
readers: ReaderView[];
unread: boolean;
voice: boolean;
}
export interface PeerHistoryView {
first_name: string | null;
is_deleted_account: boolean;
@@ -518,6 +598,7 @@ export interface LivePresenceEvent {
export interface LiveReceiptEvent {
chat_id: number;
read_at: string;
read_up_to: number;
type: "receipt";
}
+13 -40
View File
@@ -1,10 +1,6 @@
<script lang="ts">
import {
enqueueBackfill,
getCurrentPresence,
getPeer,
} from "#lib/api/endpoints.js";
import type { PeerView, PresenceSample } from "#lib/api/types.js";
import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
import type { PeerView } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
@@ -16,6 +12,7 @@
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
@@ -26,13 +23,10 @@
let { chatId }: Props = $props();
const PRESENCE_INTERVAL = 30_000;
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false);
async function backfill() {
@@ -81,34 +75,8 @@
});
$effect(() => {
if (accounts.selectedId === null || !isDm) {
presence = null;
return;
}
let active = true;
presence = null;
const refresh = () => {
if (document.visibilityState !== "visible") {
return;
}
getCurrentPresence(chatId)
.then((result) => {
if (active) {
presence = result;
}
})
.catch(() => {
if (active) {
presence = null;
}
});
};
refresh();
const timer = setInterval(refresh, PRESENCE_INTERVAL);
return () => {
active = false;
clearInterval(timer);
};
presence.follow(accounts.selectedId, isDm ? chatId : null);
return () => presence.follow(null, null);
});
const fallbackTitle = $derived(
@@ -119,8 +87,8 @@
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
if (isDm) {
if (presence) {
return formatPresence(presence);
if (presence.sample) {
return formatPresence(presence.sample, presence.now);
}
if (peer?.username) {
return `@${peer.username}`;
@@ -176,7 +144,12 @@
/>
<div class="info">
<h2 class="title">{title}</h2>
<span class="subtitle" class:online={isDm && isOnline(presence)}>
<span
class={[
"subtitle",
{ online: isDm && isOnline(presence.sample, presence.now) },
]}
>
{subtitle}
</span>
</div>
@@ -11,6 +11,7 @@
import Poll from "#lib/components/Poll.svelte";
import Reactions from "#lib/components/Reactions.svelte";
import ReplyHeader from "#lib/components/ReplyHeader.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
@@ -247,6 +248,9 @@
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem
>
@@ -363,13 +363,13 @@
);
}
function applyReceipt(readUpTo: number) {
function applyReceipt(readUpTo: number, readAt: string) {
if (ownId === null) {
return;
}
messages = messages.map((m) =>
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
? { ...m, read: true }
? { ...m, read: true, read_at: readAt }
: m
);
}
@@ -388,7 +388,7 @@
) {
applyDelete(event.message_ids);
} else if (event.type === "receipt" && event.chat_id === chatId) {
applyReceipt(event.read_up_to);
applyReceipt(event.read_up_to, event.read_at);
}
}
@@ -14,6 +14,7 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
@@ -222,6 +223,9 @@
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js";
interface Props {
message: MessageView;
@@ -25,11 +26,9 @@
{/if}
<span class="time">{formatTime(message.date)}</span>
{#if own}
<Icon
class="ticks"
name={message.read ? "message-read" : "check"}
size="1rem"
/>
<span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
</span>
{/if}
</span>
@@ -52,6 +51,10 @@
white-space: nowrap;
}
.ticks {
display: inline-flex;
}
.edited {
cursor: pointer;
padding: 0;
@@ -8,6 +8,7 @@
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte";
import ReadersPanel from "#lib/components/read/ReadersPanel.svelte";
import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte";
import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
@@ -18,13 +19,14 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = {
const titles: Record<RightPanel, string> = $derived({
alerts: "Алерты",
annotations: "Заметки",
bots: "Боты",
@@ -35,6 +37,7 @@
presence: "Аналитика",
profile: "Профиль",
reactions: "Реакции",
readers: t("read.readers.title"),
search: "Поиск",
shares: "Файлы по ссылке",
storage: "Хранилище",
@@ -44,7 +47,7 @@
"stories-all": "Все сторис",
versions: "Версии",
watches: "Отслеживания",
};
});
const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage",
@@ -104,6 +107,8 @@
<ChatSearchPanel />
{:else if ui.rightPanel === "reactions"}
<ReactionsPanel />
{:else if ui.rightPanel === "readers"}
<ReadersPanel />
{:else if ui.rightPanel === "callbacks"}
<CallbacksPanel />
{:else if ui.rightPanel === "links"}
@@ -1,193 +1,295 @@
<script lang="ts">
import {
getCurrentPresence,
getMessageVolume,
getPresenceHistory,
getPresenceHourly,
getPresenceStats,
getResponseStats,
} from "#lib/api/endpoints.js";
import type {
PresenceHourly,
PresenceSample,
PresenceStats,
ResponseStats,
VolumeBucket,
} from "#lib/api/types.js";
import OnlineDays from "#lib/components/presence/OnlineDays.svelte";
import PresenceHeatmap from "#lib/components/presence/PresenceHeatmap.svelte";
import PresenceSessions from "#lib/components/presence/PresenceSessions.svelte";
import VolumeChart from "#lib/components/presence/VolumeChart.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatPresence } from "#lib/format/presence.js";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { browserTimeZone, timeZoneLabel } from "#lib/format/datetime.js";
import {
formatClock,
formatLongSpan,
formatPresence,
formatSpan,
isOnline,
} from "#lib/format/presence.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js";
import { page } from "$app/state";
const HISTORY_LIMIT = 200;
const SECONDS_IN_MINUTE = 60;
const TEXT = {
average: "В среднем за день",
byDay: "По дням",
byHour: "По часам недели",
failed: "Не удалось загрузить аналитику.",
firstOnline: "Обычно появляется",
hidden:
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
lastOffline: "Обычно уходит",
median: "Обычная сессия",
messages: "Сообщения",
mine: "Я отвечаю",
missing: "Нет данных",
noData: "нет данных",
noMessages: "За этот период сообщений нет.",
noStatus: "Статусов пока нет.",
openChat: "Откройте чат",
period: (count: number) => `${count} дней`,
retry: "Повторить",
sessions: "Сессии",
theirs: "Мне отвечают",
title: "Аналитика",
total: (count: number) => `в сети за ${count} дней`,
};
const PERIODS = [7, 30, 90];
const DEFAULT_PERIOD = 30;
const SESSION_PAGE = 30;
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const MS_IN_SECOND = 1000;
const tz = browserTimeZone();
const zone = timeZoneLabel();
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
let current = $state<PresenceSample | null>(null);
let hourly = $state<PresenceHourly[]>([]);
let history = $state<PresenceSample[]>([]);
let volume = $state<VolumeBucket[]>([]);
let days = $state(DEFAULT_PERIOD);
let stats = $state<PresenceStats | null>(null);
let volume = $state<VolumeBucket[] | null>(null);
let response = $state<ResponseStats | null>(null);
let loading = $state(false);
let shownDays = $state(DEFAULT_PERIOD);
let failed = $state(false);
let shownChat: number | null = null;
let token = 0;
const changes = $derived.by(() => {
const result: PresenceSample[] = [];
let last: string | null = null;
for (const sample of history) {
if (sample.status !== last) {
result.push(sample);
last = sample.status;
const sample = $derived(
presence.sample?.peer_id === chatId
? presence.sample
: (stats?.current ?? null)
);
const online = $derived(isOnline(sample, presence.now));
const tracked = $derived(
stats !== null &&
(stats.current !== null || stats.summary.session_count > 0)
);
const quiet = $derived(
volume?.every((bucket) => bucket.total === 0) ?? false
);
const rows = $derived.by(() => {
if (stats === null) {
return [];
}
const { summary } = stats;
const result: { label: string; value: string }[] = [];
if (summary.daily_seconds !== null) {
result.push({
label: TEXT.average,
value: formatSpan(summary.daily_seconds),
});
}
if (summary.median_session_seconds !== null) {
result.push({
label: TEXT.median,
value: formatSpan(summary.median_session_seconds),
});
}
if (summary.first_online !== null) {
result.push({
label: TEXT.firstOnline,
value: formatClock(summary.first_online),
});
}
if (summary.last_offline !== null) {
result.push({
label: TEXT.lastOffline,
value: formatClock(summary.last_offline),
});
}
const period =
(new Date(stats.until).getTime() - new Date(stats.since).getTime()) /
MS_IN_SECOND;
const missing = period - summary.covered_seconds;
if (missing >= SECONDS_IN_HOUR) {
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
}
return result;
});
function formatDuration(seconds: number | null): string {
if (seconds === null) {
return "—";
function reply(seconds: number | null): string {
return seconds === null ? TEXT.noData : formatSpan(seconds);
}
if (seconds < SECONDS_IN_MINUTE) {
return `${Math.round(seconds)} с`;
async function load(id: number, period: number) {
token += 1;
const current = token;
failed = false;
if (shownChat !== id) {
shownChat = id;
stats = null;
volume = null;
response = null;
}
if (seconds < SECONDS_IN_HOUR) {
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
try {
const [nextStats, nextVolume, nextResponse] = await Promise.all([
id > 0
? getPresenceStats(id, period, tz, { limit: SESSION_PAGE })
: Promise.resolve(null),
getMessageVolume(id, period, tz),
getResponseStats(id),
]);
if (current === token) {
stats = nextStats;
volume = nextVolume;
response = nextResponse;
shownDays = period;
}
} catch {
if (current === token) {
failed = true;
}
if (seconds < SECONDS_IN_DAY) {
return `${Math.round(seconds / SECONDS_IN_HOUR)} ч`;
}
return `${Math.round(seconds / SECONDS_IN_DAY)} д`;
}
$effect(() => {
if (accounts.selectedId === null || chatId === null) {
return;
if (accounts.selectedId !== null && chatId !== null) {
load(chatId, days);
}
const id = chatId;
let active = true;
loading = true;
current = null;
hourly = [];
history = [];
volume = [];
response = null;
Promise.all([getMessageVolume(id), getResponseStats(id)])
.then(([vol, resp]) => {
if (active) {
volume = vol;
response = resp;
}
})
.catch(() => undefined)
.finally(() => {
if (active) {
loading = false;
}
});
if (id > 0) {
Promise.all([
getCurrentPresence(id),
getPresenceHourly(id),
getPresenceHistory(id, { limit: HISTORY_LIMIT }),
])
.then(([cur, hrs, hist]) => {
if (active) {
current = cur;
hourly = hrs;
history = hist;
}
})
.catch(() => undefined);
}
const unsub = events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === id && event.sample) {
current = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
</script>
{#if chatId === null}
<EmptyState description="Откройте чат" title="Аналитика" />
<EmptyState description={TEXT.openChat} title={TEXT.title} />
{:else}
<div class="analytics">
<div class="periods">
{#each PERIODS as period (period)}
<button
aria-pressed={days === period}
class={["period", { on: days === period }]}
onclick={() => {
days = period;
}}
type="button"
>
{TEXT.period(period)}
</button>
{/each}
</div>
{#if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button
onclick={() => chatId !== null && load(chatId, days)}
smaller
variant="text"
>
{TEXT.retry}
</Button>
</div>
{:else if volume === null || (isDm && stats === null)}
<div class="state"><Spinner /></div>
{:else}
{#if isDm && stats}
<section class="hero">
{#if tracked && !stats.hidden}
<div class="total-line">
<span class="total"
>{formatSpan(stats.summary.online_seconds)}</span
>
<span class="count">{TEXT.total(shownDays)}</span>
</div>
{/if}
{#if sample}
<p class={["status", { online }]}>
{formatPresence(sample, presence.now)}
</p>
{/if}
{#if stats.hidden}
<p class="note">{TEXT.hidden}</p>
{:else if !tracked}
<p class="note">{TEXT.noStatus}</p>
{/if}
</section>
{#if tracked && !stats.hidden}
{#if rows.length > 0}
<section>
<h3>Объём сообщений (90 дней)</h3>
{#if volume.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
<dl class="rows">
{#each rows as row (row.label)}
<div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
</section>
{/if}
<section>
<div class="section-title">
<h3>{TEXT.byHour}</h3>
<span>{zone}</span>
</div>
<PresenceHeatmap week={stats.week} />
</section>
<section>
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
<OnlineDays days={stats.days} />
</section>
{/if}
{/if}
<section>
<div class="section-title"><h3>{TEXT.messages}</h3></div>
{#if quiet}
<p class="state">{TEXT.noMessages}</p>
{:else}
<VolumeChart {volume} />
{/if}
</section>
<section>
<h3>Время ответа</h3>
{#if response}
<div class="stats">
<div class="stat">
<span class="value"
>{formatDuration(response.mine_median_seconds)}</span
>
<span class="caption">я отвечаю ({response.mine_count})</span>
{#if response && response.mine_count + response.their_count > 0}
<dl class="rows replies">
<div class="row">
<dt>{TEXT.mine}</dt>
<dd>{reply(response.mine_median_seconds)}</dd>
</div>
<div class="stat">
<span class="value"
>{formatDuration(response.their_median_seconds)}</span
>
<span class="caption">мне отвечают ({response.their_count})</span>
<div class="row">
<dt>{TEXT.theirs}</dt>
<dd>{reply(response.their_median_seconds)}</dd>
</div>
</div>
{:else}
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
</dl>
{/if}
</section>
{#if isDm}
{#if isDm && stats && tracked && !stats.hidden}
<section>
<div class="status" class:online={current?.status === "online"}>
{current ? formatPresence(current) : "нет данных"}
</div>
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
{#key `${chatId}:${shownDays}:${stats.until}`}
<PresenceSessions
days={shownDays}
first={stats.sessions}
pageSize={SESSION_PAGE}
peerId={stats.peer_id}
{tz}
/>
{/key}
</section>
<section>
<h3>Когда в сети</h3>
{#if hourly.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
{:else}
<PresenceHeatmap {hourly} />
{/if}
</section>
<section>
<h3>История статусов</h3>
{#if changes.length === 0}
<p class="muted">{loading ? "Загрузка…" : "Нет записей"}</p>
{:else}
<ul class="changes">
{#each changes as sample (sample.ts)}
<li>
<span
class="dot"
class:online={sample.status === "online"}
></span>
<span class="label">{formatPresence(sample)}</span>
<span class="time">{formatFull(sample.ts)}</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
{/if}
@@ -196,48 +298,89 @@
.analytics {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-bottom: 0.5rem;
}
.periods {
display: flex;
gap: 0.25rem;
margin: 0.75rem 1rem 0.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-background-secondary);
}
.period {
cursor: pointer;
flex: 1;
height: 2rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
background: none;
transition:
background-color 0.15s ease-out,
color 0.15s ease-out;
&.on {
color: var(--color-text);
background-color: var(--color-background);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (pointer: coarse) {
height: 2.75rem;
}
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0;
& + section {
border-top: 1px solid var(--color-borders);
}
}
h3 {
margin: 0;
font-size: 0.75rem;
.hero {
padding: 0.75rem 1rem 0.875rem;
}
.total-line {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.stats {
display: flex;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.value {
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.caption {
font-size: 0.75rem;
.count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.status {
margin: 0.25rem 0 0;
font-size: 0.9375rem;
color: var(--color-text-secondary);
@@ -246,49 +389,80 @@
}
}
.muted {
margin: 0;
font-size: 0.8125rem;
.note {
margin: 0.5rem 0 0;
font-size: 0.875rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.changes {
.section-title {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 1rem 0.625rem;
.changes li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
.dot {
width: 0.5rem;
height: 0.5rem;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--color-text-secondary);
&.online {
background-color: var(--color-primary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.rows {
margin: 0;
}
.replies {
margin-top: 0.5rem;
}
.row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
}
dt {
color: var(--color-text);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
white-space: nowrap;
}
.time {
flex-shrink: 0;
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
</style>
@@ -0,0 +1,144 @@
<script lang="ts">
import type { PresenceDay } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: PresenceDay[];
}
const { days }: Props = $props();
const SECONDS_IN_HOUR = 3600;
const SECONDS_IN_DAY = 86_400;
const TEXT = {
bar: (day: string, online: number, missing: number) => {
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
return `${day}, нет данных`;
}
const gap =
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
return `${day}, ${formatSpan(online)} в сети${gap}`;
},
today: "сегодня",
};
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
const first = $derived(days[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
function absent(day: PresenceDay, index: number): number {
if (index === days.length - 1) {
return 0;
}
return Math.max(0, SECONDS_IN_DAY - day.covered_seconds);
}
</script>
<div class="days">
<div class="chart">
<span class="peak">{formatSpan(peak)}</span>
<div class={["bars", { dense: days.length > 45 }]}>
{#each days as day, index (day.day)}
<div
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
title={TEXT.bar(
label(day.day),
day.online_seconds,
absent(day, index)
)}
>
<span
class={["bar", { zero: day.online_seconds === 0 }]}
style:height="{(day.online_seconds / peak) * 100}%"
></span>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.day)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
</div>
<style lang="scss">
.days {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
&.empty {
border-radius: 2px 2px 0 0;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.35) 0 1px,
transparent 1px 4px
);
}
}
.bar {
width: 100%;
min-height: 2px;
border-radius: 2px 2px 0 0;
background-color: var(--color-primary);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
.empty & {
min-height: 0;
}
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -1,116 +1,217 @@
<script lang="ts">
import type { PresenceHourly } from "#lib/api/types.js";
import type { PresenceHour } from "#lib/api/types.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
hourly: PresenceHourly[];
week: PresenceHour[];
}
const { hourly }: Props = $props();
const { week }: Props = $props();
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
const HOUR_TICKS = new Set([0, 6, 12, 18]);
interface Cell {
online: number;
total: number;
const TEXT = {
cell: (day: string, cell: PresenceHour) => {
const slot = `${day}, ${cell.hour}:00`;
if (cell.hours === 0) {
return slot;
}
if (cell.online_minutes === null) {
return `${slot}, нет данных`;
}
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
},
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
empty: "нет данных",
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
};
const grid = $derived.by(() => {
const cells: Cell[][] = DAYS.map(() =>
HOURS.map(() => ({ online: 0, total: 0 }))
const HOURS_IN_DAY = 24;
const HOUR_TICKS = [0, 6, 12, 18];
const MIN_SHADE = 0.18;
const rows = $derived(
TEXT.days.map((_, weekday) =>
week
.filter((cell) => cell.weekday === weekday)
.sort((a, b) => a.hour - b.hour)
)
);
for (const bucket of hourly) {
const date = new Date(bucket.bucket);
const day = (date.getDay() + 6) % 7;
const cell = cells[day]?.[date.getHours()];
if (cell) {
cell.online += bucket.online_samples;
cell.total += bucket.samples;
}
}
return cells;
});
const peak = $derived(
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
);
const hasGaps = $derived(week.some(missing));
function intensity(cell: Cell): number {
return cell.total > 0 ? cell.online / cell.total : 0;
function missing(cell: PresenceHour): boolean {
return cell.hours > 0 && cell.online_minutes === null;
}
function cellTitle(day: number, hour: number, cell: Cell): string {
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
function shade(minutes: number | null): number {
if (minutes === null || minutes === 0 || peak === 0) {
return 0;
}
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
}
</script>
<div class="heatmap">
{#each grid as row, day (day)}
<div class="heat-row">
<span class="day-label">{DAYS[day]}</span>
{#each rows as cells, weekday (weekday)}
<div class="row">
<span class="day">{TEXT.days[weekday]}</span>
<div class="cells">
{#each row as cell, hour (hour)}
{#each cells as cell (cell.hour)}
<span
class="cell"
title={cellTitle(day, hour, cell)}
style:--i={intensity(cell)}
class:empty={cell.total === 0}
class={[
"cell",
{
empty: missing(cell),
outside: cell.hours === 0,
zero: cell.online_minutes === 0,
},
]}
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
style:--shade="{shade(cell.online_minutes) * 100}%"
></span>
{/each}
</div>
</div>
{/each}
<div class="heat-row axis">
<span class="day-label"></span>
<div class="cells">
{#each HOURS as hour (hour)}
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
<div class="row">
<span class="day"></span>
<div class="ticks">
{#each HOUR_TICKS as hour (hour)}
<span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
{/each}
</div>
</div>
<div class="legend">
<span class="key"><span class="swatch zero"></span>0</span>
{#if peak > 0}
<span class="key">
<span class="scale"></span>
{TEXT.max(peak)}
</span>
{/if}
{#if hasGaps}
<span class="key">
<span class="swatch empty"></span>
{TEXT.empty}
</span>
{/if}
</div>
</div>
<style lang="scss">
.heatmap {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
padding: 0 1rem;
}
.heat-row {
.row {
display: flex;
align-items: center;
gap: 0.5rem;
align-items: center;
}
.day-label {
width: 1.5rem;
.day {
flex-shrink: 0;
font-size: 0.6875rem;
width: 1.375rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: right;
}
.cells {
display: grid;
flex: 1;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.cell {
aspect-ratio: 1;
border-radius: 2px;
background-color: var(--color-primary);
opacity: calc(0.12 + 0.88 * var(--i));
background-color: color-mix(
in srgb,
var(--color-primary) var(--shade),
transparent
);
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.outside {
background: none;
}
&.empty {
background-color: var(--color-text-secondary);
opacity: 0.08;
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
.tick {
font-size: 0.625rem;
.ticks {
position: relative;
flex: 1;
height: 1rem;
span {
position: absolute;
top: 0.125rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-align: left;
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
align-items: center;
margin: 0.375rem 0 0 1.875rem;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.key {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.scale {
width: 2.5rem;
height: 0.625rem;
border-radius: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-primary) 18%, transparent),
var(--color-primary)
);
}
.swatch {
width: 0.625rem;
height: 0.625rem;
border-radius: 2px;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
}
&.empty {
background: repeating-linear-gradient(
135deg,
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
transparent 1px 3px
);
}
}
</style>
@@ -0,0 +1,205 @@
<script lang="ts">
import { getPresenceSessions } from "#lib/api/endpoints.js";
import type { PresenceSession } from "#lib/api/types.js";
import Button from "#lib/components/ui/Button.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
import { formatSpan } from "#lib/format/presence.js";
interface Props {
days: number;
first: PresenceSession[];
pageSize: number;
peerId: number;
tz: string;
}
const { days, first, pageSize, peerId, tz }: Props = $props();
const TEXT = {
failed: "Не удалось загрузить сессии.",
more: "Показать ещё",
none: "За этот период в сети не был(а).",
now: "в сети",
retry: "Повторить",
since: (time: string) => `с ${time}`,
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
today: "Сегодня",
yesterday: "Вчера",
};
const MS_IN_DAY = 86_400_000;
let extra = $state<PresenceSession[]>([]);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
const sessions = $derived([...first, ...extra]);
const exhausted = $derived(done || first.length < pageSize);
const groups = $derived.by(() => {
const result: { label: string; sessions: PresenceSession[] }[] = [];
for (const session of sessions) {
const label = dayLabel(new Date(session.started));
const last = result.at(-1);
if (last?.label === label) {
last.sessions.push(session);
} else {
result.push({ label, sessions: [session] });
}
}
return result;
});
function dayLabel(date: Date): string {
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return TEXT.today;
}
if (
date.toDateString() ===
new Date(today.getTime() - MS_IN_DAY).toDateString()
) {
return TEXT.yesterday;
}
const text = formatLongDay(date);
return text.charAt(0).toUpperCase() + text.slice(1);
}
async function loadMore() {
if (loading) {
return;
}
loading = true;
failed = false;
try {
const batch = await getPresenceSessions(peerId, days, tz, {
limit: pageSize,
offset: sessions.length,
});
const seen = new Set(sessions.map((session) => session.started));
extra = [
...extra,
...batch.filter((session) => !seen.has(session.started)),
];
done = batch.length < pageSize;
} catch {
failed = true;
} finally {
loading = false;
}
}
</script>
{#if sessions.length === 0}
<p class="state">{TEXT.none}</p>
{:else}
{#each groups as group (group.label)}
<h4 class="day">{group.label}</h4>
<ul>
{#each group.sessions as session (session.started)}
<li>
<span>
{session.ongoing
? TEXT.since(formatTime(session.started))
: TEXT.span(
formatTime(session.started),
formatTime(session.ended)
)}
</span>
<span class={["length", { online: session.ongoing }]}>
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
</span>
</li>
{/each}
</ul>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>{TEXT.failed}</p>
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
</div>
{:else if !exhausted}
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
{/if}
{/if}
<style lang="scss">
.day {
margin: 0;
padding: 0.625rem 1rem 0.25rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-normal);
color: var(--color-text-secondary);
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
min-height: 2.5rem;
padding: 0 1rem;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
}
.length {
font-size: 0.875rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VolumeBucket } from "#lib/api/types.js";
import { formatDay, parseDay } from "#lib/format/datetime.js";
interface Props {
volume: VolumeBucket[];
@@ -7,47 +8,104 @@
const { volume }: Props = $props();
const max = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const TEXT = {
bar: (day: string, bucket: VolumeBucket) =>
bucket.total === 0
? `${day}, нет сообщений`
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
incoming: "входящие",
outgoing: "мои",
today: "сегодня",
};
function barTitle(bucket: VolumeBucket): string {
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
const first = $derived(volume[0]);
function label(day: string): string {
return formatDay(parseDay(day).toISOString());
}
</script>
<div class="volume">
<div class="bars">
<div class="chart">
<span class="peak">{peak}</span>
<div class={["bars", { dense: volume.length > 45 }]}>
{#each volume as bucket (bucket.bucket)}
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
<div
class="bar"
title={barTitle(bucket)}
style:height={`${(bucket.total / max) * 100}%`}
class={["bar", { zero: bucket.total === 0 }]}
style:height="{(bucket.total / peak) * 100}%"
>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
<div class="seg in" style:flex-grow={bucket.incoming}></div>
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
</div>
</div>
{/each}
</div>
</div>
{#if first}
<div class="axis">
<span>{label(first.bucket)}</span>
<span>{TEXT.today}</span>
</div>
{/if}
<div class="legend">
<span class="key"><span class="swatch out"></span>исходящие</span>
<span class="key"><span class="swatch in"></span>входящие</span>
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
</div>
</div>
<style lang="scss">
.volume {
padding: 0 1rem;
}
.chart {
position: relative;
padding-top: 1.125rem;
border-top: 1px dashed var(--color-borders);
}
.peak {
position: absolute;
top: 0.125rem;
left: 0;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.bars {
display: flex;
gap: 3px;
align-items: flex-end;
height: 6rem;
&.dense {
gap: 1px;
height: 7.5rem;
}
}
.slot {
display: flex;
flex: 1;
align-items: flex-end;
height: 100%;
}
.bar {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
min-height: 2px;
overflow: hidden;
border-radius: 2px 2px 0 0;
&.zero {
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
}
}
.seg.out {
@@ -55,8 +113,18 @@
}
.seg.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
background-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.legend {
@@ -69,8 +137,8 @@
.key {
display: flex;
align-items: center;
gap: 0.375rem;
align-items: center;
}
.swatch {
@@ -83,8 +151,11 @@
}
&.in {
background-color: var(--color-text-secondary);
opacity: 0.4;
background-color: color-mix(
in srgb,
var(--color-primary) 40%,
transparent
);
}
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { untrack } from "svelte";
import type { MessageView } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerColorIndex, peerName } from "#lib/format/peer.js";
import { readLine } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { reads } from "#lib/stores/reads.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props {
message: MessageView;
}
const AVATARS = 3;
let { message }: Props = $props();
const entry = $derived(reads.get(message.chat_id, message.message_id));
const fetching = $derived(entry?.fetching ?? false);
const shown = $derived(readLine(message, entry?.info ?? null, fetching));
$effect(() => {
const chatId = message.chat_id;
const messageId = message.message_id;
untrack(() => reads.open(chatId, messageId));
});
function openReaders() {
ui.openMessagePanel("readers", message.message_id);
}
</script>
{#if shown?.kind === "readers"}
<ContextMenu.Item class="bg-menu-item read-item" onSelect={openReaders}>
<Icon name="group" size="1.125rem" />
<span class="label"
>{t("read.readers", { count: shown.readers.length })}</span
>
{#if fetching}
<Spinner color="gray" size="1rem" />
{/if}
<span class="avatars">
{#each shown.readers.slice(0, AVATARS) as reader (reader.peer_id)}
<Avatar
avatar={{ id: reader.peer_id, kind: "peer" }}
colorKey={peerColorIndex(reader.peer_id)}
hasAvatar={reader.peer?.has_avatar ?? false}
name={peerName(reader.peer) || String(reader.peer_id)}
size={1.5}
/>
{/each}
</span>
</ContextMenu.Item>
<ContextMenu.Separator class="bg-menu-separator" />
{:else if shown}
<div class="bg-menu-item read-item static" class:muted={shown.muted}>
<Icon name={shown.icon} size="1.125rem" />
<span class="label">{shown.text}</span>
{#if fetching}
<Spinner color="gray" size="1rem" />
{/if}
</div>
<ContextMenu.Separator class="bg-menu-separator" />
{/if}
<style lang="scss">
:global(.bg-menu-item.read-item) {
min-height: 2.5rem;
}
.static {
cursor: default;
&:hover {
background-color: transparent;
}
}
.muted {
color: var(--color-text-secondary);
}
.label {
flex: 1;
white-space: nowrap;
}
.avatars {
display: flex;
margin-inline-start: 0.25rem;
& > :global(.Avatar) {
border: 2px solid var(--color-background);
}
& > :global(.Avatar + .Avatar) {
margin-inline-start: -0.5rem;
}
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import { untrack } from "svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { peerColorIndex, peerName } from "#lib/format/peer.js";
import { readWhen } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { reads } from "#lib/stores/reads.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
const entry = $derived(
chatId === null || messageId === null
? undefined
: reads.get(chatId, messageId)
);
const readers = $derived(entry?.info.readers ?? []);
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid !== null && mid !== null) {
untrack(() => reads.open(cid, mid));
}
});
function openProfile(peerId: number) {
goto(`/app/${peerId}`);
ui.openPanel("profile");
}
</script>
{#if entry === undefined}
<div class="center"><Spinner /></div>
{:else if readers.length === 0}
<EmptyState title={t("read.readers.empty")} />
{:else}
<ul class="readers">
{#each readers as reader (reader.peer_id)}
{@const name = peerName(reader.peer) || String(reader.peer_id)}
<li>
<button onclick={() => openProfile(reader.peer_id)} type="button">
<Avatar
avatar={{ id: reader.peer_id, kind: "peer" }}
colorKey={peerColorIndex(reader.peer_id)}
hasAvatar={reader.peer?.has_avatar ?? false}
{name}
size={2.625}
/>
<span class="info">
<span class="name">{name}</span>
<span class="time">{readWhen(reader.read_at)}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.readers {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
button {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.4375rem 0.75rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
color: inherit;
text-align: start;
background: transparent;
&:hover,
&:focus-visible {
background-color: var(--color-chat-hover);
}
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-weight: var(--font-weight-medium);
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
+33 -6
View File
@@ -9,19 +9,25 @@ const OPTIONS = {
month: "short",
year: "numeric",
},
longDay: { day: "numeric", month: "long", weekday: "long" },
offset: { timeZoneName: "shortOffset" },
time: { hour: "2-digit", minute: "2-digit" },
} satisfies Record<string, Intl.DateTimeFormatOptions>;
const formatters = new Map<string, Intl.DateTimeFormat>();
function format(kind: keyof typeof OPTIONS, date: Date): string {
function formatter(kind: keyof typeof OPTIONS): Intl.DateTimeFormat {
const key = `${locale.tag}:${kind}`;
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
formatters.set(key, formatter);
let cached = formatters.get(key);
if (!cached) {
cached = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
formatters.set(key, cached);
}
return formatter.format(date);
return cached;
}
function format(kind: keyof typeof OPTIONS, date: Date): string {
return formatter(kind).format(date);
}
export function formatTime(iso: string): string {
@@ -47,3 +53,24 @@ export function formatListDate(iso: string | null): string {
}
return format("day", date);
}
export function formatLongDay(date: Date): string {
return format("longDay", date);
}
export function parseDay(value: string): Date {
const [year, month, date] = value.split("-").map(Number);
return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1);
}
export function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function timeZoneLabel(): string {
return (
formatter("offset")
.formatToParts(new Date())
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
);
}
+107 -27
View File
@@ -1,37 +1,117 @@
import type { PresenceSample } from "#lib/api/types.js";
import { formatListDate } from "#lib/format/datetime.js";
import { formatDay, formatTime } from "#lib/format/datetime.js";
import { plural } from "#lib/format/shares.js";
export function isOnline(sample: PresenceSample | null): boolean {
if (sample === null || sample.status !== "online") {
const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60;
const SECONDS_IN_HOUR = 3600;
const MS_IN_MINUTE = 60_000;
const SECONDS_IN_DAY = 86_400;
const MS_IN_DAY = 86_400_000;
const TEXT = {
ago: (minutes: number) =>
`был(а) ${minutes} ${plural(minutes, "минуту", "минуты", "минут")} назад`,
days: (days: number) => `${days} ${plural(days, "день", "дня", "дней")}`,
hours: (hours: number) => `${hours} ч`,
hoursMinutes: (hours: number, minutes: number) => `${hours} ч ${minutes} мин`,
justNow: "был(а) только что",
lastMonth: "был(а) в этом месяце",
lastWeek: "был(а) на этой неделе",
longAgo: "был(а) давно",
minutes: (minutes: number) => `${minutes} мин`,
offline: "не в сети",
on: (day: string, time: string) => `был(а) ${day} в ${time}`,
online: "в сети",
recently: "был(а) недавно",
seconds: (seconds: number) => `${seconds} с`,
today: (time: string) => `был(а) сегодня в ${time}`,
yesterday: (time: string) => `был(а) вчера в ${time}`,
};
const FUZZY: Record<string, string> = {
last_month: TEXT.lastMonth,
last_week: TEXT.lastWeek,
long_time_ago: TEXT.longAgo,
recently: TEXT.recently,
};
export function isOnline(
sample: PresenceSample | null,
now = Date.now()
): boolean {
if (sample?.status !== "online" || sample.next_offline_date === null) {
return false;
}
if (sample.next_offline_date === null) {
return false;
}
return new Date(sample.next_offline_date).getTime() > Date.now();
return new Date(sample.next_offline_date).getTime() > now;
}
export function formatPresence(sample: PresenceSample): string {
switch (sample.status) {
case "online":
return isOnline(sample)
? "online"
: lastSeen(sample.last_online_date ?? sample.ts);
case "recently":
return "last seen recently";
case "last_week":
return "last seen within a week";
case "last_month":
return "last seen within a month";
case "long_time_ago":
return "last seen a long time ago";
default:
function lastSeen(iso: string, now: number): string {
const date = new Date(iso);
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
if (minutes < 1) {
return TEXT.justNow;
}
if (minutes < MINUTES_IN_HOUR) {
return TEXT.ago(minutes);
}
const time = formatTime(iso);
const today = new Date(now);
if (date.toDateString() === today.toDateString()) {
return TEXT.today(time);
}
if (date.toDateString() === new Date(now - MS_IN_DAY).toDateString()) {
return TEXT.yesterday(time);
}
return TEXT.on(formatDay(iso), time);
}
export function formatPresence(
sample: PresenceSample,
now = Date.now()
): string {
if (sample.status === "online") {
return isOnline(sample, now)
? TEXT.online
: lastSeen(sample.next_offline_date ?? sample.ts, now);
}
const fuzzy = FUZZY[sample.status];
if (fuzzy) {
return fuzzy;
}
return sample.last_online_date
? lastSeen(sample.last_online_date)
: "offline";
}
? lastSeen(sample.last_online_date, now)
: TEXT.offline;
}
function lastSeen(date: string): string {
return `last seen ${formatListDate(date)}`;
export function formatSpan(seconds: number): string {
if (seconds < SECONDS_IN_MINUTE) {
return TEXT.seconds(Math.round(seconds));
}
const minutes = Math.round(seconds / SECONDS_IN_MINUTE);
if (minutes < MINUTES_IN_HOUR) {
return TEXT.minutes(minutes);
}
const hours = Math.floor(minutes / MINUTES_IN_HOUR);
const rest = minutes % MINUTES_IN_HOUR;
return rest === 0 ? TEXT.hours(hours) : TEXT.hoursMinutes(hours, rest);
}
export function formatClock(secondsOfDay: number): string {
const hours = Math.floor(secondsOfDay / SECONDS_IN_HOUR);
const minutes = Math.floor(
(secondsOfDay % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE
);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
export function formatLongSpan(seconds: number): string {
if (seconds < 2 * SECONDS_IN_DAY) {
return formatSpan(seconds);
}
const days = Math.floor(seconds / SECONDS_IN_DAY);
const hours = Math.round((seconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR);
return hours === 0
? TEXT.days(days)
: `${TEXT.days(days)} ${TEXT.hours(hours)}`;
}
+104
View File
@@ -0,0 +1,104 @@
import type {
MessageView,
ReaderView,
ReadInfo,
ReadOutcome,
} from "#lib/api/types.js";
import { formatTime } from "#lib/format/datetime.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
export type ReadLine =
| { kind: "readers"; readers: ReaderView[] }
| { icon: string; kind: "text"; muted: boolean; text: string };
const REASONS: Partial<Record<ReadOutcome, MessageKey>> = {
chat_too_big: "read.reason.chat_too_big",
error: "read.reason.error",
flood: "read.reason.flood",
too_old: "read.reason.too_old",
unavailable: "read.reason.unavailable",
user_privacy: "read.reason.user_privacy",
your_privacy: "read.reason.your_privacy",
};
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
function dayLabel(date: Date, withYear: boolean): string {
const formatKey = `${locale.tag}:${withYear}`;
let formatter = dayFormatters.get(formatKey);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale.tag, {
day: "numeric",
month: "short",
year: withYear ? "numeric" : undefined,
});
dayFormatters.set(formatKey, formatter);
}
const parts = formatter.formatToParts(date);
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value.replace(".", "");
return [pick("day"), pick("month"), pick("year")].filter(Boolean).join(" ");
}
export function readWhen(iso: string): string {
const date = new Date(iso);
const now = new Date();
const time = formatTime(iso);
if (date.toDateString() === now.toDateString()) {
return t("read.when.today", { time });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return t("read.when.yesterday", { time });
}
const withYear = date.getFullYear() !== now.getFullYear();
return t("read.when.day", { day: dayLabel(date, withYear), time });
}
function line(text: string, icon = "message-read", muted = false): ReadLine {
return { icon, kind: "text", muted, text };
}
export function readTitle(message: MessageView): string | undefined {
if (message.read_at) {
return t("read.at", { when: readWhen(message.read_at) });
}
return message.read ? t("read.done") : undefined;
}
export function readLine(
message: MessageView,
info: ReadInfo | null,
fetching: boolean
): ReadLine | null {
if (info === null) {
const title = readTitle(message);
return title ? line(title) : null;
}
if (info.chat_kind === "group" && info.readers.length > 0) {
return { kind: "readers", readers: info.readers };
}
if (info.voice && info.listened_at) {
return line(
t("read.listened", { when: readWhen(info.listened_at) }),
"play-story"
);
}
const at = info.exact_at ?? info.observed_at;
if (at) {
return line(t("read.at", { when: readWhen(at) }));
}
if (info.read) {
return line(t("read.done"));
}
if (info.unread) {
return line(t("read.unread"), "check");
}
const reason = info.outcome ? REASONS[info.outcome] : undefined;
if (reason) {
return line(t(reason), "eye-crossed-outline", true);
}
return fetching ? line(t("read.pending"), "message-read", true) : null;
}
+2
View File
@@ -1,12 +1,14 @@
import { account } from "#lib/i18n/messages/account.js";
import { common } from "#lib/i18n/messages/common.js";
import { menu } from "#lib/i18n/messages/menu.js";
import { read } from "#lib/i18n/messages/read.js";
import { settings } from "#lib/i18n/messages/settings.js";
export const messages = {
...account,
...common,
...menu,
...read,
...settings,
};
+49
View File
@@ -0,0 +1,49 @@
import type { Messages } from "#lib/i18n/types.js";
export const read = {
"read.at": { en: "Read {when}", ru: "Прочитано {when}" },
"read.done": { en: "Read", ru: "Прочитано" },
"read.listened": { en: "Listened {when}", ru: "Прослушано {when}" },
"read.pending": { en: "Read time", ru: "Время прочтения" },
"read.readers": { en: "Read by {count}", ru: "Прочитали: {count}" },
"read.readers.empty": {
en: "Nobody has read it yet",
ru: "Пока никто не прочитал",
},
"read.readers.title": { en: "Read by", ru: "Прочитали" },
"read.reason.chat_too_big": {
en: "The group has over 100 members",
ru: "В группе больше 100 участников",
},
"read.reason.error": {
en: "Telegram did not answer",
ru: "Telegram не ответил",
},
"read.reason.flood": {
en: "Telegram asked to wait",
ru: "Telegram попросил подождать",
},
"read.reason.too_old": {
en: "Telegram no longer keeps the time",
ru: "Telegram уже не хранит время",
},
"read.reason.unavailable": {
en: "Telegram cannot find the chat",
ru: "Telegram не находит чат",
},
"read.reason.user_privacy": {
en: "They hide their read time",
ru: "Собеседник скрывает время прочтения",
},
"read.reason.your_privacy": {
en: "You hide your read time",
ru: "Вы скрываете своё время прочтения",
},
"read.unread": { en: "Not read yet", ru: "Не прочитано" },
"read.when.day": { en: "{day} at {time}", ru: "{day} в {time}" },
"read.when.today": { en: "today at {time}", ru: "сегодня в {time}" },
"read.when.yesterday": {
en: "yesterday at {time}",
ru: "вчера в {time}",
},
} satisfies Messages;
@@ -0,0 +1,73 @@
import { getCurrentPresence } from "#lib/api/endpoints.js";
import type { PresenceSample } from "#lib/api/types.js";
import { events } from "#lib/stores/events.svelte.js";
const CLOCK_INTERVAL = 30_000;
function createPresence() {
let peerId: number | null = null;
let key: string | null = null;
let sample = $state<PresenceSample | null>(null);
let now = $state(Date.now());
let clock: ReturnType<typeof setInterval> | undefined;
let token = 0;
async function refresh() {
const id = peerId;
token += 1;
const current = token;
if (id === null) {
return;
}
try {
const next = await getCurrentPresence(id);
if (current === token) {
sample = next;
now = Date.now();
}
} catch {
if (current === token) {
sample = null;
}
}
}
events.subscribe((event) => {
if (event.type === "presence" && event.peer_id === peerId) {
token += 1;
({ sample } = event);
now = Date.now();
}
});
events.onReconnect(() => {
refresh();
});
return {
follow(accountId: number | null, id: number | null) {
const next =
accountId === null || id === null ? null : `${accountId}:${id}`;
if (next === key) {
return;
}
key = next;
peerId = next === null ? null : id;
sample = null;
clearInterval(clock);
if (peerId !== null) {
clock = setInterval(() => {
now = Date.now();
}, CLOCK_INTERVAL);
refresh();
}
},
get now(): number {
return now;
},
get sample(): PresenceSample | null {
return sample;
},
};
}
export const presence = createPresence();
+77
View File
@@ -0,0 +1,77 @@
import { getJob, getReadInfo, refreshReadInfo } from "#lib/api/endpoints.js";
import type { ReadInfo } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
const POLL_INTERVAL = 1000;
const POLL_MAX = 20;
export interface ReadEntry {
fetching: boolean;
info: ReadInfo;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForJob(jobId: number) {
for (let attempt = 0; attempt < POLL_MAX; attempt += 1) {
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
await delay(POLL_INTERVAL);
const job = await getJob(jobId);
if (job.status !== "pending" && job.status !== "running") {
return;
}
}
}
function createReads() {
let entries = $state<Record<string, ReadEntry>>({});
const inflight = new Set<string>();
function key(chatId: number, messageId: number): string {
return `${accounts.selectedId}:${chatId}:${messageId}`;
}
async function load(entryKey: string, chatId: number, messageId: number) {
const info = await getReadInfo(chatId, messageId);
entries[entryKey] = { fetching: info.can_fetch, info };
if (!info.can_fetch) {
return;
}
try {
const { job_id } = await refreshReadInfo(chatId, messageId);
if (job_id !== null) {
await waitForJob(job_id);
}
entries[entryKey] = {
fetching: false,
info: await getReadInfo(chatId, messageId),
};
} finally {
entries[entryKey].fetching = false;
}
}
return {
get(chatId: number, messageId: number): ReadEntry | undefined {
return entries[key(chatId, messageId)];
},
open(chatId: number, messageId: number) {
const entryKey = key(chatId, messageId);
if (accounts.selectedId === null || inflight.has(entryKey)) {
return;
}
inflight.add(entryKey);
load(entryKey, chatId, messageId)
.catch(() => undefined)
.finally(() => {
inflight.delete(entryKey);
});
},
};
}
export const reads = createReads();
+1
View File
@@ -3,6 +3,7 @@ export type RightPanel =
| "search"
| "versions"
| "reactions"
| "readers"
| "callbacks"
| "links"
| "annotations"
+1 -1
View File
@@ -154,7 +154,7 @@
#RightColumn {
position: relative;
z-index: 1;
z-index: var(--z-right-column);
}
#MiddleColumn {