Compare commits
6
Commits
d8ad62fb59
...
73b8903de1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b8903de1 | ||
|
|
f44597b2dc | ||
|
|
bb38e23f29 | ||
|
|
f03d7c2890 | ||
|
|
d8dd08d7de | ||
|
|
f27943aae6 |
No files matched your search
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
|
|||||||
|
|
||||||
## MCP
|
## 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
|
## 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")
|
||||||
@@ -20,9 +20,20 @@ from api.mcp.download import (
|
|||||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.files import resolve_mime
|
from utils.files import resolve_mime
|
||||||
from utils.read import 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.accounts import self_user_id, unknown_account_hint
|
||||||
from utils.read.models import DEFAULT_LIMIT, Page
|
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.models import SearchFilters
|
||||||
from utils.search.repository import search_messages
|
from utils.search.repository import search_messages
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -295,6 +306,23 @@ async def get_callbacks(
|
|||||||
return _dump(items)
|
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
|
@mcp.tool
|
||||||
async def presence_history(
|
async def presence_history(
|
||||||
account_id: int,
|
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
|
@mcp.tool
|
||||||
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
||||||
"""Get name/username/avatar change history of a contact."""
|
"""Get name/username/avatar change history of a contact."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -58,16 +59,12 @@ class EventHub:
|
|||||||
return
|
return
|
||||||
account_id = event.get("account_id")
|
account_id = event.get("account_id")
|
||||||
chat_id = event.get("chat_id")
|
chat_id = event.get("chat_id")
|
||||||
scoped = event.get("kind") == "presence"
|
account_wide = event.get("kind") == "presence"
|
||||||
targets = [
|
targets = [
|
||||||
sub
|
sub
|
||||||
for sub in self._subscribers
|
for sub in self._subscribers
|
||||||
if sub.account_id == account_id
|
if sub.account_id == account_id
|
||||||
and (
|
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
|
||||||
sub.chat_id == chat_id
|
|
||||||
if scoped
|
|
||||||
else sub.chat_id is None or sub.chat_id == chat_id
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
if not targets:
|
if not targets:
|
||||||
return
|
return
|
||||||
@@ -114,6 +111,7 @@ class EventHub:
|
|||||||
"type": "receipt",
|
"type": "receipt",
|
||||||
"chat_id": event["chat_id"],
|
"chat_id": event["chat_id"],
|
||||||
"read_up_to": event["message_id"],
|
"read_up_to": event["message_id"],
|
||||||
|
"read_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import asyncpg
|
|||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from api.timezone import checked_tz
|
||||||
from utils.read import analytics
|
from utils.read import analytics
|
||||||
from utils.read.models import ResponseStats, VolumeBucket
|
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)
|
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
||||||
|
|
||||||
@@ -18,9 +20,12 @@ async def volume(
|
|||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
account_id: AccountId,
|
account_id: AccountId,
|
||||||
chat_id: ChatId,
|
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]:
|
) -> 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")
|
@router.get("/response-time")
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ import asyncpg
|
|||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from utils.read import presence
|
from api.timezone import checked_tz
|
||||||
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
|
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)
|
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
||||||
|
|
||||||
@@ -14,6 +23,8 @@ AccountId = Annotated[int, Query()]
|
|||||||
PeerId = Annotated[int, Query()]
|
PeerId = Annotated[int, Query()]
|
||||||
DateFrom = Annotated[datetime | None, Query()]
|
DateFrom = Annotated[datetime | None, Query()]
|
||||||
DateTo = Annotated[datetime | None, Query()]
|
DateTo = Annotated[datetime | None, Query()]
|
||||||
|
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
|
||||||
|
Tz = Annotated[str, Query()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -54,3 +65,43 @@ async def presence_hourly(
|
|||||||
return await presence.presence_hourly(
|
return await presence.presence_hourly(
|
||||||
pool, account_id, peer_id, date_from=date_from, date_to=date_to
|
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),
|
||||||
|
)
|
||||||
@@ -2,10 +2,13 @@ from typing import Annotated
|
|||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
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 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(
|
router = APIRouter(
|
||||||
prefix="/api/messages/{chat_id}/{message_id}",
|
prefix="/api/messages/{chat_id}/{message_id}",
|
||||||
@@ -16,6 +19,10 @@ router = APIRouter(
|
|||||||
AccountId = Annotated[int, Query()]
|
AccountId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callbacks")
|
@router.get("/callbacks")
|
||||||
async def message_callbacks(
|
async def message_callbacks(
|
||||||
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
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
|
pool: FromDishka[asyncpg.Pool], chat_id: int, message_id: int, account_id: AccountId
|
||||||
) -> list[LinkView]:
|
) -> list[LinkView]:
|
||||||
return await social.get_links(pool, account_id, chat_id, message_id)
|
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}
|
||||||
@@ -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 userbot.modules.read_receipts import repository
|
||||||
from utils.events import notify_bg_event
|
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(
|
async def handle(
|
||||||
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
|
client: PyroClient, update: raw.base.Update, _users: dict, _chats: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = client.capture
|
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
|
return
|
||||||
chat_id = utils.get_peer_id(update.peer)
|
chat_id, max_id = watermark
|
||||||
await repository.insert_read(
|
await repository.insert_read(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
ctx.account_id,
|
ctx.account_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
datetime.now(UTC),
|
datetime.now(UTC),
|
||||||
update.max_id,
|
max_id,
|
||||||
raw_json(update),
|
raw_json(update),
|
||||||
)
|
)
|
||||||
await notify_bg_event(
|
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_avatar,
|
||||||
fetch_custom_emoji,
|
fetch_custom_emoji,
|
||||||
fetch_media,
|
fetch_media,
|
||||||
|
fetch_read_info,
|
||||||
search_peers,
|
search_peers,
|
||||||
sync_contacts,
|
sync_contacts,
|
||||||
sync_dialogs,
|
sync_dialogs,
|
||||||
@@ -18,6 +19,7 @@ __all__ = [
|
|||||||
"fetch_avatar",
|
"fetch_avatar",
|
||||||
"fetch_custom_emoji",
|
"fetch_custom_emoji",
|
||||||
"fetch_media",
|
"fetch_media",
|
||||||
|
"fetch_read_info",
|
||||||
"search_peers",
|
"search_peers",
|
||||||
"sync_contacts",
|
"sync_contacts",
|
||||||
"sync_dialogs",
|
"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
|
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
|
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
|
async def insert_read( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
@@ -34,3 +54,33 @@ async def insert_listened( # noqa: PLR0913
|
|||||||
await pool.execute(
|
await pool.execute(
|
||||||
_INSERT, account_id, chat_id, message_id, ts, reader_id, "listened", raw
|
_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)
|
||||||
@@ -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.catchup import SavedState, catch_up, read_saved_state
|
||||||
from userbot.modules.client import PyroClient
|
from userbot.modules.client import PyroClient
|
||||||
from userbot.modules.jobs import JobConsumer
|
from userbot.modules.jobs import JobConsumer
|
||||||
|
from userbot.modules.uptime import repository as uptime
|
||||||
from utils.env import env
|
from utils.env import env
|
||||||
from utils.jobs import enqueue
|
from utils.jobs import enqueue
|
||||||
from utils.listen import Listener
|
from utils.listen import Listener
|
||||||
@@ -31,6 +32,7 @@ setup_logging()
|
|||||||
|
|
||||||
SESSION_FLUSH_INTERVAL = 60
|
SESSION_FLUSH_INTERVAL = 60
|
||||||
SESSION_RETRY_INTERVAL = 60
|
SESSION_RETRY_INTERVAL = 60
|
||||||
|
UPTIME_INTERVAL = 60
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -171,6 +173,15 @@ async def _retry_sessions(registry: AccountRegistry) -> None:
|
|||||||
logger.exception("[red]Session sync failed[/]")
|
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 _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
|
||||||
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
def spawn(coro: Coroutine[Any, Any, None]) -> None:
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
@@ -220,6 +231,7 @@ async def runner() -> None:
|
|||||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||||
flush = asyncio.create_task(_flush_sessions(registry))
|
flush = asyncio.create_task(_flush_sessions(registry))
|
||||||
retry = asyncio.create_task(_retry_sessions(registry))
|
retry = asyncio.create_task(_retry_sessions(registry))
|
||||||
|
beat = asyncio.create_task(_keep_uptime(registry, pool))
|
||||||
try:
|
try:
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
if not registry.clients:
|
if not registry.clients:
|
||||||
@@ -230,6 +242,7 @@ async def runner() -> None:
|
|||||||
finally:
|
finally:
|
||||||
await _cancel(flush)
|
await _cancel(flush)
|
||||||
await _cancel(retry)
|
await _cancel(retry)
|
||||||
|
await _cancel(beat)
|
||||||
await listener.stop()
|
await listener.stop()
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|||||||
@@ -4,23 +4,31 @@ import asyncpg
|
|||||||
|
|
||||||
from utils.read.accounts import self_user_id
|
from utils.read.accounts import self_user_id
|
||||||
from utils.read.models import ResponseStats, VolumeBucket
|
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(
|
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]:
|
) -> list[VolumeBucket]:
|
||||||
self_id = await self_user_id(pool, account_id)
|
self_id = await self_user_id(pool, account_id)
|
||||||
rows = await pool.fetch(
|
since, _ = await period_bounds(pool, days, tz)
|
||||||
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, "
|
rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
|
||||||
"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),
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
VolumeBucket(
|
VolumeBucket(
|
||||||
bucket=row["bucket"],
|
bucket=row["bucket"],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from utils.read.models import (
|
|||||||
MessageView,
|
MessageView,
|
||||||
Page,
|
Page,
|
||||||
)
|
)
|
||||||
from utils.read.read_receipts import read_up_to
|
from utils.read.read_receipts import read_times, read_up_to
|
||||||
|
|
||||||
_MESSAGE_COLS = (
|
_MESSAGE_COLS = (
|
||||||
"chat_id, message_id, date, sender_id, text, has_media, is_self_destruct, "
|
"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)
|
self_id = await self_user_id(pool, account_id)
|
||||||
if self_id is None:
|
if self_id is None:
|
||||||
return
|
return
|
||||||
marker = await read_up_to(pool, account_id, chat_id)
|
own = [view for view in views if view.sender_id == self_id]
|
||||||
if marker is None:
|
if not own:
|
||||||
return
|
return
|
||||||
for view in views:
|
marker = await read_up_to(pool, account_id, chat_id)
|
||||||
if view.sender_id == self_id and view.message_id <= marker:
|
times = await read_times(
|
||||||
view.read = True
|
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(
|
def _build_album(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -203,6 +203,7 @@ class MessageView(BaseModel):
|
|||||||
is_sticker: bool = False
|
is_sticker: bool = False
|
||||||
is_animated_emoji: bool = False
|
is_animated_emoji: bool = False
|
||||||
read: bool = False
|
read: bool = False
|
||||||
|
read_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MessageVersionView(BaseModel):
|
class MessageVersionView(BaseModel):
|
||||||
@@ -361,8 +362,61 @@ class PresenceHourly(BaseModel):
|
|||||||
last_seen: datetime | None
|
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):
|
class VolumeBucket(BaseModel):
|
||||||
bucket: datetime
|
bucket: date
|
||||||
total: int
|
total: int
|
||||||
outgoing: int
|
outgoing: int
|
||||||
incoming: int
|
incoming: int
|
||||||
@@ -387,6 +441,29 @@ class PeerView(BaseModel):
|
|||||||
updated_at: datetime
|
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):
|
class PeerHistoryView(BaseModel):
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
first_name: str | None
|
first_name: str | None
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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]
|
||||||
@@ -1,5 +1,89 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import asyncpg
|
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:
|
async def read_up_to(pool: asyncpg.Pool, account_id: int, chat_id: int) -> int | None:
|
||||||
return await pool.fetchval(
|
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,
|
account_id,
|
||||||
chat_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,
|
||||||
|
)
|
||||||
@@ -28,9 +28,11 @@ import type {
|
|||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PolicyRule,
|
PolicyRule,
|
||||||
PolicyScope,
|
PolicyScope,
|
||||||
PresenceHourly,
|
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
|
PresenceSession,
|
||||||
|
PresenceStats,
|
||||||
ReactionView,
|
ReactionView,
|
||||||
|
ReadInfo,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
@@ -205,30 +207,38 @@ export function getCurrentPresence(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPresenceHistory(
|
export function getPresenceStats(
|
||||||
peerId: number,
|
peerId: number,
|
||||||
|
days: number,
|
||||||
|
tz: string,
|
||||||
page: Page = {}
|
page: Page = {}
|
||||||
): Promise<PresenceSample[]> {
|
): Promise<PresenceStats> {
|
||||||
return request<PresenceSample[]>("/presence", {
|
return request<PresenceStats>("/presence/stats", {
|
||||||
account: true,
|
account: true,
|
||||||
query: { peer_id: peerId, ...page },
|
query: { days, peer_id: peerId, tz, ...page },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
export function getPresenceSessions(
|
||||||
return request<PresenceHourly[]>("/presence/hourly", {
|
peerId: number,
|
||||||
|
days: number,
|
||||||
|
tz: string,
|
||||||
|
page: Page = {}
|
||||||
|
): Promise<PresenceSession[]> {
|
||||||
|
return request<PresenceSession[]>("/presence/sessions", {
|
||||||
account: true,
|
account: true,
|
||||||
query: { peer_id: peerId },
|
query: { days, peer_id: peerId, tz, ...page },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageVolume(
|
export function getMessageVolume(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
days = 90
|
days: number,
|
||||||
|
tz: string
|
||||||
): Promise<VolumeBucket[]> {
|
): Promise<VolumeBucket[]> {
|
||||||
return request<VolumeBucket[]>("/analytics/volume", {
|
return request<VolumeBucket[]>("/analytics/volume", {
|
||||||
account: true,
|
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(
|
export function getMessageReactions(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
messageId: number
|
messageId: number
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export interface MessageView {
|
|||||||
quote: string | null;
|
quote: string | null;
|
||||||
reactions: ReactionCount[];
|
reactions: ReactionCount[];
|
||||||
read: boolean;
|
read: boolean;
|
||||||
|
read_at: string | null;
|
||||||
reply: ReplyView | null;
|
reply: ReplyView | null;
|
||||||
sender_id: number | null;
|
sender_id: number | null;
|
||||||
service: ServiceView | null;
|
service: ServiceView | null;
|
||||||
@@ -335,12 +336,57 @@ export interface PresenceSample {
|
|||||||
ts: string;
|
ts: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceHourly {
|
export interface PresenceSession {
|
||||||
bucket: string;
|
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;
|
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;
|
peer_id: number;
|
||||||
samples: number;
|
sessions: PresenceSession[];
|
||||||
|
since: string;
|
||||||
|
summary: PresenceSummary;
|
||||||
|
tz: string;
|
||||||
|
until: string;
|
||||||
|
week: PresenceHour[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolumeBucket {
|
export interface VolumeBucket {
|
||||||
@@ -369,6 +415,40 @@ export interface PeerView {
|
|||||||
username: string | null;
|
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 {
|
export interface PeerHistoryView {
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
is_deleted_account: boolean;
|
is_deleted_account: boolean;
|
||||||
@@ -518,6 +598,7 @@ export interface LivePresenceEvent {
|
|||||||
|
|
||||||
export interface LiveReceiptEvent {
|
export interface LiveReceiptEvent {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
|
read_at: string;
|
||||||
read_up_to: number;
|
read_up_to: number;
|
||||||
type: "receipt";
|
type: "receipt";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { enqueueBackfill, getPeer } from "#lib/api/endpoints.js";
|
||||||
enqueueBackfill,
|
import type { PeerView } from "#lib/api/types.js";
|
||||||
getCurrentPresence,
|
|
||||||
getPeer,
|
|
||||||
} from "#lib/api/endpoints.js";
|
|
||||||
import type { PeerView, PresenceSample } from "#lib/api/types.js";
|
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
@@ -16,6 +12,7 @@
|
|||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { discover } from "#lib/stores/discover.svelte.js";
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.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 { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -26,13 +23,10 @@
|
|||||||
|
|
||||||
let { chatId }: Props = $props();
|
let { chatId }: Props = $props();
|
||||||
|
|
||||||
const PRESENCE_INTERVAL = 30_000;
|
|
||||||
|
|
||||||
const isDm = $derived(chatId > 0);
|
const isDm = $derived(chatId > 0);
|
||||||
const chat = $derived(chats.byId(chatId));
|
const chat = $derived(chats.byId(chatId));
|
||||||
const discovered = $derived(discover.get(chatId));
|
const discovered = $derived(discover.get(chatId));
|
||||||
let peer = $state<PeerView | null>(null);
|
let peer = $state<PeerView | null>(null);
|
||||||
let presence = $state<PresenceSample | null>(null);
|
|
||||||
let backfilling = $state(false);
|
let backfilling = $state(false);
|
||||||
|
|
||||||
async function backfill() {
|
async function backfill() {
|
||||||
@@ -81,34 +75,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (accounts.selectedId === null || !isDm) {
|
presence.follow(accounts.selectedId, isDm ? chatId : null);
|
||||||
presence = null;
|
return () => presence.follow(null, 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);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackTitle = $derived(
|
const fallbackTitle = $derived(
|
||||||
@@ -119,8 +87,8 @@
|
|||||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||||
const subtitle = $derived.by(() => {
|
const subtitle = $derived.by(() => {
|
||||||
if (isDm) {
|
if (isDm) {
|
||||||
if (presence) {
|
if (presence.sample) {
|
||||||
return formatPresence(presence);
|
return formatPresence(presence.sample, presence.now);
|
||||||
}
|
}
|
||||||
if (peer?.username) {
|
if (peer?.username) {
|
||||||
return `@${peer.username}`;
|
return `@${peer.username}`;
|
||||||
@@ -176,7 +144,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<h2 class="title">{title}</h2>
|
<h2 class="title">{title}</h2>
|
||||||
<span class="subtitle" class:online={isDm && isOnline(presence)}>
|
<span
|
||||||
|
class={[
|
||||||
|
"subtitle",
|
||||||
|
{ online: isDm && isOnline(presence.sample, presence.now) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import Poll from "#lib/components/Poll.svelte";
|
import Poll from "#lib/components/Poll.svelte";
|
||||||
import Reactions from "#lib/components/Reactions.svelte";
|
import Reactions from "#lib/components/Reactions.svelte";
|
||||||
import ReplyHeader from "#lib/components/ReplyHeader.svelte";
|
import ReplyHeader from "#lib/components/ReplyHeader.svelte";
|
||||||
|
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
@@ -247,6 +248,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
|
{#if own}
|
||||||
|
<MessageReadItem {message} />
|
||||||
|
{/if}
|
||||||
<ContextMenuItem icon="note" onselect={openAnnotations}
|
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||||
>Заметки</ContextMenuItem
|
>Заметки</ContextMenuItem
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -363,13 +363,13 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyReceipt(readUpTo: number) {
|
function applyReceipt(readUpTo: number, readAt: string) {
|
||||||
if (ownId === null) {
|
if (ownId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messages = messages.map((m) =>
|
messages = messages.map((m) =>
|
||||||
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
||||||
? { ...m, read: true }
|
? { ...m, read: true, read_at: readAt }
|
||||||
: m
|
: m
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,7 @@
|
|||||||
) {
|
) {
|
||||||
applyDelete(event.message_ids);
|
applyDelete(event.message_ids);
|
||||||
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
} 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 TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
||||||
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
||||||
import VoiceMessage from "#lib/components/media/VoiceMessage.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 ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
@@ -222,6 +223,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
|
{#if own}
|
||||||
|
<MessageReadItem {message} />
|
||||||
|
{/if}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||||
>Открыть на весь экран</ContextMenuItem
|
>Открыть на весь экран</ContextMenuItem
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { MessageView } from "#lib/api/types.js";
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatTime } from "#lib/format/datetime.js";
|
import { formatTime } from "#lib/format/datetime.js";
|
||||||
|
import { readTitle } from "#lib/format/read.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
@@ -25,11 +26,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="time">{formatTime(message.date)}</span>
|
<span class="time">{formatTime(message.date)}</span>
|
||||||
{#if own}
|
{#if own}
|
||||||
<Icon
|
<span class="ticks" title={readTitle(message)}>
|
||||||
class="ticks"
|
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||||
name={message.read ? "message-read" : "check"}
|
</span>
|
||||||
size="1rem"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -52,6 +51,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.edited {
|
.edited {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
||||||
import ProfilePanel from "#lib/components/profile/ProfilePanel.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 ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte";
|
||||||
import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
||||||
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
||||||
@@ -18,13 +19,14 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.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 { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = {
|
const titles: Record<RightPanel, string> = $derived({
|
||||||
alerts: "Алерты",
|
alerts: "Алерты",
|
||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
bots: "Боты",
|
bots: "Боты",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
presence: "Аналитика",
|
presence: "Аналитика",
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
reactions: "Реакции",
|
reactions: "Реакции",
|
||||||
|
readers: t("read.readers.title"),
|
||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
storage: "Хранилище",
|
storage: "Хранилище",
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
watches: "Отслеживания",
|
watches: "Отслеживания",
|
||||||
};
|
});
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
"storage-chat": "storage",
|
"storage-chat": "storage",
|
||||||
@@ -104,6 +107,8 @@
|
|||||||
<ChatSearchPanel />
|
<ChatSearchPanel />
|
||||||
{:else if ui.rightPanel === "reactions"}
|
{:else if ui.rightPanel === "reactions"}
|
||||||
<ReactionsPanel />
|
<ReactionsPanel />
|
||||||
|
{:else if ui.rightPanel === "readers"}
|
||||||
|
<ReadersPanel />
|
||||||
{:else if ui.rightPanel === "callbacks"}
|
{:else if ui.rightPanel === "callbacks"}
|
||||||
<CallbacksPanel />
|
<CallbacksPanel />
|
||||||
{:else if ui.rightPanel === "links"}
|
{:else if ui.rightPanel === "links"}
|
||||||
|
|||||||
@@ -1,193 +1,295 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
getCurrentPresence,
|
|
||||||
getMessageVolume,
|
getMessageVolume,
|
||||||
getPresenceHistory,
|
getPresenceStats,
|
||||||
getPresenceHourly,
|
|
||||||
getResponseStats,
|
getResponseStats,
|
||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import type {
|
import type {
|
||||||
PresenceHourly,
|
PresenceStats,
|
||||||
PresenceSample,
|
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
VolumeBucket,
|
VolumeBucket,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
|
import OnlineDays from "#lib/components/presence/OnlineDays.svelte";
|
||||||
import PresenceHeatmap from "#lib/components/presence/PresenceHeatmap.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 VolumeChart from "#lib/components/presence/VolumeChart.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatPresence } from "#lib/format/presence.js";
|
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 { 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";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const HISTORY_LIMIT = 200;
|
const TEXT = {
|
||||||
const SECONDS_IN_MINUTE = 60;
|
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_HOUR = 3600;
|
||||||
const SECONDS_IN_DAY = 86_400;
|
const MS_IN_SECOND = 1000;
|
||||||
|
|
||||||
|
const tz = browserTimeZone();
|
||||||
|
const zone = timeZoneLabel();
|
||||||
|
|
||||||
const chatId = $derived(
|
const chatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
);
|
);
|
||||||
const isDm = $derived(chatId !== null && chatId > 0);
|
const isDm = $derived(chatId !== null && chatId > 0);
|
||||||
|
|
||||||
let current = $state<PresenceSample | null>(null);
|
let days = $state(DEFAULT_PERIOD);
|
||||||
let hourly = $state<PresenceHourly[]>([]);
|
let stats = $state<PresenceStats | null>(null);
|
||||||
let history = $state<PresenceSample[]>([]);
|
let volume = $state<VolumeBucket[] | null>(null);
|
||||||
let volume = $state<VolumeBucket[]>([]);
|
|
||||||
let response = $state<ResponseStats | 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 sample = $derived(
|
||||||
const result: PresenceSample[] = [];
|
presence.sample?.peer_id === chatId
|
||||||
let last: string | null = null;
|
? presence.sample
|
||||||
for (const sample of history) {
|
: (stats?.current ?? null)
|
||||||
if (sample.status !== last) {
|
);
|
||||||
result.push(sample);
|
const online = $derived(isOnline(sample, presence.now));
|
||||||
last = sample.status;
|
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;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDuration(seconds: number | null): string {
|
function reply(seconds: number | null): string {
|
||||||
if (seconds === null) {
|
return seconds === null ? TEXT.noData : formatSpan(seconds);
|
||||||
return "—";
|
|
||||||
}
|
}
|
||||||
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) {
|
try {
|
||||||
return `${Math.round(seconds / SECONDS_IN_MINUTE)} мин`;
|
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(() => {
|
$effect(() => {
|
||||||
if (accounts.selectedId === null || chatId === null) {
|
if (accounts.selectedId !== null && chatId !== null) {
|
||||||
return;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null}
|
{#if chatId === null}
|
||||||
<EmptyState description="Откройте чат" title="Аналитика" />
|
<EmptyState description={TEXT.openChat} title={TEXT.title} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="analytics">
|
<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>
|
<section>
|
||||||
<h3>Объём сообщений (90 дней)</h3>
|
<dl class="rows">
|
||||||
{#if volume.length === 0}
|
{#each rows as row (row.label)}
|
||||||
<p class="muted">{loading ? "Загрузка…" : "Нет сообщений"}</p>
|
<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}
|
{:else}
|
||||||
<VolumeChart {volume} />
|
<VolumeChart {volume} />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
{#if response && response.mine_count + response.their_count > 0}
|
||||||
|
<dl class="rows replies">
|
||||||
<section>
|
<div class="row">
|
||||||
<h3>Время ответа</h3>
|
<dt>{TEXT.mine}</dt>
|
||||||
{#if response}
|
<dd>{reply(response.mine_median_seconds)}</dd>
|
||||||
<div class="stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="value"
|
|
||||||
>{formatDuration(response.mine_median_seconds)}</span
|
|
||||||
>
|
|
||||||
<span class="caption">я отвечаю ({response.mine_count})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="row">
|
||||||
<span class="value"
|
<dt>{TEXT.theirs}</dt>
|
||||||
>{formatDuration(response.their_median_seconds)}</span
|
<dd>{reply(response.their_median_seconds)}</dd>
|
||||||
>
|
|
||||||
<span class="caption">мне отвечают ({response.their_count})</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
{:else}
|
|
||||||
<p class="muted">{loading ? "Загрузка…" : "Нет данных"}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if isDm}
|
{#if isDm && stats && tracked && !stats.hidden}
|
||||||
<section>
|
<section>
|
||||||
<div class="status" class:online={current?.status === "online"}>
|
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
|
||||||
{current ? formatPresence(current) : "нет данных"}
|
{#key `${chatId}:${shownDays}:${stats.until}`}
|
||||||
</div>
|
<PresenceSessions
|
||||||
|
days={shownDays}
|
||||||
|
first={stats.sessions}
|
||||||
|
pageSize={SESSION_PAGE}
|
||||||
|
peerId={stats.peer_id}
|
||||||
|
{tz}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Когда в сети</h3>
|
|
||||||
{#if hourly.length === 0}
|
|
||||||
<p class="muted">{loading ? "Загрузка…" : "Пока нет данных"}</p>
|
|
||||||
{:else}
|
|
||||||
<PresenceHeatmap {hourly} />
|
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -196,48 +298,89 @@
|
|||||||
.analytics {
|
.analytics {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
padding-bottom: 0.5rem;
|
||||||
padding: 1rem;
|
}
|
||||||
|
|
||||||
|
.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 {
|
section {
|
||||||
display: flex;
|
padding: 0.5rem 0;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
& + section {
|
||||||
|
border-top: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.hero {
|
||||||
margin: 0;
|
padding: 0.75rem 1rem 0.875rem;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.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);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-text-secondary);
|
font-variant-numeric: tabular-nums;
|
||||||
text-transform: uppercase;
|
line-height: 1.2;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.count {
|
||||||
display: flex;
|
font-size: 0.875rem;
|
||||||
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;
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
@@ -246,49 +389,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.note {
|
||||||
margin: 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.changes {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 0.75rem;
|
||||||
gap: 0.5rem;
|
align-items: baseline;
|
||||||
margin: 0;
|
justify-content: space-between;
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
padding: 0.375rem 1rem 0.625rem;
|
||||||
}
|
|
||||||
|
|
||||||
.changes li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
.dot {
|
h3 {
|
||||||
width: 0.5rem;
|
margin: 0;
|
||||||
height: 0.5rem;
|
font-size: inherit;
|
||||||
flex-shrink: 0;
|
font-weight: var(--font-weight-medium);
|
||||||
border-radius: 50%;
|
color: var(--color-primary);
|
||||||
background-color: var(--color-text-secondary);
|
white-space: nowrap;
|
||||||
|
|
||||||
&.online {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.rows {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
.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;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.state {
|
||||||
flex-shrink: 0;
|
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);
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<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 {
|
interface Props {
|
||||||
hourly: PresenceHourly[];
|
week: PresenceHour[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hourly }: Props = $props();
|
const { week }: Props = $props();
|
||||||
|
|
||||||
const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
const TEXT = {
|
||||||
const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
|
cell: (day: string, cell: PresenceHour) => {
|
||||||
const HOUR_TICKS = new Set([0, 6, 12, 18]);
|
const slot = `${day}, ${cell.hour}:00`;
|
||||||
|
if (cell.hours === 0) {
|
||||||
interface Cell {
|
return slot;
|
||||||
online: number;
|
|
||||||
total: number;
|
|
||||||
}
|
}
|
||||||
|
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 HOURS_IN_DAY = 24;
|
||||||
const cells: Cell[][] = DAYS.map(() =>
|
const HOUR_TICKS = [0, 6, 12, 18];
|
||||||
HOURS.map(() => ({ online: 0, total: 0 }))
|
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 peak = $derived(
|
||||||
const date = new Date(bucket.bucket);
|
Math.max(0, ...week.map((cell) => cell.online_minutes ?? 0))
|
||||||
const day = (date.getDay() + 6) % 7;
|
);
|
||||||
const cell = cells[day]?.[date.getHours()];
|
const hasGaps = $derived(week.some(missing));
|
||||||
if (cell) {
|
|
||||||
cell.online += bucket.online_samples;
|
|
||||||
cell.total += bucket.samples;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
});
|
|
||||||
|
|
||||||
function intensity(cell: Cell): number {
|
function missing(cell: PresenceHour): boolean {
|
||||||
return cell.total > 0 ? cell.online / cell.total : 0;
|
return cell.hours > 0 && cell.online_minutes === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellTitle(day: number, hour: number, cell: Cell): string {
|
function shade(minutes: number | null): number {
|
||||||
const pct = cell.total > 0 ? Math.round(intensity(cell) * 100) : 0;
|
if (minutes === null || minutes === 0 || peak === 0) {
|
||||||
return `${DAYS[day]} ${hour}:00 — ${pct}% online`;
|
return 0;
|
||||||
|
}
|
||||||
|
return MIN_SHADE + (1 - MIN_SHADE) * (minutes / peak);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="heatmap">
|
<div class="heatmap">
|
||||||
{#each grid as row, day (day)}
|
{#each rows as cells, weekday (weekday)}
|
||||||
<div class="heat-row">
|
<div class="row">
|
||||||
<span class="day-label">{DAYS[day]}</span>
|
<span class="day">{TEXT.days[weekday]}</span>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
{#each row as cell, hour (hour)}
|
{#each cells as cell (cell.hour)}
|
||||||
<span
|
<span
|
||||||
class="cell"
|
class={[
|
||||||
title={cellTitle(day, hour, cell)}
|
"cell",
|
||||||
style:--i={intensity(cell)}
|
{
|
||||||
class:empty={cell.total === 0}
|
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>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="heat-row axis">
|
<div class="row">
|
||||||
<span class="day-label"></span>
|
<span class="day"></span>
|
||||||
<div class="cells">
|
<div class="ticks">
|
||||||
{#each HOURS as hour (hour)}
|
{#each HOUR_TICKS as hour (hour)}
|
||||||
<span class="tick">{HOUR_TICKS.has(hour) ? hour : ""}</span>
|
<span style:left="{(hour / HOURS_IN_DAY) * 100}%">{hour}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.heatmap {
|
.heatmap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heat-row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label {
|
.day {
|
||||||
width: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.6875rem;
|
width: 1.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cells {
|
.cells {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
grid-template-columns: repeat(24, 1fr);
|
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-color: var(--color-primary);
|
background-color: color-mix(
|
||||||
opacity: calc(0.12 + 0.88 * var(--i));
|
in srgb,
|
||||||
|
var(--color-primary) var(--shade),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
|
||||||
|
&.zero {
|
||||||
|
background-color: rgba(var(--color-text-secondary-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.outside {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
background-color: var(--color-text-secondary);
|
background: repeating-linear-gradient(
|
||||||
opacity: 0.08;
|
135deg,
|
||||||
|
rgba(var(--color-text-secondary-rgb), 0.45) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tick {
|
.ticks {
|
||||||
font-size: 0.625rem;
|
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);
|
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>
|
</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">
|
<script lang="ts">
|
||||||
import type { VolumeBucket } from "#lib/api/types.js";
|
import type { VolumeBucket } from "#lib/api/types.js";
|
||||||
|
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
volume: VolumeBucket[];
|
volume: VolumeBucket[];
|
||||||
@@ -7,47 +8,104 @@
|
|||||||
|
|
||||||
const { volume }: Props = $props();
|
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 {
|
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||||
return `${bucket.bucket.slice(0, 10)} — всего ${bucket.total} (вх ${bucket.incoming} / исх ${bucket.outgoing})`;
|
const first = $derived(volume[0]);
|
||||||
|
|
||||||
|
function label(day: string): string {
|
||||||
|
return formatDay(parseDay(day).toISOString());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="volume">
|
<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)}
|
{#each volume as bucket (bucket.bucket)}
|
||||||
|
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class={["bar", { zero: bucket.total === 0 }]}
|
||||||
title={barTitle(bucket)}
|
style:height="{(bucket.total / peak) * 100}%"
|
||||||
style:height={`${(bucket.total / max) * 100}%`}
|
|
||||||
>
|
>
|
||||||
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
|
|
||||||
<div class="seg in" style:flex-grow={bucket.incoming}></div>
|
<div class="seg in" style:flex-grow={bucket.incoming}></div>
|
||||||
|
<div class="seg out" style:flex-grow={bucket.outgoing}></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if first}
|
||||||
|
<div class="axis">
|
||||||
|
<span>{label(first.bucket)}</span>
|
||||||
|
<span>{TEXT.today}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="key"><span class="swatch out"></span>исходящие</span>
|
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
|
||||||
<span class="key"><span class="swatch in"></span>входящие</span>
|
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<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 {
|
.bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
height: 6rem;
|
||||||
|
|
||||||
|
&.dense {
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: 7.5rem;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
min-height: 2px;
|
min-height: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px 2px 0 0;
|
border-radius: 2px 2px 0 0;
|
||||||
|
|
||||||
|
&.zero {
|
||||||
|
background-color: rgba(var(--color-text-secondary-rgb), 0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg.out {
|
.seg.out {
|
||||||
@@ -55,8 +113,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seg.in {
|
.seg.in {
|
||||||
background-color: var(--color-text-secondary);
|
background-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||||
opacity: 0.4;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.legend {
|
||||||
@@ -69,8 +137,8 @@
|
|||||||
|
|
||||||
.key {
|
.key {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
@@ -83,8 +151,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.in {
|
&.in {
|
||||||
background-color: var(--color-text-secondary);
|
background-color: color-mix(
|
||||||
opacity: 0.4;
|
in srgb,
|
||||||
|
var(--color-primary) 40%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -9,19 +9,25 @@ const OPTIONS = {
|
|||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
},
|
},
|
||||||
|
longDay: { day: "numeric", month: "long", weekday: "long" },
|
||||||
|
offset: { timeZoneName: "shortOffset" },
|
||||||
time: { hour: "2-digit", minute: "2-digit" },
|
time: { hour: "2-digit", minute: "2-digit" },
|
||||||
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
} satisfies Record<string, Intl.DateTimeFormatOptions>;
|
||||||
|
|
||||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
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}`;
|
const key = `${locale.tag}:${kind}`;
|
||||||
let formatter = formatters.get(key);
|
let cached = formatters.get(key);
|
||||||
if (!formatter) {
|
if (!cached) {
|
||||||
formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
cached = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]);
|
||||||
formatters.set(key, formatter);
|
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 {
|
export function formatTime(iso: string): string {
|
||||||
@@ -47,3 +53,24 @@ export function formatListDate(iso: string | null): string {
|
|||||||
}
|
}
|
||||||
return format("day", date);
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,117 @@
|
|||||||
import type { PresenceSample } from "#lib/api/types.js";
|
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 {
|
const SECONDS_IN_MINUTE = 60;
|
||||||
if (sample === null || sample.status !== "online") {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (sample.next_offline_date === null) {
|
return new Date(sample.next_offline_date).getTime() > now;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return new Date(sample.next_offline_date).getTime() > Date.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPresence(sample: PresenceSample): string {
|
function lastSeen(iso: string, now: number): string {
|
||||||
switch (sample.status) {
|
const date = new Date(iso);
|
||||||
case "online":
|
const minutes = Math.floor((now - date.getTime()) / MS_IN_MINUTE);
|
||||||
return isOnline(sample)
|
if (minutes < 1) {
|
||||||
? "online"
|
return TEXT.justNow;
|
||||||
: lastSeen(sample.last_online_date ?? sample.ts);
|
}
|
||||||
case "recently":
|
if (minutes < MINUTES_IN_HOUR) {
|
||||||
return "last seen recently";
|
return TEXT.ago(minutes);
|
||||||
case "last_week":
|
}
|
||||||
return "last seen within a week";
|
const time = formatTime(iso);
|
||||||
case "last_month":
|
const today = new Date(now);
|
||||||
return "last seen within a month";
|
if (date.toDateString() === today.toDateString()) {
|
||||||
case "long_time_ago":
|
return TEXT.today(time);
|
||||||
return "last seen a long time ago";
|
}
|
||||||
default:
|
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
|
return sample.last_online_date
|
||||||
? lastSeen(sample.last_online_date)
|
? lastSeen(sample.last_online_date, now)
|
||||||
: "offline";
|
: TEXT.offline;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastSeen(date: string): string {
|
export function formatSpan(seconds: number): string {
|
||||||
return `last seen ${formatListDate(date)}`;
|
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)}`;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { account } from "#lib/i18n/messages/account.js";
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
import { common } from "#lib/i18n/messages/common.js";
|
import { common } from "#lib/i18n/messages/common.js";
|
||||||
import { menu } from "#lib/i18n/messages/menu.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";
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
...account,
|
...account,
|
||||||
...common,
|
...common,
|
||||||
...menu,
|
...menu,
|
||||||
|
...read,
|
||||||
...settings,
|
...settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -3,6 +3,7 @@ export type RightPanel =
|
|||||||
| "search"
|
| "search"
|
||||||
| "versions"
|
| "versions"
|
||||||
| "reactions"
|
| "reactions"
|
||||||
|
| "readers"
|
||||||
| "callbacks"
|
| "callbacks"
|
||||||
| "links"
|
| "links"
|
||||||
| "annotations"
|
| "annotations"
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
#RightColumn {
|
#RightColumn {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: var(--z-right-column);
|
||||||
}
|
}
|
||||||
|
|
||||||
#MiddleColumn {
|
#MiddleColumn {
|
||||||
|
|||||||
Reference in new issue
Block a user