Compare commits

..
15 Commits
Author SHA1 Message Date
hh 543e6eaa50 feat(frontend): service messages speak russian and english 2026-10-19 05:32:34 +00:00
hh 7baa32f400 chore(frontend): notes dictionary drops a string the shared ui one replaced 2026-10-19 05:26:53 +00:00
hh e977ab77a8 fix(frontend): a filter written to the url survives another url write, the chat picked with alt and arrows stays in view 2026-10-19 05:26:20 +00:00
hh 8a1723e99f fix(frontend): panel history survives a link that rewrites its own url, dictionaries reuse the thread and panel strings of the feed 2026-10-19 05:26:20 +00:00
hh c06597aba2 chore(backend): chat stats migration follows the search migration 2026-10-19 05:26:11 +00:00
hh 7f3458b3ef docs: mcp tool list matches the server 2026-10-19 05:26:11 +00:00
hh 4a32e32114 feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history 2026-10-19 05:26:11 +00:00
hh 9c556ff8b9 feat(frontend): notes list with tag chips and the tag in the url, from the menu and settings, capture status dot on the menu and a line in settings 2026-10-19 05:26:11 +00:00
hh 171d8d922d feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages 2026-10-19 05:25:54 +00:00
hh 848d2aca1a feat(frontend): right panel lives in browser history and back closes it first, message panels close on chat switch, hotkeys for search, chats, in-chat search and closing 2026-10-19 05:25:54 +00:00
hh 7f7fc1296e feat(watcher): watched bots report capture uptime like the userbot 2026-10-19 05:25:44 +00:00
hh f62c345e2b feat(backend): who of the given people is online now in one request 2026-10-19 05:25:44 +00:00
hh 9ae8b34005 feat(backend): capture status and the stretches the archive was not recording 2026-10-19 05:25:44 +00:00
hh a0e134756c feat(backend): notes list across chats with the message and chat they belong to, filter by tag, tag counts 2026-10-19 05:25:44 +00:00
hh 97f9082f5e feat(backend): chat list marks a deleted last message, history pages mark messages with a note 2026-10-19 05:25:44 +00:00
53 changed files with 2397 additions and 210 deletions

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out. - **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
- **Presence** as a time series (TimescaleDB), read receipts, stories. - **Presence** as a time series (TimescaleDB), read receipts, stories.
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default. - **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client. - **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API. - **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
Nothing is ever deleted from the archive. Nothing is ever deleted from the archive.
@@ -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_feed`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `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: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be.
## Run ## Run
@@ -0,0 +1,97 @@
"""chat stats last deleted
Revision ID: f1c4a8e2d6b3
Revises: d8f1a6c3e5b7
Create Date: 2026-10-19 04:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "f1c4a8e2d6b3"
down_revision: str | None = "d8f1a6c3e5b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_APPLY = """
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
last_date, last_message_id,
last_text, last_sender_id{column})
VALUES (NEW.account_id, NEW.chat_id, 1,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.date END,
CASE WHEN NEW.date <= now() + interval '1 day'
THEN NEW.message_id END,
NEW.text, NEW.sender_id{value})
ON CONFLICT (account_id, chat_id) DO UPDATE SET
message_count = cs.message_count + 1,
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_date ELSE cs.last_date END,
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_message_id
ELSE cs.last_message_id END,
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_text ELSE cs.last_text END,
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
THEN EXCLUDED.last_sender_id
ELSE cs.last_sender_id END{merge};
ELSE
UPDATE chat_stats
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
WHERE account_id = NEW.account_id
AND chat_id = NEW.chat_id
AND last_message_id = NEW.message_id;
END IF;
RETURN NULL;
END;
$$
"""
_DELETED_PARTS = {
"column": ", last_deleted",
"value": ", NEW.deleted_at IS NOT NULL",
"merge": (
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
),
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
}
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
_UPDATE_TRIGGER = (
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
"EXECUTE FUNCTION chat_stats_apply()"
)
_BACKFILL = """
UPDATE chat_stats cs SET last_deleted = true
FROM messages m
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
AND m.deleted_at IS NOT NULL
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
)
op.execute(_BACKFILL)
op.execute(_APPLY.format(**_DELETED_PARTS))
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
op.execute(_APPLY.format(**_PLAIN_PARTS))
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
+2
View File
@@ -21,6 +21,7 @@ from api.routers import (
avatars, avatars,
backfill, backfill,
bots, bots,
capture,
chats, chats,
custom_emoji, custom_emoji,
discover, discover,
@@ -114,6 +115,7 @@ app.include_router(webhooks.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(storage.router) app.include_router(storage.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app) app.mount("/mcp", mcp_app)
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.read import annotations from utils.read import annotations
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
router = APIRouter( router = APIRouter(
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
@@ -42,6 +42,26 @@ async def list_annotations(
) )
@router.get("/notes")
async def list_notes(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
tag: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[NoteView]:
return await annotations.list_notes(
pool, account_id, Page(limit=limit, offset=offset), tag=tag
)
@router.get("/tags")
async def list_tags(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[NoteTag]:
return await annotations.list_tags(pool, account_id)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_annotation( async def create_annotation(
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.read import capture
from utils.read.models import CaptureGap, CaptureStatus
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
AccountId = Annotated[int, Query()]
@router.get("/status")
async def capture_status(
pool: FromDishka[asyncpg.Pool], account_id: AccountId
) -> CaptureStatus:
return await capture.capture_status(pool, account_id)
@router.get("/gaps")
async def capture_gaps(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> list[CaptureGap]:
return await capture.capture_gaps(
pool, account_id, date_from=date_from, date_to=date_to
)
+11
View File
@@ -9,6 +9,7 @@ from api.timezone import checked_tz
from utils.read import presence, presence_stats from utils.read import presence, presence_stats
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
MAX_LIMIT,
Page, Page,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
@@ -54,6 +55,16 @@ async def current_presence(
return await presence.current_presence(pool, account_id, peer_id) return await presence.current_presence(pool, account_id, peer_id)
@router.get("/online")
async def online_now(
pool: FromDishka[asyncpg.Pool],
account_id: AccountId,
peer_ids: Annotated[str, Query()],
) -> list[PresenceSample]:
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
@router.get("/hourly") @router.get("/hourly")
async def presence_hourly( async def presence_hourly(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
+72 -1
View File
@@ -1,9 +1,34 @@
import re
from collections import Counter
import asyncpg import asyncpg
from utils.read.models import AnnotationView, Page from utils.read.chats import get_chats
from utils.read.models import AnnotationView, NoteTag, NoteView, Page
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at" _COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
TAG = re.compile(r"(?<!\w)#(\w+)")
WORD = re.compile(r"\w+")
_NOTES = """
SELECT a.id, a.account_id, a.chat_id, a.message_id, a.text,
a.created_at, a.updated_at,
m.text AS message_text, m.date AS message_date,
COALESCE(m.has_media, false) AS message_has_media,
m.deleted_at IS NOT NULL AS message_deleted
FROM annotations a
LEFT JOIN LATERAL (
SELECT text, date, has_media, deleted_at FROM messages
WHERE account_id = a.account_id AND chat_id = a.chat_id
AND message_id = a.message_id
LIMIT 1
) m ON true
WHERE a.account_id = $1 AND ($4::text IS NULL OR a.text ~* $4)
ORDER BY a.created_at DESC, a.id DESC
LIMIT $2 OFFSET $3
"""
async def list_annotations( async def list_annotations(
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -70,3 +95,49 @@ async def update_annotation(
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool: async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id) result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
return result.endswith("1") return result.endswith("1")
def _tag_pattern(tag: str | None) -> str | None:
if tag is None:
return None
return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])"
async def list_notes(
pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None
) -> list[NoteView]:
if tag is not None and not WORD.fullmatch(tag):
return []
rows = await pool.fetch(
_NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag)
)
chats = {
chat.chat_id: chat
for chat in await get_chats(
pool, account_id, list({row["chat_id"] for row in rows})
)
}
notes: list[NoteView] = []
for row in rows:
chat = chats.get(row["chat_id"])
notes.append(
NoteView(
**dict(row),
chat_title=chat.title if chat else None,
chat_has_avatar=chat.has_avatar if chat else False,
)
)
return notes
async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]:
rows = await pool.fetch(
"SELECT text FROM annotations WHERE account_id = $1", account_id
)
counts: Counter[str] = Counter()
for row in rows:
counts.update({tag.lower() for tag in TAG.findall(row["text"])})
return [
NoteTag(tag=tag, count=count)
for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
]
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
import asyncpg
from utils.read.models import CaptureGap, CaptureStatus
_STATUS = """
SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live
FROM capture_uptime WHERE account_id = $1
ORDER BY started_at DESC LIMIT 1
"""
_GAPS = """
SELECT seen_at AS start, next_start AS "end"
FROM (
SELECT seen_at,
lead(started_at) OVER (ORDER BY started_at) AS next_start
FROM capture_uptime WHERE account_id = $1
) stretches
WHERE next_start - seen_at > interval '30 minutes'
AND ($2::timestamptz IS NULL OR next_start >= $2)
AND ($3::timestamptz IS NULL OR seen_at <= $3)
ORDER BY seen_at
"""
async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus:
row = await pool.fetchrow(_STATUS, account_id)
if row is None:
return CaptureStatus(live=False, since=None, last_seen=None)
return CaptureStatus(
live=row["live"], since=row["started_at"], last_seen=row["seen_at"]
)
async def capture_gaps(
pool: asyncpg.Pool,
account_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> list[CaptureGap]:
rows = await pool.fetch(_GAPS, account_id, date_from, date_to)
return [CaptureGap(**dict(row)) for row in rows]
+23 -1
View File
@@ -80,7 +80,8 @@ _CHAT_ROWS = """
WITH ids AS ({ids}) WITH ids AS ({ids})
SELECT ids.chat_id, SELECT ids.chat_id,
COALESCE(cs.message_count, 0) AS message_count, COALESCE(cs.message_count, 0) AS message_count,
cs.last_date, cs.last_text, cs.last_sender_id, cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id,
COALESCE(cs.last_deleted, false) AS last_deleted,
COALESCE(named.title, COALESCE(named.title,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''), NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title, p.username) AS title,
@@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
last_date=row["last_date"], last_date=row["last_date"],
last_text=row["last_text"], last_text=row["last_text"],
last_sender_id=row["last_sender_id"], last_sender_id=row["last_sender_id"],
last_message_id=row["last_message_id"],
last_deleted=row["last_deleted"],
) )
@@ -259,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913
views.append(_build_album(members, media_by_key)) views.append(_build_album(members, media_by_key))
index = end index = end
await _apply_read_status(pool, account_id, chat_id, views) await _apply_read_status(pool, account_id, chat_id, views)
await _apply_notes(pool, account_id, chat_id, views)
return views return views
@@ -278,6 +282,7 @@ async def get_message(
raw = load_raw(row["raw"]) raw = load_raw(row["raw"])
view = build_message_view(row, raw, _single_media(row, raw, media_by_key)) view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
await _apply_read_status(pool, account_id, chat_id, [view]) await _apply_read_status(pool, account_id, chat_id, [view])
await _apply_notes(pool, account_id, chat_id, [view])
return view return view
@@ -301,6 +306,23 @@ async def _apply_read_status(
) )
async def _apply_notes(
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
) -> None:
if not views:
return
rows = await pool.fetch(
"SELECT DISTINCT message_id FROM annotations "
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])",
account_id,
chat_id,
[view.message_id for view in views],
)
noted = {row["message_id"] for row in rows}
for view in views:
view.has_note = view.message_id in noted
def _build_album( def _build_album(
members: list[tuple[asyncpg.Record, dict]], members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record], media_by_key: dict[tuple[int, int], asyncpg.Record],
+28
View File
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
last_date: datetime | None last_date: datetime | None
last_text: str | None last_text: str | None
last_sender_id: int | None last_sender_id: int | None
last_message_id: int | None = None
last_deleted: bool = False
class DiscoverItem(BaseModel): class DiscoverItem(BaseModel):
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
is_animated_emoji: bool = False is_animated_emoji: bool = False
read: bool = False read: bool = False
read_at: datetime | None = None read_at: datetime | None = None
has_note: bool = False
class MessageVersionView(BaseModel): class MessageVersionView(BaseModel):
@@ -611,6 +614,31 @@ class AnnotationView(BaseModel):
updated_at: datetime updated_at: datetime
class NoteView(AnnotationView):
chat_title: str | None
chat_has_avatar: bool
message_text: str | None
message_date: datetime | None
message_has_media: bool
message_deleted: bool
class NoteTag(BaseModel):
tag: str
count: int
class CaptureStatus(BaseModel):
live: bool
since: datetime | None
last_seen: datetime | None
class CaptureGap(BaseModel):
start: datetime
end: datetime
class WatchView(BaseModel): class WatchView(BaseModel):
id: int id: int
account_id: int account_id: int
+23
View File
@@ -48,6 +48,29 @@ async def current_presence(
return PresenceSample(**dict(row)) if row is not None else None return PresenceSample(**dict(row)) if row is not None else None
_ONLINE = """
SELECT peer_id, ts, status, last_online_date, next_offline_date
FROM (
SELECT DISTINCT ON (peer_id)
peer_id, ts, status, last_online_date, next_offline_date
FROM presence
WHERE account_id = $1 AND peer_id = ANY($2::bigint[])
AND ts > now() - interval '1 hour'
ORDER BY peer_id, ts DESC
) latest
WHERE status = 'online' AND next_offline_date > now()
"""
async def online_now(
pool: asyncpg.Pool, account_id: int, peer_ids: list[int]
) -> list[PresenceSample]:
if not peer_ids:
return []
rows = await pool.fetch(_ONLINE, account_id, peer_ids)
return [PresenceSample(**dict(row)) for row in rows]
async def presence_hourly( async def presence_hourly(
pool: asyncpg.Pool, pool: asyncpg.Pool,
account_id: int, account_id: int,
+6
View File
@@ -113,6 +113,12 @@ class BotRegistry:
def running(self) -> list[int]: def running(self) -> list[int]:
return list(self._running) return list(self._running)
@property
def connected_accounts(self) -> list[int]:
return [
bot.account_id for bot in self._running.values() if bot.client.is_connected
]
async def sync(self) -> None: async def sync(self) -> None:
async with self._lock: async with self._lock:
await self._adopt_dropped_in() await self._adopt_dropped_in()
+14 -2
View File
@@ -6,6 +6,7 @@ import asyncpg
import uvloop import uvloop
from dependencies.container import container from dependencies.container import container
from userbot.modules.uptime import repository as uptime
from utils.listen import Listener from utils.listen import Listener
from utils.logging import logger, setup_logging from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL from utils.read.bots import BOTS_CHANGED_CHANNEL
@@ -16,6 +17,7 @@ from watcher.registry import BotRegistry
setup_logging() setup_logging()
RETRY_INTERVAL = 300 RETRY_INTERVAL = 300
UPTIME_INTERVAL = 60
async def _retry_loop(registry: BotRegistry) -> None: async def _retry_loop(registry: BotRegistry) -> None:
@@ -27,6 +29,14 @@ async def _retry_loop(registry: BotRegistry) -> None:
logger.exception("[red]Bot sync failed[/]") logger.exception("[red]Bot sync failed[/]")
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
while True:
for account_id in registry.connected_accounts:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener: def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
def on_change(_payload: str) -> None: def on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync()) task = asyncio.create_task(registry.sync())
@@ -58,6 +68,7 @@ async def runner() -> None:
stop = asyncio.Event() stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set) asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry)) retry = asyncio.create_task(_retry_loop(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try: try:
await registry.sync() await registry.sync()
if not registry.running: if not registry.running:
@@ -66,9 +77,10 @@ async def runner() -> None:
logger.info("[green]Bot watcher running.[/]") logger.info("[green]Bot watcher running.[/]")
await stop.wait() await stop.wait()
finally: finally:
retry.cancel() for task in (retry, beat):
task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await retry await task
await listener.stop() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+6 -5
View File
@@ -1,13 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts import type { PanelState } from "#lib/stores/ui.svelte.js";
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} interface PageState {
inApp?: boolean;
panel?: PanelState;
}
// interface Platform {} // interface Platform {}
} }
} }
export {};
+32
View File
@@ -0,0 +1,32 @@
import { request } from "#lib/api/client.js";
import type { PresenceSample } from "#lib/api/types.js";
export interface CaptureStatus {
last_seen: string | null;
live: boolean;
since: string | null;
}
export interface CaptureGap {
end: string;
start: string;
}
export function getCaptureStatus(accountId: number): Promise<CaptureStatus> {
return request<CaptureStatus>("/capture/status", {
query: { account_id: accountId },
});
}
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
return request<CaptureGap[]>("/capture/gaps", {
query: { account_id: accountId },
});
}
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
return request<PresenceSample[]>("/presence/online", {
account: true,
query: { peer_ids: peerIds.join(",") },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { request } from "#lib/api/client.js";
import type { Annotation } from "#lib/api/types.js";
export interface Note extends Annotation {
chat_has_avatar: boolean;
chat_title: string | null;
message_date: string | null;
message_deleted: boolean;
message_has_media: boolean;
message_text: string | null;
}
export interface NoteTag {
count: number;
tag: string;
}
export function listNotes(
tag: string | null,
offset: number,
limit: number
): Promise<Note[]> {
return request<Note[]>("/annotations/notes", {
account: true,
query: { limit, offset, tag },
});
}
export function listNoteTags(): Promise<NoteTag[]> {
return request<NoteTag[]>("/annotations/tags", { account: true });
}
+3
View File
@@ -45,6 +45,8 @@ export interface Chat {
is_contact: boolean; is_contact: boolean;
kind: ChatKind; kind: ChatKind;
last_date: string | null; last_date: string | null;
last_deleted: boolean;
last_message_id: number | null;
last_sender_id: number | null; last_sender_id: number | null;
last_text: string | null; last_text: string | null;
message_count: number; message_count: number;
@@ -196,6 +198,7 @@ export interface MessageView {
entities: EntityView[]; entities: EntityView[];
forward: ForwardView | null; forward: ForwardView | null;
has_media: boolean; has_media: boolean;
has_note: boolean;
inline_buttons: InlineButton[][]; inline_buttons: InlineButton[][];
is_animated_emoji: boolean; is_animated_emoji: boolean;
is_self_destruct: boolean; is_self_destruct: boolean;
@@ -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 { accountName, peerName } from "#lib/format/peer.js"; import { accountName, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
@@ -16,10 +17,12 @@
function nameOf(id: number | null): string { function nameOf(id: number | null): string {
if (id === null) { if (id === null) {
return "Someone"; return t("service.someone");
} }
if (id === ownId) { if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You"; return accounts.selected
? accountName(accounts.selected)
: t("service.you");
} }
const peer = peers.get(id); const peer = peers.get(id);
return peer ? peerName(peer) : String(id); return peer ? peerName(peer) : String(id);
@@ -44,35 +47,39 @@
const selfJoin = const selfJoin =
members.length === 1 && members[0] === message.sender_id; members.length === 1 && members[0] === message.sender_id;
if (selfJoin) { if (selfJoin) {
return `${actor} joined the group`; return t("service.joined", { actor });
} }
const names = members.map(nameOf).join(", "); const names = members.map(nameOf).join(", ");
return names ? `${actor} added ${names}` : `${actor} joined the group`; return names
? t("service.added", { actor, names })
: t("service.joined", { actor });
} }
case "left_chat_members": case "left_chat_members":
return `${actor} left the group`; return t("service.left", { actor });
case "pinned_message": case "pinned_message":
return `${actor} pinned a message`; return t("service.pinned", { actor });
case "group_chat_created": case "group_chat_created":
return `${actor} created the group`; return t("service.groupCreated", { actor });
case "channel_chat_created": case "channel_chat_created":
return "Channel created"; return t("service.channelCreated");
case "phone_call_ended": case "phone_call_ended":
return service.duration === null return service.duration === null
? "Call ended" ? t("service.callEnded")
: `Call ended · ${durationOf(service.duration)}`; : t("service.callEndedAfter", {
duration: durationOf(service.duration),
});
case "phone_call_started": case "phone_call_started":
return "Call started"; return t("service.callStarted");
case "poll_option_added": case "poll_option_added":
return `${actor} added a poll option`; return t("service.pollOption", { actor });
case "new_chat_title": case "new_chat_title":
return `${actor} changed the group name`; return t("service.titleChanged", { actor });
case "new_chat_photo": case "new_chat_photo":
return `${actor} changed the group photo`; return t("service.photoChanged", { actor });
case "delete_chat_photo": case "delete_chat_photo":
return `${actor} removed the group photo`; return t("service.photoRemoved", { actor });
default: default:
return service.kind.replace(/_/g, " ") || "Service message"; return t("service.unknown");
} }
}); });
@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from "bits-ui";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
anchor: HTMLElement | null;
chatId: number;
month: { month: number; year: number } | null;
open: boolean;
}
let { anchor, chatId, month, open = $bindable() }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Portal>
<Popover.Content
aria-label={t("chat.calendar")}
class="bg-menu-content calendar-popover"
customAnchor={anchor}
side="bottom"
sideOffset={6}
>
{#key month}
<ChatCalendar
{chatId}
initial={month}
onjump={() => {
open = false;
}}
/>
{/key}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.calendar-popover) {
width: min(20rem, calc(100vw - 1.5rem));
padding: 0;
background-color: var(--color-background);
}
</style>
+19 -3
View File
@@ -29,8 +29,11 @@
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));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null); let peer = $state<PeerView | null>(null);
let backfilling = $state(false); let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() { async function backfill() {
if (backfilling) { if (backfilling) {
@@ -39,7 +42,10 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success(t("chat.backfill.started")); backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch { } catch {
toasts.error(t("chat.backfill.failed")); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
@@ -48,6 +54,10 @@
} }
function back() { function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href); const target = new URL(page.url.href);
target.pathname = "/app"; target.pathname = "/app";
goto(withoutMessage(target)); goto(withoutMessage(target));
@@ -216,14 +226,15 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label={t("chat.backfill")} aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
smaller smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={backfilled ? "check" : "cloud-download"} />
</Button> </Button>
</div> </div>
</header> </header>
@@ -287,8 +298,13 @@
} }
.subtitle { .subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online { &.online {
color: var(--color-primary); color: var(--color-primary);
@@ -76,6 +76,27 @@
} }
}); });
$effect(() => {
const id = activeChatId;
untrack(() => {
const index = list.findIndex((chat) => chat.chat_id === id);
if (index === -1 || viewport === null) {
return;
}
const row = viewport.querySelector<HTMLElement>(".Chat.selected");
if (row) {
row.scrollIntoView({ block: "nearest" });
return;
}
const top = index * rowHeight;
if (top < viewport.scrollTop) {
viewport.scrollTop = top;
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
}
});
});
function measure(el: HTMLElement) { function measure(el: HTMLElement) {
({ scrollTop } = el); ({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -4,9 +4,11 @@
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";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { online } from "#lib/stores/online.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -31,6 +33,14 @@
? t("chat.deletedAccount") ? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id })) : t("chat.fallbackTitle", { id: chat.chat_id }))
); );
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
const isOnline = $derived(person && online.isOnline(chat.chat_id));
$effect(() => {
if (person) {
online.watch(chat.chat_id);
}
});
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived( const showSender = $derived(
@@ -74,12 +84,14 @@
class:selected class:selected
use:ripple use:ripple
> >
<span class="avatar" class:online={isOnline}>
<Avatar <Avatar
avatar={{ id: chat.chat_id, kind: avatarKind }} avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id} colorKey={chat.chat_id}
hasAvatar={chat.has_avatar} hasAvatar={chat.has_avatar}
name={title} name={title}
/> />
</span>
<div class="info"> <div class="info">
<div class="info-row"> <div class="info-row">
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
@@ -90,7 +102,13 @@
{#if senderPrefix} {#if senderPrefix}
<span class="sender">{senderPrefix}</span> <span class="sender">{senderPrefix}</span>
{/if} {/if}
{#if chat.last_deleted}
<span class="deleted-mark" title={t("chat.deletedPreview")}
><Icon name="delete" size="1rem" /></span
><s class="deleted-text">{preview}</s>
{:else}
{preview} {preview}
{/if}
</span> </span>
</div> </div>
</div> </div>
@@ -147,11 +165,15 @@
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
&.selected { &.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active); background-color: var(--color-chat-active);
.title, .title,
@@ -163,6 +185,37 @@
} }
} }
.avatar {
position: relative;
display: flex;
flex-shrink: 0;
&.online::after {
content: "";
position: absolute;
right: 0.0625rem;
bottom: 0.0625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid var(--online-ring, var(--color-background));
border-radius: 50%;
background-color: var(--color-green);
}
}
.deleted-mark {
display: inline-flex;
margin-inline-end: 0.1875rem;
vertical-align: -0.1875rem;
}
.deleted-text {
text-decoration: line-through;
}
.info { .info {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { tick } from "svelte";
import { chats } from "#lib/stores/chats.svelte.js";
import { search } from "#lib/stores/search.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const EDITABLE = "input, textarea, select, [contenteditable='true']";
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
async function focusIn(selector: string) {
await tick();
document.querySelector<HTMLInputElement>(selector)?.focus();
}
async function focusSearch() {
if (ui.leftView === "settings") {
ui.closeSettings();
}
await focusIn("#LeftColumn .left-header input");
}
function stepChat(step: number) {
const { list } = chats;
const index = list.findIndex((chat) => chat.chat_id === chatId);
if (index === -1 && step < 0) {
return;
}
const target = index === -1 ? list[0] : list[index + step];
if (target) {
goto(`/app/${target.chat_id}`);
}
}
function closeTop(editable: HTMLElement | null) {
if (editable) {
if (editable.closest("#LeftColumn .left-header")) {
search.close();
}
editable.blur();
} else if (ui.rightPanel !== null) {
ui.closePanel();
} else if (ui.leftView === "settings") {
ui.closeSettings();
} else if (chatId !== null) {
goto("/app");
}
}
function onCommand(event: KeyboardEvent): boolean {
const key = event.key.toLowerCase();
if (key === "k") {
focusSearch();
return true;
}
if (key === "f" && chatId !== null) {
ui.openPanel("search");
focusIn("#RightColumn input");
return true;
}
return false;
}
function onPlain(event: KeyboardEvent): boolean {
if (event.key === "/" && !event.altKey) {
focusSearch();
return true;
}
if (
event.altKey &&
(event.key === "ArrowUp" || event.key === "ArrowDown")
) {
stepChat(event.key === "ArrowUp" ? -1 : 1);
return true;
}
return false;
}
function onkeydown(event: KeyboardEvent) {
if (event.defaultPrevented || document.querySelector(LAYERS)) {
return;
}
const target = event.target as HTMLElement | null;
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
const modified = event.ctrlKey || event.metaKey;
if (event.key === "Escape") {
closeTop(editable);
return;
}
const handled = modified
? !event.altKey && onCommand(event)
: editable === null && onPlain(event);
if (handled) {
event.preventDefault();
}
}
</script>
<svelte:window {onkeydown} />
+29 -1
View File
@@ -5,10 +5,12 @@
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte"; import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { captureLine } from "#lib/format/capture.js";
import { accountName } from "#lib/format/peer.js"; import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { theme } from "#lib/stores/theme.svelte.js"; import { theme } from "#lib/stores/theme.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -23,6 +25,7 @@
const entries: Entry[] = [ const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" }, { icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" }, { icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "note", label: "notes.title", panel: "notes" },
{ icon: "data", label: "settings.storage", panel: "storage" }, { icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" }, { icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" }, { icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
@@ -31,6 +34,12 @@
let adding = $state(false); let adding = $state(false);
const quiet = $derived(
capture.status !== null && !capture.status.live
? captureLine(capture.status, accounts.selected?.is_bot ?? false)
: null
);
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
const match = DIGIT.exec(event.code); const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) { if (!(event.altKey && match)) {
@@ -54,14 +63,18 @@
<span class="menu-trigger"> <span class="menu-trigger">
<Button <Button
{...props} {...props}
aria-label={t("menu.open")} aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
round round
smaller smaller
title={quiet ?? undefined}
variant="translucent" variant="translucent"
> >
<Icon name="menu" /> <Icon name="menu" />
</Button> </Button>
<AlertBadge /> <AlertBadge />
{#if quiet}
<span class="capture-dot"></span>
{/if}
</span> </span>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -142,6 +155,21 @@
flex-shrink: 0; flex-shrink: 0;
} }
.capture-dot {
pointer-events: none;
position: absolute;
right: 0.125rem;
bottom: 0.125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-error);
box-shadow: 0 0 0 2px var(--color-background);
}
:global(.main-menu) { :global(.main-menu) {
width: 17rem; width: 17rem;
max-height: calc(100dvh - 5rem); max-height: calc(100dvh - 5rem);
@@ -15,14 +15,15 @@
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";
import CopyItem from "#lib/components/ui/CopyItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WebPage from "#lib/components/WebPage.svelte"; import WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js"; import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.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 { appear } from "#lib/transitions/appear.js"; import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -137,13 +138,8 @@
onjump(message.reply.message_id); onjump(message.reply.message_id);
} }
} }
async function copyText() { function messageLink(): string {
await navigator.clipboard.writeText(message.text ?? ""); return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
toasts.success(t("thread.copiedText"));
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success(t("thread.copiedId"));
} }
function openSenderProfile() { function openSenderProfile() {
if (senderChat === message.chat_id) { if (senderChat === message.chat_id) {
@@ -212,12 +208,6 @@
{#if message.reply} {#if message.reply}
<ReplyHeader {onjump} reply={message.reply} /> <ReplyHeader {onjump} reply={message.reply} />
{/if} {/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
{t("thread.deleted")}
</span>
{/if}
{#if message.poll} {#if message.poll}
<Poll poll={message.poll} /> <Poll poll={message.poll} />
{/if} {/if}
@@ -236,7 +226,13 @@
{own} {own}
/> />
{:else if message.has_media} {:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} /> <MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if} {/if}
{/if} {/if}
{#if hasText} {#if hasText}
@@ -266,7 +262,13 @@
{t("thread.links", { count: linkCount })} {t("thread.links", { count: linkCount })}
</button> </button>
{/if} {/if}
<MessageMeta {message} {onversions} {own} /> <MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length} {#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} /> <InlineButtons rows={message.inline_buttons} />
{/if} {/if}
@@ -319,14 +321,26 @@
>{t("thread.toReply")}</ContextMenuItem >{t("thread.toReply")}</ContextMenuItem
> >
{/if} {/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText} {#if hasText}
<ContextMenuItem icon="copy" onselect={copyText} <CopyItem
>{t("thread.copyText")}</ContextMenuItem done={t("chat.menu.copied")}
> icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if} {/if}
<ContextMenuItem icon="hashtag" onselect={copyId} <CopyItem
>{t("thread.copyId")}</ContextMenuItem done={t("chat.menu.copied")}
> icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null} {#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile} <ContextMenuItem icon="info" onselect={openSenderProfile}
>{t("thread.senderProfile")}</ContextMenuItem >{t("thread.senderProfile")}</ContextMenuItem
@@ -454,17 +468,6 @@
text-decoration: line-through; text-decoration: line-through;
} }
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip { .detail-chip {
display: inline-flex; display: inline-flex;
+125 -10
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack } from "svelte"; import { tick, untrack } from "svelte";
import type { CaptureGap } from "#lib/api/capture.js";
import { listMessages } from "#lib/api/endpoints.js"; import { listMessages } from "#lib/api/endpoints.js";
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js"; import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
import type { LiveEvent, MessageView } from "#lib/api/types.js"; import type { LiveEvent, MessageView } from "#lib/api/types.js";
import ActionMessage from "#lib/components/ActionMessage.svelte"; import ActionMessage from "#lib/components/ActionMessage.svelte";
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte"; import MediaViewer from "#lib/components/MediaViewer.svelte";
import MessageBubble from "#lib/components/MessageBubble.svelte"; import MessageBubble from "#lib/components/MessageBubble.svelte";
import MessageVersions from "#lib/components/MessageVersions.svelte"; import MessageVersions from "#lib/components/MessageVersions.svelte";
@@ -11,9 +13,10 @@
import TrackChat from "#lib/components/TrackChat.svelte"; import TrackChat from "#lib/components/TrackChat.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDay } from "#lib/format/datetime.js"; import { formatDay, formatSpanRange } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
@@ -34,6 +37,7 @@
const STICK_OFFSET = 9; const STICK_OFFSET = 9;
const IDLE_DELAY = 1500; const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360; const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400;
let messages = $state<MessageView[]>([]); let messages = $state<MessageView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -45,6 +49,7 @@
let suppressAppear = $state(false); let suppressAppear = $state(false);
let scrolling = $state(false); let scrolling = $state(false);
let stuckDay = $state<string | null>(null); let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null; let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false); let mediaOpen = $state(false);
@@ -53,6 +58,9 @@
let versionsOpen = $state(false); let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null); let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null); let highlightId = $state<number | null>(null);
let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | null = null; let highlightTimer: ReturnType<typeof setTimeout> | null = null;
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -66,6 +74,8 @@
dayKey: string; dayKey: string;
daySeparator: string | null; daySeparator: string | null;
firstInGroup: boolean; firstInGroup: boolean;
gapsAfter: CaptureGap[];
gapsBefore: CaptureGap[];
lastInGroup: boolean; lastInGroup: boolean;
message: MessageView; message: MessageView;
own: boolean; own: boolean;
@@ -75,6 +85,27 @@
return new Date(iso).toDateString(); return new Date(iso).toDateString();
} }
const gapSpans = $derived(
capture.gaps.map((gap) => ({
day: dayKey(gap.start),
end: Date.parse(gap.end),
gap,
start: Date.parse(gap.start),
}))
);
function gapsBetween(
prev: MessageView | undefined,
message: MessageView
): typeof gapSpans {
if (!prev || gapSpans.length === 0) {
return [];
}
const from = Date.parse(prev.date);
const to = Date.parse(message.date);
return gapSpans.filter((span) => span.start >= from && span.end <= to);
}
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const out: Row[] = []; const out: Row[] = [];
for (let i = 0; i < messages.length; i += 1) { for (let i = 0; i < messages.length; i += 1) {
@@ -82,15 +113,28 @@
const prev = messages[i - 1]; const prev = messages[i - 1];
const next = messages[i + 1]; const next = messages[i + 1];
const day = dayKey(message.date); const day = dayKey(message.date);
const gaps = gapsBetween(prev, message);
const samePrevDay = prev ? dayKey(prev.date) === day : false; const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false; const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({ out.push({
dayKey: day, dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date), daySeparator: samePrevDay ? null : formatDay(message.date),
firstInGroup: firstInGroup:
!prev || prev.sender_id !== message.sender_id || !samePrevDay, !prev ||
prev.sender_id !== message.sender_id ||
!samePrevDay ||
gaps.length > 0,
gapsAfter: gaps
.filter((span) => span.day === day)
.map((span) => span.gap),
gapsBefore: gaps
.filter((span) => span.day !== day)
.map((span) => span.gap),
lastInGroup: lastInGroup:
!next || next.sender_id !== message.sender_id || !sameNextDay, !next ||
next.sender_id !== message.sender_id ||
!sameNextDay ||
gapsBetween(message, next).length > 0,
message, message,
own: ownId !== null && message.sender_id === ownId, own: ownId !== null && message.sender_id === ownId,
}); });
@@ -219,13 +263,14 @@
} }
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1; const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator"); const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null; const stuck: string[] = [];
for (const sep of seps) { for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) { if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
day = sep.dataset.day ?? null; stuck.push(sep.dataset.day);
} }
} }
stuckDay = day; stuckDay = stuck.pop() ?? null;
passedDays = new Set(stuck);
} }
async function loadInitial() { async function loadInitial() {
@@ -252,6 +297,7 @@
await tick(); await tick();
scrollToBottom(); scrollToBottom();
updateStuck(); updateStuck();
setTimeout(updateStuck, SETTLE_DELAY);
} catch { } catch {
toasts.error(t("thread.loadFailed")); toasts.error(t("thread.loadFailed"));
} finally { } finally {
@@ -394,6 +440,13 @@
} }
} }
function openCalendar(event: MouseEvent, iso: string) {
const date = new Date(iso);
calendarAnchor = event.currentTarget as HTMLElement;
calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() };
calendarOpen = true;
}
function openMedia(message: MessageView, index: number) { function openMedia(message: MessageView, index: number) {
mediaItems = viewerItemsFrom(message.message_id, message.media); mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index; mediaIndex = index;
@@ -581,15 +634,29 @@
<div class="loading-older"><Spinner /></div> <div class="loading-older"><Spinner /></div>
{/if} {/if}
{#each rows as row (row.message.message_id)} {#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator} {#if row.daySeparator}
<div <div
class="day-separator" class="day-separator"
data-day={row.dayKey} data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay} class:idle={!scrolling && row.dayKey === stuckDay}
class:passed={passedDays.has(row.dayKey)}
> >
<span>{row.daySeparator}</span> <button
aria-label={t("chat.calendar")}
onclick={(event) => openCalendar(event, row.message.date)}
title={t("chat.calendar")}
type="button"
>
{row.daySeparator}
</button>
</div> </div>
{/if} {/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service} {#if row.message.service}
<ActionMessage message={row.message} onjump={goTo} /> <ActionMessage message={row.message} onjump={goTo} />
{:else} {:else}
@@ -625,6 +692,20 @@
{/if} {/if}
</div> </div>
{#snippet gapLine(
gap: CaptureGap
)}
<div class="capture-gap">
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
</div>
{/snippet}
<CalendarPopover
anchor={calendarAnchor}
{chatId}
month={calendarMonth}
bind:open={calendarOpen}
/>
<MediaViewer <MediaViewer
{chatId} {chatId}
items={mediaItems} items={mediaItems}
@@ -714,10 +795,15 @@
justify-content: center; justify-content: center;
margin: 0.75rem 0; margin: 0.75rem 0;
span { button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem; border-radius: 1rem;
font: inherit;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-white); color: var(--color-white);
@@ -725,10 +811,39 @@
background-color: var(--color-default-shadow); background-color: var(--color-default-shadow);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
} }
&.idle span { &.idle button,
&.passed button {
pointer-events: none;
opacity: 0; opacity: 0;
} }
} }
.capture-gap {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px dashed var(--color-borders);
}
span {
flex-shrink: 0;
}
}
</style> </style>
+54 -34
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js"; import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js"; import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
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";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js"; import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js"; import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props { interface Props {
items?: Snippet;
message: MessageView; message: MessageView;
onopen: () => void; onopen: () => void;
onversions?: () => void;
own: boolean; own: boolean;
} }
let { message, onopen, own }: Props = $props(); let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5; const POLL_TRIES = 5;
const POLL_DELAY = 3000; const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false); let loaded = $state(false);
let media = $state<InlineMedia | null>(null); let media = $state<InlineMedia | null>(null);
let queuing = $state(false); let queuing = $state(false);
let queued = $state(false);
let saving = $state(false); let saving = $state(false);
const ref = $derived(message.media[0] ?? null); const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived( const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
); );
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл"); const fileName = $derived(
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
);
const waiting = $derived(queuing || queued);
function delay(ms: number): Promise<void> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -101,16 +108,16 @@
} }
async function queue() { async function queue() {
if (queuing) { if (waiting) {
return; return;
} }
queuing = true; queuing = true;
try { try {
await fetchMedia(message.chat_id, message.message_id); await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь"); queued = true;
poll(); poll();
} catch { } catch {
toasts.error("Не удалось поставить в очередь"); toasts.error(t("chat.media.queueFailed"));
} finally { } finally {
queuing = false; queuing = false;
} }
@@ -124,7 +131,7 @@
try { try {
await downloadMedia(storedId); await downloadMedia(storedId);
} catch { } catch {
toasts.error("Не удалось скачать файл"); toasts.error(t("chat.media.downloadFailed"));
} finally { } finally {
saving = false; saving = false;
} }
@@ -145,7 +152,7 @@
{#if message.is_self_destruct} {#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button"> <button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" /> <Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span> <span>{t("chat.media.selfDestruct")}</span>
</button> </button>
{:else if asFile && ref} {:else if asFile && ref}
<FileChip media={ref} {own} /> <FileChip media={ref} {own} />
@@ -165,11 +172,11 @@
<AudioFile {own} title={fileName} url={ready.url} /> <AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage} {:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button"> <button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}> <img alt={t("chat.media.photo")} src={ready.url}>
</button> </button>
{:else if ready && isStaticSticker} {:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button"> <button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}> <img alt={fileName} src={ready.url}>
</button> </button>
{:else if ready && isVideoSticker} {:else if ready && isVideoSticker}
<video <video
@@ -205,46 +212,59 @@
</button> </button>
{:else if media?.state === "not-downloaded" && vk !== "other"} {:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button"> <button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" /> <Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span> <span
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small> >{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button> </button>
{:else if media?.state === "not-downloaded"} {:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button"> <button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" /> <Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span> <span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button> </button>
{:else} {:else}
<button class="media-chip" onclick={onopen} type="button"> <button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" /> <Icon name="photo" size="1.25rem" />
<span>Медиа</span> <span>{t("chat.media")}</span>
</button> </button>
{/if} {/if}
</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 >{t("chat.media.fullscreen")}</ContextMenuItem
> >
<ContextMenuItem {#if onversions}
icon="recent" <ContextMenuItem icon="recent" onselect={onversions}
onselect={() => ui.openMessagePanel("versions", message.message_id)} >{t("chat.media.versions")}</ContextMenuItem
>Версии медиа</ContextMenuItem
> >
{/if}
{#if storedId === null} {#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}> <ContextMenuItem icon="cloud-download" onselect={queue}
Скачать в архив >{t("chat.media.queue")}</ContextMenuItem
</ContextMenuItem> >
{:else} {:else}
<ContextMenuItem icon="download" onselect={save}> <ContextMenuItem icon="download" onselect={save}
Скачать файл >{t("chat.media.save")}</ContextMenuItem
</ContextMenuItem> >
<ContextMenuItem icon="allow-share" onselect={share}> <ContextMenuItem icon="allow-share" onselect={share}
Доступ по ссылке >{t("chat.media.share")}</ContextMenuItem
</ContextMenuItem> >
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if} {/if}
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts"> <script lang="ts">
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 {
formatExact,
formatStamp,
formatTime,
} from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js"; import { readTitle } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props { interface Props {
message: MessageView; message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void; onversions?: () => void;
own?: boolean; own?: boolean;
} }
let { message, onversions, own = false }: Props = $props(); let {
message,
noted = false,
onnote,
onversions,
own = false,
}: Props = $props();
const TIP_DURATION = 3000;
let tip = $state(false);
let tipTimer: ReturnType<typeof setTimeout> | undefined;
const exact = $derived(formatExact(message.date));
function toggleTip() {
tip = !tip;
clearTimeout(tipTimer);
if (tip) {
tipTimer = setTimeout(() => {
tip = false;
}, TIP_DURATION);
}
}
</script> </script>
<span class="MessageMeta"> <span class="MessageMeta">
{#if message.edited_at} {#if noted}
<button <button
class="edited" aria-label={t("chat.note")}
onclick={onversions} class="meta-button note"
title="View edit history" onclick={onnote}
title={t("chat.note")}
type="button" type="button"
> >
edited <Icon name="note" size="1rem" />
</button> </button>
{/if} {/if}
<span class="time">{formatTime(message.date)}</span> {#if message.deleted_at}
<span class="deleted" title={formatExact(message.deleted_at)}>
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
</span>
{/if}
{#if message.edited_at}
<button
class="meta-button edited"
onclick={onversions}
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
type="button"
>
{t("chat.edited")}
</button>
{/if}
<button
class="meta-button time"
onclick={toggleTip}
title={exact}
type="button"
>
{formatTime(message.date)}
</button>
{#if tip}
<span class="tip" role="status">{exact}</span>
{/if}
{#if own} {#if own}
<span class="ticks" title={readTitle(message)}> <span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" /> <Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss"> <style lang="scss">
.MessageMeta { .MessageMeta {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
@@ -47,21 +105,70 @@
user-select: none; user-select: none;
} }
.time { .meta-button {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
border-radius: 0.25rem;
font: inherit;
color: inherit;
white-space: nowrap; white-space: nowrap;
background: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.note {
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.edited,
.deleted {
font-style: italic;
white-space: nowrap;
}
.deleted::after {
content: "·";
margin-inline-start: 0.25rem;
font-style: normal;
}
.tip {
pointer-events: none;
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-style: normal;
color: var(--color-white);
white-space: nowrap;
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
} }
.ticks { .ticks {
display: inline-flex; display: inline-flex;
} }
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style> </style>
+10 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte"; import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte"; import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import NotesPanel from "#lib/components/annotations/NotesPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte"; import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte"; import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte"; import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
@@ -25,6 +26,7 @@
import type { MessageKey } from "#lib/i18n/messages.js"; import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { 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";
@@ -37,6 +39,7 @@
changes: "feed.changes", changes: "feed.changes",
jobs: "settings.jobs", jobs: "settings.jobs",
links: "panel.links", links: "panel.links",
notes: "notes.title",
policy: "settings.policy", policy: "settings.policy",
presence: "chat.analytics", presence: "chat.analytics",
profile: "chat.profile", profile: "chat.profile",
@@ -49,7 +52,6 @@
"storage-trash": "panel.trash", "storage-trash": "panel.trash",
stories: "settings.stories", stories: "settings.stories",
"stories-all": "panel.allStories", "stories-all": "panel.allStories",
versions: "panel.versions",
watches: "settings.watches", watches: "settings.watches",
webhooks: "watches.webhooks", webhooks: "watches.webhooks",
}; };
@@ -65,6 +67,9 @@
if (ui.rightPanel === null) { if (ui.rightPanel === null) {
return ""; return "";
} }
if (ui.rightPanel === "notes" && notes.tag !== null) {
return t("notes.tag", { tag: notes.tag });
}
const chat = const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId) ? chats.byId(storageUi.chatId)
@@ -139,6 +144,10 @@
<AlertsPanel /> <AlertsPanel />
{:else if ui.rightPanel === "annotations"} {:else if ui.rightPanel === "annotations"}
<AnnotationsPanel /> <AnnotationsPanel />
{:else if ui.rightPanel === "notes"}
{#key accounts.selectedId}
<NotesPanel />
{/key}
{:else if ui.rightPanel === "shares"} {:else if ui.rightPanel === "shares"}
<SharesPanel /> <SharesPanel />
{:else if ui.rightPanel === "bots"} {:else if ui.rightPanel === "bots"}
@@ -22,6 +22,7 @@
EVENT_ICONS, EVENT_ICONS,
} from "#lib/format/watches.js"; } from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js"; import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -48,15 +49,7 @@
); );
function writeParam(value: Filter | null) { function writeParam(value: Filter | null) {
const url = new URL(page.url.href); replaceParam(ALERTS_PARAM, value);
if (value === null) {
url.searchParams.delete(ALERTS_PARAM);
} else {
url.searchParams.set(ALERTS_PARAM, value);
}
if (url.href !== page.url.href) {
goto(url, { replace: true, reset: false });
}
} }
function setFilter(next: Filter) { function setFilter(next: Filter) {
@@ -135,10 +128,10 @@
async function openAlert(alert: AlertItem) { async function openAlert(alert: AlertItem) {
seen(alert); seen(alert);
await goto(alert.path);
if (isMobile()) { if (isMobile()) {
ui.closePanel(); ui.resetPanel();
} }
await goto(alert.path);
} }
async function seeAll() { async function seeAll() {
@@ -4,6 +4,7 @@
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte"; import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
import NoteText from "./NoteText.svelte";
interface Props { interface Props {
annotations: Annotation[]; annotations: Annotation[];
@@ -32,7 +33,7 @@
submitLabel={t("common.save")} submitLabel={t("common.save")}
/> />
{:else} {:else}
<p class="text">{annotation.text}</p> <NoteText text={annotation.text} />
<div class="meta"> <div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span> <span class="time">{formatFull(annotation.updated_at)}</span>
<button <button
@@ -75,12 +76,6 @@
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state"; import { page } from "$app/state";
import AnnotationEditor from "./AnnotationEditor.svelte"; import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -41,6 +42,7 @@
.then((rows) => { .then((rows) => {
if (current === token) { if (current === token) {
items = rows; items = rows;
notes.mark(cid, mid, rows.length > 0);
} }
}) })
.finally(() => { .finally(() => {
@@ -59,6 +61,7 @@
try { try {
const created = await createAnnotation(chatId, messageId, text); const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items]; items = [created, ...items];
notes.mark(chatId, messageId, true);
} finally { } finally {
saving = false; saving = false;
} }
@@ -72,6 +75,7 @@
async function remove(annotation: Annotation) { async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id); await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id); items = items.filter((item) => item.id !== annotation.id);
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
} }
</script> </script>
@@ -0,0 +1,84 @@
<script lang="ts">
import { noteParts, notes } from "#lib/stores/notes.svelte.js";
interface Props {
interactive?: boolean;
text: string;
}
const { interactive = true, text }: Props = $props();
const parts = $derived(noteParts(text));
function openTag(event: MouseEvent, tag: string) {
event.stopPropagation();
notes.openTag(tag);
}
</script>
<p class="note-text">
{#each parts as part, index (index)}
{#if "tag" in part && interactive}
<button
class="tag"
onclick={(event) => openTag(event, part.tag)}
type="button"
class:active={notes.tag === part.tag}
>
{part.label}
</button>
{:else if "tag" in part}
<span class="tag" class:active={notes.tag === part.tag}
>{part.label}</span
>
{:else}
{part.text}
{/if}
{/each}
</p>
<style lang="scss">
.note-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
display: inline;
padding: 0 0.3125rem;
border: 0;
border-radius: 0.375rem;
font: inherit;
font-size: 0.875em;
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
transition: background-color 0.15s ease;
&.active {
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (hover: hover) {
&:is(button):hover {
background-color: color-mix(
in srgb,
var(--color-primary) 20%,
transparent
);
}
}
&:is(button) {
cursor: pointer;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -0,0 +1,402 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ripple } from "#lib/actions/ripple.js";
import {
listNotes,
listNoteTags,
type Note,
type NoteTag,
} from "#lib/api/notes.js";
import NoteText from "#lib/components/annotations/NoteText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 50;
let items = $state<Note[]>([]);
let tags = $state<NoteTag[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(false);
let failed = $state(false);
let token = 0;
const chips = $derived.by(() => {
const current = notes.tag;
if (current === null || tags.some((item) => item.tag === current)) {
return tags;
}
return [{ count: 0, tag: current }, ...tags];
});
async function load(tag: string | null, offset = 0) {
token += 1;
const mine = token;
if (offset === 0) {
loading = true;
} else {
loadingMore = true;
}
try {
const page = await listNotes(tag, offset, PAGE);
if (mine !== token) {
return;
}
items = offset === 0 ? page : [...items, ...page];
hasMore = page.length === PAGE;
failed = false;
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
loadingMore = false;
}
}
}
$effect(() => {
const { tag } = notes;
untrack(() => {
replaceParam(NOTES_PARAM, tag ?? "");
load(tag);
});
});
onMount(() => {
listNoteTags()
.then((result) => {
tags = result;
})
.catch(() => undefined);
return () => {
notes.tag = null;
replaceParam(NOTES_PARAM, null);
};
});
function pick(tag: string | null) {
notes.tag = notes.tag === tag ? null : tag;
}
function href(note: Note): string {
return `/app/${note.chat_id}?message=${note.message_id}`;
}
function open(event: MouseEvent, note: Note) {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
ui.requestJump(note.chat_id, note.message_id);
if (isMobile()) {
ui.resetPanel();
goto(href(note));
return;
}
const tag = notes.tag === null ? "" : encodeURIComponent(notes.tag);
goto(`${href(note)}&${NOTES_PARAM}=${tag}`);
}
function chatTitle(note: Note): string {
return note.chat_title ?? t("chat.fallbackTitle", { id: note.chat_id });
}
function quote(note: Note): string {
if (note.message_date === null) {
return t("notes.messageGone");
}
return note.message_text ?? t("notes.mediaMessage");
}
</script>
<div class="notes">
{#if chips.length > 0}
<div class="chips">
<button
aria-pressed={notes.tag === null}
class="chip"
onclick={() => pick(null)}
type="button"
class:on={notes.tag === null}
>
{t("notes.all")}
</button>
{#each chips as item (item.tag)}
<button
aria-pressed={notes.tag === item.tag}
class="chip"
onclick={() => pick(item.tag)}
type="button"
class:on={notes.tag === item.tag}
>
#{item.tag}
{#if item.count > 0}
<span class="value">{item.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if failed && items.length === 0}
<div class="state">
<p>{t("notes.failed")}</p>
<Button onclick={() => load(notes.tag)} variant="text">
{t("notes.retry")}
</Button>
</div>
{:else if items.length === 0}
<EmptyState
description={notes.tag === null ? t("notes.empty") : undefined}
title={notes.tag === null
? t("notes.emptyTitle")
: t("notes.emptyTag", { tag: notes.tag })}
/>
{:else}
<ul class="list">
{#each items as note (note.id)}
<li>
<a
class="row"
href={href(note)}
onclick={(event) => open(event, note)}
use:ripple
>
<Avatar
avatar={{
id: note.chat_id,
kind: note.chat_id > 0 ? "peer" : "chat",
}}
colorKey={note.chat_id}
hasAvatar={note.chat_has_avatar}
name={chatTitle(note)}
size={2.75}
/>
<div class="body">
<div class="top">
<span class="title">{chatTitle(note)}</span>
<span class="date">{formatListDate(note.created_at)}</span>
</div>
<div class="text">
<NoteText interactive={false} text={note.text} />
</div>
<div class="quote" class:deleted={note.message_deleted}>
{quote(note)}
</div>
</div>
</a>
</li>
{/each}
</ul>
{#if hasMore}
<div class="more">
<Button
loading={loadingMore}
onclick={() => load(notes.tag, items.length)}
variant="text"
>
{t("notes.more")}
</Button>
</div>
{/if}
{/if}
</div>
<style lang="scss">
.notes {
display: flex;
flex-direction: column;
min-height: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text);
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.value {
color: var(--color-text-secondary);
}
.list {
margin: 0;
padding: 0.5rem;
list-style: none;
}
.row {
position: relative;
overflow: hidden;
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.5rem 0.625rem;
border-radius: 0.625rem;
color: var(--color-text);
text-decoration: none;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.body {
overflow: hidden;
flex: 1;
min-width: 0;
}
.top {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.text {
margin-top: 0.125rem;
font-size: 0.9375rem;
line-height: 1.35;
}
.quote {
overflow: hidden;
margin-top: 0.375rem;
padding-inline-start: 0.5rem;
border-inline-start: 2px solid var(--color-primary);
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.deleted {
text-decoration: line-through;
}
}
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.state {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 2rem 1rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
display: flex;
justify-content: center;
padding: 0 0.5rem 1rem;
}
</style>
@@ -1,40 +1,42 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js"; import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
import type { DayCount } from "#lib/api/types.js"; import type { DayCount } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const LEVELS = 4;
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface YearMonth { interface YearMonth {
month: number; month: number;
year: number; year: number;
} }
interface Props {
chatId: number;
initial?: YearMonth | null;
onjump?: () => void;
}
let { chatId, initial = null, onjump }: Props = $props();
const LEVELS = 4;
const MONDAY = Date.UTC(2024, 0, 1);
const DAY_MS = 86_400_000;
const weekdays = $derived.by(() => {
const names = new Intl.DateTimeFormat(locale.tag, {
timeZone: "UTC",
weekday: "short",
});
return Array.from({ length: 7 }, (_, offset) =>
names.format(new Date(MONDAY + offset * DAY_MS))
);
});
interface DayCell { interface DayCell {
count: number; count: number;
day: number; day: number;
@@ -44,7 +46,7 @@
let days = $state<DayCount[]>([]); let days = $state<DayCount[]>([]);
let loading = $state(false); let loading = $state(false);
let cursor = $state<YearMonth | null>(null); let cursor = $state<YearMonth | null>(untrack(() => initial));
const cache = new Map<string, DayCount[]>(); const cache = new Map<string, DayCount[]>();
const now = new Date(); const now = new Date();
@@ -61,6 +63,13 @@
); );
const view = $derived(cursor ?? currentMonth); const view = $derived(cursor ?? currentMonth);
const monthLabel = $derived.by(() => {
const name = new Intl.DateTimeFormat(locale.tag, {
month: "long",
timeZone: "UTC",
}).format(new Date(Date.UTC(view.year, view.month, 1)));
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${view.year}`;
});
function index(value: YearMonth): number { function index(value: YearMonth): number {
return value.year * 12 + value.month; return value.year * 12 + value.month;
@@ -115,9 +124,14 @@
return result; return result;
}); });
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
$effect(() => { $effect(() => {
const _id = chatId; const key = `${chatId}:${accounts.selectedId}`;
const _account = accounts.selectedId; if (key === shown) {
return;
}
shown = key;
cache.clear(); cache.clear();
cursor = null; cursor = null;
days = []; days = [];
@@ -161,7 +175,10 @@
try { try {
const target = await getMessageAt(chatId, cell.key); const target = await getMessageAt(chatId, cell.key);
ui.requestJump(chatId, target.message_id); ui.requestJump(chatId, target.message_id);
onjump?.();
if (page.params.chatId !== String(chatId)) {
goto(`/app/${chatId}`); goto(`/app/${chatId}`);
}
} catch { } catch {
// no message resolved for that day // no message resolved for that day
} }
@@ -174,7 +191,7 @@
<div class="calendar"> <div class="calendar">
<header class="nav"> <header class="nav">
<button <button
aria-label="Предыдущий год" aria-label={t("chat.calendar.prevYear")}
class="step" class="step"
onclick={() => shift(-12)} onclick={() => shift(-12)}
type="button" type="button"
@@ -182,16 +199,16 @@
« «
</button> </button>
<button <button
aria-label="Предыдущий месяц" aria-label={t("chat.calendar.prevMonth")}
class="step" class="step"
onclick={() => shift(-1)} onclick={() => shift(-1)}
type="button" type="button"
> >
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</button> </button>
<span class="label">{MONTHS[view.month]} {view.year}</span> <span class="label">{monthLabel}</span>
<button <button
aria-label="Следующий месяц" aria-label={t("chat.calendar.nextMonth")}
class="step" class="step"
disabled={!canNext} disabled={!canNext}
onclick={() => shift(1)} onclick={() => shift(1)}
@@ -200,7 +217,7 @@
<Icon name="arrow-right" /> <Icon name="arrow-right" />
</button> </button>
<button <button
aria-label="Следующий год" aria-label={t("chat.calendar.nextYear")}
class="step" class="step"
disabled={!canNext} disabled={!canNext}
onclick={() => shift(12)} onclick={() => shift(12)}
@@ -211,7 +228,7 @@
</header> </header>
<div class="weekdays"> <div class="weekdays">
{#each WEEKDAYS as weekday (weekday)} {#each weekdays as weekday (weekday)}
<span>{weekday}</span> <span>{weekday}</span>
{/each} {/each}
</div> </div>
@@ -225,7 +242,7 @@
<button <button
class="cell day lvl-{cell.level}" class="cell day lvl-{cell.level}"
onclick={() => jump(cell)} onclick={() => jump(cell)}
title={`${cell.count} сообщений`} title={t("chat.calendar.count", { count: cell.count })}
type="button" type="button"
> >
{cell.day} {cell.day}
@@ -0,0 +1,58 @@
<script lang="ts">
import { captureLine } from "#lib/format/capture.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
const status = $derived(capture.status);
</script>
{#if status}
<div class="capture-line" role="status" class:quiet={!status.live}>
<span class="mark"><span class="dot"></span></span>
<span class="label"
>{captureLine(status, accounts.selected?.is_bot ?? false)}</span
>
</div>
{/if}
<style lang="scss">
.capture-line {
display: flex;
align-items: center;
gap: 1.5rem;
min-height: 3.5rem;
padding: 0 1.25rem;
font-size: 1rem;
color: var(--color-text);
}
.mark {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background-color: var(--color-green);
}
.quiet .dot {
background-color: var(--color-error);
}
.label {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -2,6 +2,7 @@
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte"; import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
import About from "#lib/components/settings/About.svelte"; import About from "#lib/components/settings/About.svelte";
import Appearance from "#lib/components/settings/Appearance.svelte"; import Appearance from "#lib/components/settings/Appearance.svelte";
import CaptureLine from "#lib/components/settings/CaptureLine.svelte";
import Language from "#lib/components/settings/Language.svelte"; import Language from "#lib/components/settings/Language.svelte";
import MyAccount from "#lib/components/settings/MyAccount.svelte"; import MyAccount from "#lib/components/settings/MyAccount.svelte";
import SettingsItem from "#lib/components/settings/SettingsItem.svelte"; import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
@@ -39,6 +40,7 @@
</div> </div>
<div class="section"> <div class="section">
<CaptureLine />
<SettingsItem <SettingsItem
icon="settings" icon="settings"
label={t("settings.policy")} label={t("settings.policy")}
@@ -83,6 +85,11 @@
<AlertBadge inline /> <AlertBadge inline />
{/snippet} {/snippet}
</SettingsItem> </SettingsItem>
<SettingsItem
icon="note"
label={t("notes.title")}
onclick={() => ui.openPanel("notes")}
/>
</div> </div>
<About /> <About />
@@ -1,7 +1,11 @@
<script lang="ts" module> <script lang="ts" module>
import { createContext } from "svelte";
type PointerHandler = (event: PointerEvent) => void; type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>(); const claimed = new WeakSet<Event>();
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -18,6 +22,10 @@
let open = $state(false); let open = $state(false);
let suppressClick = false; let suppressClick = false;
provideMenuCloser(() => {
open = false;
});
function triggerProps(props: Record<string, unknown>) { function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler; const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler; const onpointerup = props.onpointerup as PointerHandler;
@@ -0,0 +1,34 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { menuCloser } from "./ContextMenu.svelte";
import Icon from "./Icon.svelte";
interface Props {
done: string;
icon: string;
label: string;
text: () => string;
}
const { done, icon, label, text }: Props = $props();
const CLOSE_DELAY = 900;
const close = menuCloser();
let copied = $state(false);
async function copy() {
try {
await navigator.clipboard.writeText(text());
} catch {
return;
}
copied = true;
setTimeout(close, CLOSE_DELAY);
}
</script>
<ContextMenu.Item class="bg-menu-item" closeOnSelect={false} onSelect={copy}>
<Icon name={copied ? "check" : icon} size="1.125rem" />
{copied ? done : label}
</ContextMenu.Item>
+14
View File
@@ -0,0 +1,14 @@
import type { CaptureStatus } from "#lib/api/capture.js";
import { formatSince } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
export function captureLine(status: CaptureStatus, bot: boolean): string {
if (status.live && status.since) {
return t("capture.live", { when: formatSince(status.since) });
}
if (status.last_seen) {
const when = formatSince(status.last_seen);
return bot ? t("capture.botQuiet", { when }) : t("capture.quiet", { when });
}
return bot ? t("capture.botNever") : t("capture.never");
}
+48
View File
@@ -3,6 +3,15 @@ import { locale } from "#lib/i18n/locale.svelte.js";
const OPTIONS = { const OPTIONS = {
date: { day: "numeric", month: "short", year: "numeric" }, date: { day: "numeric", month: "short", year: "numeric" },
day: { day: "numeric", month: "short" }, day: { day: "numeric", month: "short" },
exact: {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "long",
second: "2-digit",
weekday: "long",
year: "numeric",
},
full: { full: {
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
@@ -12,6 +21,14 @@ const OPTIONS = {
}, },
longDay: { day: "numeric", month: "long", weekday: "long" }, longDay: { day: "numeric", month: "long", weekday: "long" },
offset: { timeZoneName: "shortOffset" }, offset: { timeZoneName: "shortOffset" },
stamp: { day: "numeric", hour: "2-digit", minute: "2-digit", month: "short" },
stampYear: {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "short",
year: "numeric",
},
time: { hour: "2-digit", minute: "2-digit" }, time: { hour: "2-digit", minute: "2-digit" },
} satisfies Record<string, Intl.DateTimeFormatOptions>; } satisfies Record<string, Intl.DateTimeFormatOptions>;
@@ -47,6 +64,37 @@ export function formatFull(iso: string): string {
return format("full", new Date(iso)); return format("full", new Date(iso));
} }
export function formatExact(iso: string): string {
return format("exact", new Date(iso));
}
function stampKind(date: Date): "stamp" | "stampYear" {
return date.getFullYear() === new Date().getFullYear()
? "stamp"
: "stampYear";
}
export function formatStamp(iso: string): string {
const date = new Date(iso);
return format(stampKind(date), date);
}
export function formatSince(iso: string): string {
const date = new Date(iso);
if (date.toDateString() === new Date().toDateString()) {
return format("time", date);
}
return format(stampKind(date), date);
}
export function formatSpanRange(startIso: string, endIso: string): string {
const start = new Date(startIso);
const end = new Date(endIso);
const kind =
start.toDateString() === end.toDateString() ? "time" : stampKind(start);
return formatter(kind).formatRange(start, end);
}
export function formatListDate(iso: string | null): string { export function formatListDate(iso: string | null): string {
if (!iso) { if (!iso) {
return ""; return "";
+6
View File
@@ -1,6 +1,8 @@
import { account } from "#lib/i18n/messages/account.js"; import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.js"; import { alerts } from "#lib/i18n/messages/alerts.js";
import { bots } from "#lib/i18n/messages/bots.js"; import { bots } from "#lib/i18n/messages/bots.js";
import { capture } from "#lib/i18n/messages/capture.js";
import { chat } from "#lib/i18n/messages/chat.js";
import { common } from "#lib/i18n/messages/common.js"; import { common } from "#lib/i18n/messages/common.js";
import { feed } from "#lib/i18n/messages/feed.js"; import { feed } from "#lib/i18n/messages/feed.js";
import { jobs } from "#lib/i18n/messages/jobs.js"; import { jobs } from "#lib/i18n/messages/jobs.js";
@@ -12,6 +14,7 @@ import { policy } from "#lib/i18n/messages/policy.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
import { search } from "#lib/i18n/messages/search.js"; import { search } from "#lib/i18n/messages/search.js";
import { service } from "#lib/i18n/messages/service.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
import { shares } from "#lib/i18n/messages/shares.js"; import { shares } from "#lib/i18n/messages/shares.js";
import { social } from "#lib/i18n/messages/social.js"; import { social } from "#lib/i18n/messages/social.js";
@@ -25,6 +28,8 @@ export const messages = {
...account, ...account,
...alerts, ...alerts,
...bots, ...bots,
...capture,
...chat,
...common, ...common,
...feed, ...feed,
...jobs, ...jobs,
@@ -36,6 +41,7 @@ export const messages = {
...profile, ...profile,
...read, ...read,
...search, ...search,
...service,
...settings, ...settings,
...shares, ...shares,
...social, ...social,
+24
View File
@@ -0,0 +1,24 @@
import type { Messages } from "#lib/i18n/types.js";
export const capture = {
"capture.botNever": {
en: "The bot has not connected yet",
ru: "Бот ещё не выходил на связь",
},
"capture.botQuiet": {
en: "The bot is silent since {when}",
ru: "Бот молчит с {when}",
},
"capture.live": {
en: "Capturing since {when}",
ru: "Захват идёт с {when}",
},
"capture.never": {
en: "The userbot has not connected yet",
ru: "Юзербот ещё не выходил на связь",
},
"capture.quiet": {
en: "The userbot is silent since {when}",
ru: "Юзербот молчит с {when}",
},
} satisfies Messages;
+56
View File
@@ -0,0 +1,56 @@
import type { Messages } from "#lib/i18n/types.js";
export const chat = {
"chat.calendar": { en: "Jump to date", ru: "Перейти к дате" },
"chat.calendar.count": {
en: { one: "{count} message", other: "{count} messages" },
ru: {
few: "{count} сообщения",
many: "{count} сообщений",
one: "{count} сообщение",
other: "{count} сообщения",
},
},
"chat.calendar.nextMonth": { en: "Next month", ru: "Следующий месяц" },
"chat.calendar.nextYear": { en: "Next year", ru: "Следующий год" },
"chat.calendar.prevMonth": { en: "Previous month", ru: "Предыдущий месяц" },
"chat.calendar.prevYear": { en: "Previous year", ru: "Предыдущий год" },
"chat.deletedAt": { en: "deleted {when}", ru: "удалено {when}" },
"chat.deletedPreview": { en: "Deleted", ru: "Удалено" },
"chat.edited": { en: "edited", ru: "изменено" },
"chat.editedAt": { en: "Edited {when}", ru: "Изменено {when}" },
"chat.gap": { en: "archive was off {range}", ru: "архив не писал {range}" },
"chat.media.download": { en: "Download {kind}", ru: "Скачать: {kind}" },
"chat.media.downloadFailed": {
en: "Could not download the file",
ru: "Не удалось скачать файл",
},
"chat.media.file": { en: "File", ru: "Файл" },
"chat.media.fullscreen": {
en: "Open full screen",
ru: "Открыть на весь экран",
},
"chat.media.photo": { en: "Photo", ru: "Фото" },
"chat.media.queue": { en: "Download to archive", ru: "Скачать в архив" },
"chat.media.queued": { en: "Queued", ru: "В очереди" },
"chat.media.queueFailed": {
en: "Could not queue the download",
ru: "Не удалось поставить в очередь",
},
"chat.media.save": { en: "Download file", ru: "Скачать файл" },
"chat.media.selfDestruct": {
en: "Self-destructing media",
ru: "Самоуничтожающееся медиа",
},
"chat.media.share": { en: "Share by link", ru: "Доступ по ссылке" },
"chat.media.tapToDownload": {
en: "Tap to download",
ru: "Нажмите, чтобы скачать",
},
"chat.media.versions": { en: "Media versions", ru: "Версии медиа" },
"chat.media.video": { en: "Video", ru: "Видео" },
"chat.menu.copied": { en: "Copied", ru: "Скопировано" },
"chat.menu.copyLink": { en: "Copy link", ru: "Копировать ссылку" },
"chat.menu.linkCopied": { en: "Link copied", ru: "Ссылка скопирована" },
"chat.note": { en: "Note", ru: "Заметка" },
} satisfies Messages;
+22 -3
View File
@@ -2,10 +2,29 @@ import type { Messages } from "#lib/i18n/types.js";
export const notes = { export const notes = {
"notes.add": { en: "Add note", ru: "Добавить заметку" }, "notes.add": { en: "Add note", ru: "Добавить заметку" },
"notes.all": { en: "All", ru: "Все" },
"notes.edit": { en: "Edit", ru: "Изменить" }, "notes.edit": { en: "Edit", ru: "Изменить" },
"notes.placeholder": { "notes.empty": {
en: "Note on the message…", en: "Right-click a message and pick Notes to write one",
ru: "Заметка к сообщению…", ru: "Нажмите на сообщение правой кнопкой и выберите «Заметки»",
}, },
"notes.emptyTag": {
en: "No notes with #{tag}",
ru: "Нет заметок с #{tag}",
},
"notes.emptyTitle": { en: "No notes yet", ru: "Заметок пока нет" },
"notes.failed": {
en: "Could not load notes",
ru: "Не удалось загрузить заметки",
},
"notes.mediaMessage": { en: "Media", ru: "Медиа" },
"notes.messageGone": {
en: "Message is not in the archive",
ru: "Сообщения нет в архиве",
},
"notes.more": { en: "Show more", ru: "Показать ещё" },
"notes.placeholder": { en: "Note to the message", ru: "Заметка к сообщению" },
"notes.retry": { en: "Retry", ru: "Повторить" },
"notes.tag": { en: "Notes with #{tag}", ru: "Заметки с #{tag}" },
"notes.title": { en: "Notes", ru: "Заметки" }, "notes.title": { en: "Notes", ru: "Заметки" },
} satisfies Messages; } satisfies Messages;
+50
View File
@@ -0,0 +1,50 @@
import type { Messages } from "#lib/i18n/types.js";
export const service = {
"service.added": {
en: "{actor} added {names}",
ru: "{actor} добавил(а) {names}",
},
"service.callEnded": { en: "Call ended", ru: "Звонок завершён" },
"service.callEndedAfter": {
en: "Call ended · {duration}",
ru: "Звонок завершён · {duration}",
},
"service.callStarted": { en: "Call started", ru: "Звонок начат" },
"service.channelCreated": { en: "Channel created", ru: "Канал создан" },
"service.groupCreated": {
en: "{actor} created the group",
ru: "{actor} создал(а) группу",
},
"service.joined": {
en: "{actor} joined the group",
ru: "{actor} вступил(а) в группу",
},
"service.left": {
en: "{actor} left the group",
ru: "{actor} покинул(а) группу",
},
"service.photoChanged": {
en: "{actor} changed the group photo",
ru: "{actor} изменил(а) фото группы",
},
"service.photoRemoved": {
en: "{actor} removed the group photo",
ru: "{actor} удалил(а) фото группы",
},
"service.pinned": {
en: "{actor} pinned a message",
ru: "{actor} закрепил(а) сообщение",
},
"service.pollOption": {
en: "{actor} added a poll option",
ru: "{actor} добавил(а) вариант в опрос",
},
"service.someone": { en: "Someone", ru: "Кто-то" },
"service.titleChanged": {
en: "{actor} changed the group name",
ru: "{actor} изменил(а) название группы",
},
"service.unknown": { en: "Service message", ru: "Служебное сообщение" },
"service.you": { en: "You", ru: "Вы" },
} satisfies Messages;
+32
View File
@@ -0,0 +1,32 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
const ATTEMPTS = 2;
function withParam(key: string, value: string | null): URL {
const url = new URL(page.url.href);
if (value === null) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
return url;
}
export async function replaceParam(
key: string,
value: string | null
): Promise<void> {
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
const url = withParam(key, value);
if (url.href === page.url.href) {
return;
}
// biome-ignore lint/performance/noAwaitInLoops: a write overtaken by another navigation is retried once
await goto(url, {
replace: true,
reset: false,
state: { ...page.state },
}).catch(() => undefined);
}
}
+59
View File
@@ -0,0 +1,59 @@
import {
type CaptureGap,
type CaptureStatus,
getCaptureGaps,
getCaptureStatus,
} from "#lib/api/capture.js";
const POLL_INTERVAL = 60_000;
function createCapture() {
let account: number | null = null;
let status = $state<CaptureStatus | null>(null);
let gaps = $state.raw<CaptureGap[]>([]);
let timer: ReturnType<typeof setInterval> | undefined;
async function refresh() {
const current = account;
if (current === null) {
return;
}
const next = await getCaptureStatus(current).catch(() => null);
if (next === null || current !== account) {
return;
}
const restarted = next.since !== status?.since;
status = next;
if (restarted) {
const found = await getCaptureGaps(current).catch(() => null);
if (found !== null && current === account) {
gaps = found;
}
}
}
return {
get gaps(): CaptureGap[] {
return gaps;
},
get status(): CaptureStatus | null {
return status;
},
track(accountId: number | null) {
if (accountId === account) {
return;
}
account = accountId;
status = null;
gaps = [];
clearInterval(timer);
if (accountId === null) {
return;
}
refresh();
timer = setInterval(refresh, POLL_INTERVAL);
},
};
}
export const capture = createCapture();
+25
View File
@@ -14,6 +14,7 @@ import { folders } from "#lib/stores/folders.svelte.js";
const POLL_INTERVAL = 1500; const POLL_INTERVAL = 1500;
const POLL_MAX = 12; const POLL_MAX = 12;
const PAGE_SIZE = 40; const PAGE_SIZE = 40;
const CHANNEL_IDS = -1_000_000_000_000;
const ALL = "all"; const ALL = "all";
const EMPTY: Chat[] = []; const EMPTY: Chat[] = [];
@@ -159,7 +160,29 @@ function createChats() {
} }
} }
function applyDelete(chatId: number | null, ids: number[]) {
for (const bucket of Object.values(buckets)) {
for (const chat of bucket.list) {
const inScope =
chatId === null
? chat.chat_id > CHANNEL_IDS
: chat.chat_id === chatId;
if (
inScope &&
chat.last_message_id !== null &&
ids.includes(chat.last_message_id)
) {
chat.last_deleted = true;
}
}
}
}
function applyEvent(event: LiveEvent) { function applyEvent(event: LiveEvent) {
if (event.type === "delete") {
applyDelete(event.chat_id, event.message_ids);
return;
}
if (event.type !== "message") { if (event.type !== "message") {
return; return;
} }
@@ -174,6 +197,8 @@ function createChats() {
} }
seenChat = true; seenChat = true;
existing.last_date = message.date; existing.last_date = message.date;
existing.last_deleted = message.deleted_at !== null;
existing.last_message_id = message.message_id;
existing.last_sender_id = message.sender_id; existing.last_sender_id = message.sender_id;
existing.last_text = message.text; existing.last_text = message.text;
existing.message_count += 1; existing.message_count += 1;
+59
View File
@@ -0,0 +1,59 @@
import { SvelteMap } from "svelte/reactivity";
import type { MessageView } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
export const NOTES_PARAM = "notes";
const TAG = /(^|[^\p{L}\p{N}_])#([\p{L}\p{N}_]+)/gu;
export type NotePart = { label: string; tag: string } | { text: string };
export function noteParts(text: string): NotePart[] {
const parts: NotePart[] = [];
let last = 0;
for (const match of text.matchAll(TAG)) {
const start = match.index + match[1].length;
if (start > last) {
parts.push({ text: text.slice(last, start) });
}
parts.push({ label: `#${match[2]}`, tag: match[2].toLowerCase() });
last = start + match[2].length + 1;
}
if (last < text.length) {
parts.push({ text: text.slice(last) });
}
return parts;
}
function createNotes() {
let tag = $state<string | null>(null);
const marks = new SvelteMap<string, boolean>();
function key(chatId: number, messageId: number): string {
return `${accounts.selectedId}:${chatId}:${messageId}`;
}
return {
has(message: MessageView): boolean {
return (
marks.get(key(message.chat_id, message.message_id)) ?? message.has_note
);
},
mark(chatId: number, messageId: number, noted: boolean) {
marks.set(key(chatId, messageId), noted);
},
openTag(next: string | null) {
tag = next;
ui.openPanel("notes");
},
get tag(): string | null {
return tag;
},
set tag(next: string | null) {
tag = next;
},
};
}
export const notes = createNotes();
+94
View File
@@ -0,0 +1,94 @@
import { SvelteMap } from "svelte/reactivity";
import { getOnline } from "#lib/api/capture.js";
import type { PresenceSample } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
const CLOCK_INTERVAL = 30_000;
const BATCH = 200;
function createOnline() {
const until = new SvelteMap<number, number>();
const asked = new Set<number>();
let pending: number[] = [];
let account: number | null = null;
let now = $state(Date.now());
let ticking = false;
function apply(peerId: number, sample: PresenceSample | null) {
const ends = sample?.next_offline_date;
if (sample?.status === "online" && ends) {
until.set(peerId, new Date(ends).getTime());
now = Date.now();
} else {
until.delete(peerId);
}
}
async function ask(ids: number[]) {
const current = account;
for (let start = 0; start < ids.length; start += BATCH) {
// biome-ignore lint/performance/noAwaitInLoops: batches go one after another
const samples = await getOnline(ids.slice(start, start + BATCH)).catch(
() => []
);
if (current !== account) {
return;
}
for (const sample of samples) {
apply(sample.peer_id, sample);
}
}
}
function flush() {
const ids = pending;
pending = [];
ask(ids);
}
function syncAccount() {
if (accounts.selectedId === account) {
return;
}
account = accounts.selectedId;
until.clear();
asked.clear();
pending = [];
}
events.subscribe((event) => {
if (event.type === "presence") {
apply(event.peer_id, event.sample);
}
});
events.onReconnect(() => {
until.clear();
ask([...asked]);
});
return {
isOnline(peerId: number): boolean {
return (until.get(peerId) ?? 0) > now;
},
watch(peerId: number) {
syncAccount();
if (account === null || asked.has(peerId)) {
return;
}
asked.add(peerId);
if (pending.length === 0) {
queueMicrotask(flush);
}
pending.push(peerId);
if (!ticking) {
ticking = true;
setInterval(() => {
now = Date.now();
}, CLOCK_INTERVAL);
}
},
};
}
export const online = createOnline();
+95 -25
View File
@@ -1,13 +1,16 @@
import { pushState, replaceState } from "$app/navigation";
import { page } from "$app/state";
export type RightPanel = export type RightPanel =
| "changes" | "changes"
| "profile" | "profile"
| "search" | "search"
| "versions"
| "reactions" | "reactions"
| "readers" | "readers"
| "callbacks" | "callbacks"
| "links" | "links"
| "annotations" | "annotations"
| "notes"
| "jobs" | "jobs"
| "presence" | "presence"
| "stories" | "stories"
@@ -24,32 +27,82 @@ export type RightPanel =
export type LeftView = "main" | "settings"; export type LeftView = "main" | "settings";
export interface PanelState {
chatId: number | null;
messageId: number | null;
name: RightPanel;
peerId: number | null;
}
interface JumpTarget { interface JumpTarget {
chatId: number; chatId: number;
messageId: number; messageId: number;
} }
interface PersonTarget { function currentChat(): number | null {
chatId: number; return page.params.chatId ? Number(page.params.chatId) : null;
peerId: number; }
function samePanel(a: PanelState | null, b: PanelState | null): boolean {
return (
a?.name === b?.name &&
a?.messageId === b?.messageId &&
a?.peerId === b?.peerId &&
a?.chatId === b?.chatId
);
} }
function createUi() { function createUi() {
let rightPanel = $state<RightPanel | null>(null); let panel = $state.raw<PanelState | null>(null);
let panelMessageId = $state<number | null>(null);
let leftColumnOpen = $state(true); let leftColumnOpen = $state(true);
let leftView = $state<LeftView>("main"); let leftView = $state<LeftView>("main");
let jumpTarget = $state<JumpTarget | null>(null); let jumpTarget = $state<JumpTarget | null>(null);
let person = $state<PersonTarget | null>(null); let writing: Promise<unknown> = Promise.resolve();
function queue(step: () => Promise<void> | undefined) {
writing = writing.then(step).catch(() => undefined);
}
function record(stay = false) {
queue(() => {
const next = panel;
if (next === null || samePanel(page.state.panel ?? null, next)) {
return;
}
const fresh = page.state.panel === undefined && !stay;
return (fresh ? pushState : replaceState)("", {
...page.state,
panel: next,
});
});
}
function markInApp() {
queue(() => {
if (page.state.inApp) {
return;
}
return replaceState("", { ...page.state, inApp: true });
});
}
function show(next: PanelState) {
panel = next;
record();
}
return { return {
clearJump() { clearJump() {
jumpTarget = null; jumpTarget = null;
}, },
closePanel() { closePanel() {
rightPanel = null; if (panel === null) {
panelMessageId = null; return;
person = null; }
panel = null;
if (page.state.panel !== undefined) {
history.back();
}
}, },
closeSettings() { closeSettings() {
leftView = "main"; leftView = "main";
@@ -63,43 +116,60 @@ function createUi() {
get leftView() { get leftView() {
return leftView; return leftView;
}, },
openMessagePanel(panel: RightPanel, messageId: number) { markInApp,
rightPanel = panel; openMessagePanel(name: RightPanel, messageId: number) {
panelMessageId = messageId; show({ chatId: currentChat(), messageId, name, peerId: null });
}, },
openPanel(panel: RightPanel) { openPanel(name: RightPanel) {
rightPanel = panel; show({ chatId: null, messageId: null, name, peerId: null });
panelMessageId = null;
person = null;
}, },
openPerson(chatId: number, peerId: number) { openPerson(chatId: number, peerId: number) {
rightPanel = "profile"; show({ chatId, messageId: null, name: "profile", peerId });
panelMessageId = null;
person = { chatId, peerId };
}, },
openSettings() { openSettings() {
leftView = "settings"; leftView = "settings";
}, },
get panel() {
return panel;
},
get panelMessageId() { get panelMessageId() {
return panelMessageId; return panel?.messageId ?? null;
}, },
personIn(chatId: number | null): number | null { personIn(chatId: number | null): number | null {
return person !== null && person.chatId === chatId ? person.peerId : null; return panel !== null && panel.peerId !== null && panel.chatId === chatId
? panel.peerId
: null;
}, },
record,
requestJump(chatId: number, messageId: number) { requestJump(chatId: number, messageId: number) {
jumpTarget = { chatId, messageId }; jumpTarget = { chatId, messageId };
}, },
resetPanel() {
panel = null;
},
restore(next: PanelState | null) {
if (!samePanel(panel, next)) {
panel = next;
}
},
get rightPanel() { get rightPanel() {
return rightPanel; return panel?.name ?? null;
}, },
setChat(chatId: number | null) { setChat(chatId: number | null) {
if (person !== null && person.chatId !== chatId) { if (panel === null || panel.chatId === null || panel.chatId === chatId) {
person = null; return;
} }
panel =
panel.messageId === null
? { ...panel, chatId: null, peerId: null }
: null;
}, },
setLeftColumn(open: boolean) { setLeftColumn(open: boolean) {
leftColumnOpen = open; leftColumnOpen = open;
}, },
settled(): Promise<unknown> {
return writing;
},
toggleLeftColumn() { toggleLeftColumn() {
leftColumnOpen = !leftColumnOpen; leftColumnOpen = !leftColumnOpen;
}, },
+69 -2
View File
@@ -5,6 +5,7 @@
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import FeedView from "#lib/components/feed/FeedView.svelte"; import FeedView from "#lib/components/feed/FeedView.svelte";
import Hotkeys from "#lib/components/Hotkeys.svelte";
import MainMenu from "#lib/components/MainMenu.svelte"; import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
@@ -18,12 +19,15 @@
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js"; import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { feedFilterOf } from "#lib/stores/feed.svelte.js"; import { feedFilterOf } from "#lib/stores/feed.svelte.js";
import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js";
import { search } from "#lib/stores/search.svelte.js"; import { search } from "#lib/stores/search.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { type PanelState, ui } from "#lib/stores/ui.svelte.js";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
let { children } = $props(); let { children } = $props();
@@ -42,6 +46,64 @@
events.open(auth.token === null ? null : accounts.selectedId); events.open(auth.token === null ? null : accounts.selectedId);
}); });
$effect(() => {
capture.track(auth.token === null ? null : accounts.selectedId);
});
let carrying: object | null = null;
let moving = false;
let seenPanel: PanelState | null = null;
$effect(() => {
const next = page.state.panel ?? null;
untrack(() => {
const changed = JSON.stringify(next) !== JSON.stringify(seenPanel);
seenPanel = next;
if (changed && carrying === null) {
ui.restore(next);
}
});
});
beforeNavigate((navigation) => {
if (navigation.shallow || navigation.type === "popstate") {
return;
}
const token = {};
carrying = ui.rightPanel === null ? null : token;
moving ||= navigation.to?.url.pathname !== page.url.pathname;
navigation.complete.catch(() => {
if (carrying === token) {
carrying = null;
}
});
});
afterNavigate((navigation) => {
if (
navigation.shallow ||
navigation.type === "popstate" ||
navigation.type === "enter"
) {
return;
}
ui.setChat(page.params.chatId ? Number(page.params.chatId) : null);
const moved = moving;
moving = false;
if (moved) {
ui.markInApp();
}
const token = carrying;
if (token !== null) {
ui.record(!moved);
}
ui.settled().then(() => {
if (carrying === token) {
carrying = null;
}
});
});
$effect(() => { $effect(() => {
const chatId = page.params.chatId ? Number(page.params.chatId) : null; const chatId = page.params.chatId ? Number(page.params.chatId) : null;
untrack(() => { untrack(() => {
@@ -59,14 +121,17 @@
} }
shownAccount = id; shownAccount = id;
untrack(() => { untrack(() => {
ui.closePanel(); ui.resetPanel();
search.reset(); search.reset();
}); });
}); });
onMount(() => { onMount(() => {
const tag = page.url.searchParams.get(NOTES_PARAM);
if (page.url.searchParams.has(ALERTS_PARAM)) { if (page.url.searchParams.has(ALERTS_PARAM)) {
ui.openPanel("alerts"); ui.openPanel("alerts");
} else if (tag !== null) {
notes.openTag(tag || null);
} }
}); });
</script> </script>
@@ -136,6 +201,8 @@
{/if} {/if}
</div> </div>
<Hotkeys />
<PolicyDialog /> <PolicyDialog />
<ShareDialog <ShareDialog