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.
- **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.
- **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.
Nothing is ever deleted from the archive.
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
## MCP
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_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
@@ -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,
backfill,
bots,
capture,
chats,
custom_emoji,
discover,
@@ -114,6 +115,7 @@ app.include_router(webhooks.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
app.include_router(capture.router)
app.mount("/mcp", mcp_app)
+21 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
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(
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)
async def create_annotation(
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.models import (
DEFAULT_LIMIT,
MAX_LIMIT,
Page,
PresenceHourly,
PresenceSample,
@@ -54,6 +55,16 @@ async def current_presence(
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")
async def presence_hourly(
pool: FromDishka[asyncpg.Pool],
+72 -1
View File
@@ -1,9 +1,34 @@
import re
from collections import Counter
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"
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(
pool: asyncpg.Pool,
@@ -70,3 +95,49 @@ async def update_annotation(
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
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})
SELECT ids.chat_id,
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,
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
p.username) AS title,
@@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
last_date=row["last_date"],
last_text=row["last_text"],
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))
index = end
await _apply_read_status(pool, account_id, chat_id, views)
await _apply_notes(pool, account_id, chat_id, views)
return views
@@ -278,6 +282,7 @@ async def get_message(
raw = load_raw(row["raw"])
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_notes(pool, account_id, chat_id, [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(
members: list[tuple[asyncpg.Record, dict]],
media_by_key: dict[tuple[int, int], asyncpg.Record],
+28
View File
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
last_date: datetime | None
last_text: str | None
last_sender_id: int | None
last_message_id: int | None = None
last_deleted: bool = False
class DiscoverItem(BaseModel):
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
is_animated_emoji: bool = False
read: bool = False
read_at: datetime | None = None
has_note: bool = False
class MessageVersionView(BaseModel):
@@ -611,6 +614,31 @@ class AnnotationView(BaseModel):
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):
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
_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(
pool: asyncpg.Pool,
account_id: int,
+6
View File
@@ -113,6 +113,12 @@ class BotRegistry:
def running(self) -> list[int]:
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 with self._lock:
await self._adopt_dropped_in()
+14 -2
View File
@@ -6,6 +6,7 @@ import asyncpg
import uvloop
from dependencies.container import container
from userbot.modules.uptime import repository as uptime
from utils.listen import Listener
from utils.logging import logger, setup_logging
from utils.read.bots import BOTS_CHANGED_CHANNEL
@@ -16,6 +17,7 @@ from watcher.registry import BotRegistry
setup_logging()
RETRY_INTERVAL = 300
UPTIME_INTERVAL = 60
async def _retry_loop(registry: BotRegistry) -> None:
@@ -27,6 +29,14 @@ async def _retry_loop(registry: BotRegistry) -> None:
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 on_change(_payload: str) -> None:
task = asyncio.create_task(registry.sync())
@@ -58,6 +68,7 @@ async def runner() -> None:
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
retry = asyncio.create_task(_retry_loop(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try:
await registry.sync()
if not registry.running:
@@ -66,9 +77,10 @@ async def runner() -> None:
logger.info("[green]Bot watcher running.[/]")
await stop.wait()
finally:
retry.cancel()
for task in (retry, beat):
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await retry
await task
await listener.stop()
await registry.close()
await container.close()
+6 -5
View File
@@ -1,13 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { PanelState } from "#lib/stores/ui.svelte.js";
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface PageState {
inApp?: boolean;
panel?: PanelState;
}
// 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;
kind: ChatKind;
last_date: string | null;
last_deleted: boolean;
last_message_id: number | null;
last_sender_id: number | null;
last_text: string | null;
message_count: number;
@@ -196,6 +198,7 @@ export interface MessageView {
entities: EntityView[];
forward: ForwardView | null;
has_media: boolean;
has_note: boolean;
inline_buttons: InlineButton[][];
is_animated_emoji: boolean;
is_self_destruct: boolean;
@@ -2,6 +2,7 @@
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
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 { peers } from "#lib/stores/peers.svelte.js";
@@ -16,10 +17,12 @@
function nameOf(id: number | null): string {
if (id === null) {
return "Someone";
return t("service.someone");
}
if (id === ownId) {
return accounts.selected ? accountName(accounts.selected) : "You";
return accounts.selected
? accountName(accounts.selected)
: t("service.you");
}
const peer = peers.get(id);
return peer ? peerName(peer) : String(id);
@@ -44,35 +47,39 @@
const selfJoin =
members.length === 1 && members[0] === message.sender_id;
if (selfJoin) {
return `${actor} joined the group`;
return t("service.joined", { actor });
}
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":
return `${actor} left the group`;
return t("service.left", { actor });
case "pinned_message":
return `${actor} pinned a message`;
return t("service.pinned", { actor });
case "group_chat_created":
return `${actor} created the group`;
return t("service.groupCreated", { actor });
case "channel_chat_created":
return "Channel created";
return t("service.channelCreated");
case "phone_call_ended":
return service.duration === null
? "Call ended"
: `Call ended · ${durationOf(service.duration)}`;
? t("service.callEnded")
: t("service.callEndedAfter", {
duration: durationOf(service.duration),
});
case "phone_call_started":
return "Call started";
return t("service.callStarted");
case "poll_option_added":
return `${actor} added a poll option`;
return t("service.pollOption", { actor });
case "new_chat_title":
return `${actor} changed the group name`;
return t("service.titleChanged", { actor });
case "new_chat_photo":
return `${actor} changed the group photo`;
return t("service.photoChanged", { actor });
case "delete_chat_photo":
return `${actor} removed the group photo`;
return t("service.photoRemoved", { actor });
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 chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null);
let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() {
if (backfilling) {
@@ -39,7 +42,10 @@
backfilling = true;
try {
await enqueueBackfill(chatId, true);
toasts.success(t("chat.backfill.started"));
backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch {
toasts.error(t("chat.backfill.failed"));
} finally {
@@ -48,6 +54,10 @@
}
function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href);
target.pathname = "/app";
goto(withoutMessage(target));
@@ -216,14 +226,15 @@
<Icon name="stats" />
</Button>
<Button
aria-label={t("chat.backfill")}
aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
loading={backfilling}
onclick={backfill}
round
smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent"
>
<Icon name="cloud-download" />
<Icon name={backfilled ? "check" : "cloud-download"} />
</Button>
</div>
</header>
@@ -287,8 +298,13 @@
}
.subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online {
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) {
({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -4,9 +4,11 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.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 { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -31,6 +33,14 @@
? t("chat.deletedAccount")
: 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 ownId = $derived(accounts.selected?.tg_user_id ?? null);
const showSender = $derived(
@@ -74,12 +84,14 @@
class:selected
use:ripple
>
<span class="avatar" class:online={isOnline}>
<Avatar
avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id}
hasAvatar={chat.has_avatar}
name={title}
/>
</span>
<div class="info">
<div class="info-row">
<h3 class="title">{title}</h3>
@@ -90,7 +102,13 @@
{#if senderPrefix}
<span class="sender">{senderPrefix}</span>
{/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}
{/if}
</span>
</div>
</div>
@@ -147,11 +165,15 @@
@media (hover: hover) {
&:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover);
}
}
&.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active);
.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 {
overflow: hidden;
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 Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { captureLine } from "#lib/format/capture.js";
import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.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 { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -23,6 +25,7 @@
const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "note", label: "notes.title", panel: "notes" },
{ icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
@@ -31,6 +34,12 @@
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) {
const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) {
@@ -54,14 +63,18 @@
<span class="menu-trigger">
<Button
{...props}
aria-label={t("menu.open")}
aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
round
smaller
title={quiet ?? undefined}
variant="translucent"
>
<Icon name="menu" />
</Button>
<AlertBadge />
{#if quiet}
<span class="capture-dot"></span>
{/if}
</span>
{/snippet}
</DropdownMenu.Trigger>
@@ -142,6 +155,21 @@
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) {
width: 17rem;
max-height: calc(100dvh - 5rem);
@@ -15,14 +15,15 @@
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.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 WebPage from "#lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.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 { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
@@ -137,13 +138,8 @@
onjump(message.reply.message_id);
}
}
async function copyText() {
await navigator.clipboard.writeText(message.text ?? "");
toasts.success(t("thread.copiedText"));
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success(t("thread.copiedId"));
function messageLink(): string {
return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
}
function openSenderProfile() {
if (senderChat === message.chat_id) {
@@ -212,12 +208,6 @@
{#if message.reply}
<ReplyHeader {onjump} reply={message.reply} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
{t("thread.deleted")}
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
@@ -236,7 +226,13 @@
{own}
/>
{:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
<MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if}
{/if}
{#if hasText}
@@ -266,7 +262,13 @@
{t("thread.links", { count: linkCount })}
</button>
{/if}
<MessageMeta {message} {onversions} {own} />
<MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
@@ -319,14 +321,26 @@
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText}
<ContextMenuItem icon="copy" onselect={copyText}
>{t("thread.copyText")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if}
<ContextMenuItem icon="hashtag" onselect={copyId}
>{t("thread.copyId")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>{t("thread.senderProfile")}</ContextMenuItem
@@ -454,17 +468,6 @@
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 {
display: inline-flex;
+125 -10
View File
@@ -1,9 +1,11 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import type { CaptureGap } from "#lib/api/capture.js";
import { listMessages } from "#lib/api/endpoints.js";
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
import type { LiveEvent, MessageView } from "#lib/api/types.js";
import ActionMessage from "#lib/components/ActionMessage.svelte";
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import MessageBubble from "#lib/components/MessageBubble.svelte";
import MessageVersions from "#lib/components/MessageVersions.svelte";
@@ -11,9 +13,10 @@
import TrackChat from "#lib/components/TrackChat.svelte";
import Icon from "#lib/components/ui/Icon.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { capture } from "#lib/stores/capture.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
@@ -34,6 +37,7 @@
const STICK_OFFSET = 9;
const IDLE_DELAY = 1500;
const MAX_MESSAGES = 360;
const SETTLE_DELAY = 400;
let messages = $state<MessageView[]>([]);
let loading = $state(true);
@@ -45,6 +49,7 @@
let suppressAppear = $state(false);
let scrolling = $state(false);
let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false);
@@ -53,6 +58,9 @@
let versionsOpen = $state(false);
let versionsMessageId = $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;
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -66,6 +74,8 @@
dayKey: string;
daySeparator: string | null;
firstInGroup: boolean;
gapsAfter: CaptureGap[];
gapsBefore: CaptureGap[];
lastInGroup: boolean;
message: MessageView;
own: boolean;
@@ -75,6 +85,27 @@
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 out: Row[] = [];
for (let i = 0; i < messages.length; i += 1) {
@@ -82,15 +113,28 @@
const prev = messages[i - 1];
const next = messages[i + 1];
const day = dayKey(message.date);
const gaps = gapsBetween(prev, message);
const samePrevDay = prev ? dayKey(prev.date) === day : false;
const sameNextDay = next ? dayKey(next.date) === day : false;
out.push({
dayKey: day,
daySeparator: samePrevDay ? null : formatDay(message.date),
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:
!next || next.sender_id !== message.sender_id || !sameNextDay,
!next ||
next.sender_id !== message.sender_id ||
!sameNextDay ||
gapsBetween(message, next).length > 0,
message,
own: ownId !== null && message.sender_id === ownId,
});
@@ -219,13 +263,14 @@
}
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
let day: string | null = null;
const stuck: string[] = [];
for (const sep of seps) {
if (sep.getBoundingClientRect().top <= limit) {
day = sep.dataset.day ?? null;
if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
stuck.push(sep.dataset.day);
}
}
stuckDay = day;
stuckDay = stuck.pop() ?? null;
passedDays = new Set(stuck);
}
async function loadInitial() {
@@ -252,6 +297,7 @@
await tick();
scrollToBottom();
updateStuck();
setTimeout(updateStuck, SETTLE_DELAY);
} catch {
toasts.error(t("thread.loadFailed"));
} 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) {
mediaItems = viewerItemsFrom(message.message_id, message.media);
mediaIndex = index;
@@ -581,15 +634,29 @@
<div class="loading-older"><Spinner /></div>
{/if}
{#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator}
<div
class="day-separator"
data-day={row.dayKey}
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>
{/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service}
<ActionMessage message={row.message} onjump={goTo} />
{:else}
@@ -625,6 +692,20 @@
{/if}
</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
{chatId}
items={mediaItems}
@@ -714,10 +795,15 @@
justify-content: center;
margin: 0.75rem 0;
span {
button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem;
font: inherit;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
@@ -725,10 +811,39 @@
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
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;
}
}
.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>
+54 -34
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props {
items?: Snippet;
message: MessageView;
onopen: () => void;
onversions?: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let queued = $state(false);
let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived(
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> {
return new Promise((resolve) => {
@@ -101,16 +108,16 @@
}
async function queue() {
if (queuing) {
if (waiting) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь");
queued = true;
poll();
} catch {
toasts.error("Не удалось поставить в очередь");
toasts.error(t("chat.media.queueFailed"));
} finally {
queuing = false;
}
@@ -124,7 +131,7 @@
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("chat.media.downloadFailed"));
} finally {
saving = false;
}
@@ -145,7 +152,7 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span>
<span>{t("chat.media.selfDestruct")}</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
@@ -165,11 +172,11 @@
<AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
<img alt={t("chat.media.photo")} src={ready.url}>
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}>
<img alt={fileName} src={ready.url}>
</button>
{:else if ready && isVideoSticker}
<video
@@ -205,46 +212,59 @@
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span
>{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Медиа</span>
<span>{t("chat.media")}</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>{t("chat.media.fullscreen")}</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
{#if onversions}
<ContextMenuItem icon="recent" onselect={onversions}
>{t("chat.media.versions")}</ContextMenuItem
>
{/if}
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
<ContextMenuItem icon="cloud-download" onselect={queue}
>{t("chat.media.queue")}</ContextMenuItem
>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={save}
>{t("chat.media.save")}</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={share}
>{t("chat.media.share")}</ContextMenuItem
>
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if}
{/snippet}
</ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts">
import type { MessageView } from "#lib/api/types.js";
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 { t } from "#lib/i18n/locale.svelte.js";
interface Props {
message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void;
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>
<span class="MessageMeta">
{#if message.edited_at}
{#if noted}
<button
class="edited"
onclick={onversions}
title="View edit history"
aria-label={t("chat.note")}
class="meta-button note"
onclick={onnote}
title={t("chat.note")}
type="button"
>
edited
<Icon name="note" size="1rem" />
</button>
{/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}
<span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss">
.MessageMeta {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -47,21 +105,70 @@
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;
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 {
display: inline-flex;
}
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style>
+10 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import AlertsPanel from "#lib/components/alerts/AlertsPanel.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 StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
@@ -25,6 +26,7 @@
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.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 { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -37,6 +39,7 @@
changes: "feed.changes",
jobs: "settings.jobs",
links: "panel.links",
notes: "notes.title",
policy: "settings.policy",
presence: "chat.analytics",
profile: "chat.profile",
@@ -49,7 +52,6 @@
"storage-trash": "panel.trash",
stories: "settings.stories",
"stories-all": "panel.allStories",
versions: "panel.versions",
watches: "settings.watches",
webhooks: "watches.webhooks",
};
@@ -65,6 +67,9 @@
if (ui.rightPanel === null) {
return "";
}
if (ui.rightPanel === "notes" && notes.tag !== null) {
return t("notes.tag", { tag: notes.tag });
}
const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId)
@@ -139,6 +144,10 @@
<AlertsPanel />
{:else if ui.rightPanel === "annotations"}
<AnnotationsPanel />
{:else if ui.rightPanel === "notes"}
{#key accounts.selectedId}
<NotesPanel />
{/key}
{:else if ui.rightPanel === "shares"}
<SharesPanel />
{:else if ui.rightPanel === "bots"}
@@ -22,6 +22,7 @@
EVENT_ICONS,
} from "#lib/format/watches.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { replaceParam } from "#lib/params.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
@@ -48,15 +49,7 @@
);
function writeParam(value: Filter | null) {
const url = new URL(page.url.href);
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 });
}
replaceParam(ALERTS_PARAM, value);
}
function setFilter(next: Filter) {
@@ -135,10 +128,10 @@
async function openAlert(alert: AlertItem) {
seen(alert);
await goto(alert.path);
if (isMobile()) {
ui.closePanel();
ui.resetPanel();
}
await goto(alert.path);
}
async function seeAll() {
@@ -4,6 +4,7 @@
import { t } from "#lib/i18n/locale.svelte.js";
import Icon from "../ui/Icon.svelte";
import AnnotationEditor from "./AnnotationEditor.svelte";
import NoteText from "./NoteText.svelte";
interface Props {
annotations: Annotation[];
@@ -32,7 +33,7 @@
submitLabel={t("common.save")}
/>
{:else}
<p class="text">{annotation.text}</p>
<NoteText text={annotation.text} />
<div class="meta">
<span class="time">{formatFull(annotation.updated_at)}</span>
<button
@@ -75,12 +76,6 @@
border-radius: 0.75rem;
}
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.meta {
display: flex;
align-items: center;
@@ -11,6 +11,7 @@
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.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 { page } from "$app/state";
import AnnotationEditor from "./AnnotationEditor.svelte";
@@ -41,6 +42,7 @@
.then((rows) => {
if (current === token) {
items = rows;
notes.mark(cid, mid, rows.length > 0);
}
})
.finally(() => {
@@ -59,6 +61,7 @@
try {
const created = await createAnnotation(chatId, messageId, text);
items = [created, ...items];
notes.mark(chatId, messageId, true);
} finally {
saving = false;
}
@@ -72,6 +75,7 @@
async function remove(annotation: Annotation) {
await deleteAnnotation(annotation.id);
items = items.filter((item) => item.id !== annotation.id);
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
}
</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">
import { untrack } from "svelte";
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
import type { DayCount } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.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 { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const LEVELS = 4;
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
import { page } from "$app/state";
interface YearMonth {
month: 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 {
count: number;
day: number;
@@ -44,7 +46,7 @@
let days = $state<DayCount[]>([]);
let loading = $state(false);
let cursor = $state<YearMonth | null>(null);
let cursor = $state<YearMonth | null>(untrack(() => initial));
const cache = new Map<string, DayCount[]>();
const now = new Date();
@@ -61,6 +63,13 @@
);
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 {
return value.year * 12 + value.month;
@@ -115,9 +124,14 @@
return result;
});
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
$effect(() => {
const _id = chatId;
const _account = accounts.selectedId;
const key = `${chatId}:${accounts.selectedId}`;
if (key === shown) {
return;
}
shown = key;
cache.clear();
cursor = null;
days = [];
@@ -161,7 +175,10 @@
try {
const target = await getMessageAt(chatId, cell.key);
ui.requestJump(chatId, target.message_id);
onjump?.();
if (page.params.chatId !== String(chatId)) {
goto(`/app/${chatId}`);
}
} catch {
// no message resolved for that day
}
@@ -174,7 +191,7 @@
<div class="calendar">
<header class="nav">
<button
aria-label="Предыдущий год"
aria-label={t("chat.calendar.prevYear")}
class="step"
onclick={() => shift(-12)}
type="button"
@@ -182,16 +199,16 @@
«
</button>
<button
aria-label="Предыдущий месяц"
aria-label={t("chat.calendar.prevMonth")}
class="step"
onclick={() => shift(-1)}
type="button"
>
<Icon name="arrow-left" />
</button>
<span class="label">{MONTHS[view.month]} {view.year}</span>
<span class="label">{monthLabel}</span>
<button
aria-label="Следующий месяц"
aria-label={t("chat.calendar.nextMonth")}
class="step"
disabled={!canNext}
onclick={() => shift(1)}
@@ -200,7 +217,7 @@
<Icon name="arrow-right" />
</button>
<button
aria-label="Следующий год"
aria-label={t("chat.calendar.nextYear")}
class="step"
disabled={!canNext}
onclick={() => shift(12)}
@@ -211,7 +228,7 @@
</header>
<div class="weekdays">
{#each WEEKDAYS as weekday (weekday)}
{#each weekdays as weekday (weekday)}
<span>{weekday}</span>
{/each}
</div>
@@ -225,7 +242,7 @@
<button
class="cell day lvl-{cell.level}"
onclick={() => jump(cell)}
title={`${cell.count} сообщений`}
title={t("chat.calendar.count", { count: cell.count })}
type="button"
>
{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 About from "#lib/components/settings/About.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 MyAccount from "#lib/components/settings/MyAccount.svelte";
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
@@ -39,6 +40,7 @@
</div>
<div class="section">
<CaptureLine />
<SettingsItem
icon="settings"
label={t("settings.policy")}
@@ -83,6 +85,11 @@
<AlertBadge inline />
{/snippet}
</SettingsItem>
<SettingsItem
icon="note"
label={t("notes.title")}
onclick={() => ui.openPanel("notes")}
/>
</div>
<About />
@@ -1,7 +1,11 @@
<script lang="ts" module>
import { createContext } from "svelte";
type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>();
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
</script>
<script lang="ts">
@@ -18,6 +22,10 @@
let open = $state(false);
let suppressClick = false;
provideMenuCloser(() => {
open = false;
});
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown 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 = {
date: { day: "numeric", month: "short", year: "numeric" },
day: { day: "numeric", month: "short" },
exact: {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
month: "long",
second: "2-digit",
weekday: "long",
year: "numeric",
},
full: {
day: "numeric",
hour: "2-digit",
@@ -12,6 +21,14 @@ const OPTIONS = {
},
longDay: { day: "numeric", month: "long", weekday: "long" },
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" },
} satisfies Record<string, Intl.DateTimeFormatOptions>;
@@ -47,6 +64,37 @@ export function formatFull(iso: string): string {
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 {
if (!iso) {
return "";
+6
View File
@@ -1,6 +1,8 @@
import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.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 { feed } from "#lib/i18n/messages/feed.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 { read } from "#lib/i18n/messages/read.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 { shares } from "#lib/i18n/messages/shares.js";
import { social } from "#lib/i18n/messages/social.js";
@@ -25,6 +28,8 @@ export const messages = {
...account,
...alerts,
...bots,
...capture,
...chat,
...common,
...feed,
...jobs,
@@ -36,6 +41,7 @@ export const messages = {
...profile,
...read,
...search,
...service,
...settings,
...shares,
...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 = {
"notes.add": { en: "Add note", ru: "Добавить заметку" },
"notes.all": { en: "All", ru: "Все" },
"notes.edit": { en: "Edit", ru: "Изменить" },
"notes.placeholder": {
en: "Note on the message…",
ru: "Заметка к сообщению…",
"notes.empty": {
en: "Right-click a message and pick Notes to write one",
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: "Заметки" },
} 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_MAX = 12;
const PAGE_SIZE = 40;
const CHANNEL_IDS = -1_000_000_000_000;
const ALL = "all";
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) {
if (event.type === "delete") {
applyDelete(event.chat_id, event.message_ids);
return;
}
if (event.type !== "message") {
return;
}
@@ -174,6 +197,8 @@ function createChats() {
}
seenChat = true;
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_text = message.text;
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 =
| "changes"
| "profile"
| "search"
| "versions"
| "reactions"
| "readers"
| "callbacks"
| "links"
| "annotations"
| "notes"
| "jobs"
| "presence"
| "stories"
@@ -24,32 +27,82 @@ export type RightPanel =
export type LeftView = "main" | "settings";
export interface PanelState {
chatId: number | null;
messageId: number | null;
name: RightPanel;
peerId: number | null;
}
interface JumpTarget {
chatId: number;
messageId: number;
}
interface PersonTarget {
chatId: number;
peerId: number;
function currentChat(): number | null {
return page.params.chatId ? Number(page.params.chatId) : null;
}
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() {
let rightPanel = $state<RightPanel | null>(null);
let panelMessageId = $state<number | null>(null);
let panel = $state.raw<PanelState | null>(null);
let leftColumnOpen = $state(true);
let leftView = $state<LeftView>("main");
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 {
clearJump() {
jumpTarget = null;
},
closePanel() {
rightPanel = null;
panelMessageId = null;
person = null;
if (panel === null) {
return;
}
panel = null;
if (page.state.panel !== undefined) {
history.back();
}
},
closeSettings() {
leftView = "main";
@@ -63,43 +116,60 @@ function createUi() {
get leftView() {
return leftView;
},
openMessagePanel(panel: RightPanel, messageId: number) {
rightPanel = panel;
panelMessageId = messageId;
markInApp,
openMessagePanel(name: RightPanel, messageId: number) {
show({ chatId: currentChat(), messageId, name, peerId: null });
},
openPanel(panel: RightPanel) {
rightPanel = panel;
panelMessageId = null;
person = null;
openPanel(name: RightPanel) {
show({ chatId: null, messageId: null, name, peerId: null });
},
openPerson(chatId: number, peerId: number) {
rightPanel = "profile";
panelMessageId = null;
person = { chatId, peerId };
show({ chatId, messageId: null, name: "profile", peerId });
},
openSettings() {
leftView = "settings";
},
get panel() {
return panel;
},
get panelMessageId() {
return panelMessageId;
return panel?.messageId ?? 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) {
jumpTarget = { chatId, messageId };
},
resetPanel() {
panel = null;
},
restore(next: PanelState | null) {
if (!samePanel(panel, next)) {
panel = next;
}
},
get rightPanel() {
return rightPanel;
return panel?.name ?? null;
},
setChat(chatId: number | null) {
if (person !== null && person.chatId !== chatId) {
person = null;
if (panel === null || panel.chatId === null || panel.chatId === chatId) {
return;
}
panel =
panel.messageId === null
? { ...panel, chatId: null, peerId: null }
: null;
},
setLeftColumn(open: boolean) {
leftColumnOpen = open;
},
settled(): Promise<unknown> {
return writing;
},
toggleLeftColumn() {
leftColumnOpen = !leftColumnOpen;
},
+69 -2
View File
@@ -5,6 +5,7 @@
import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte";
import FeedView from "#lib/components/feed/FeedView.svelte";
import Hotkeys from "#lib/components/Hotkeys.svelte";
import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte";
@@ -18,12 +19,15 @@
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ALERTS_PARAM } from "#lib/stores/alerts.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 { 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 { shareUi } from "#lib/stores/shares.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";
let { children } = $props();
@@ -42,6 +46,64 @@
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(() => {
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
untrack(() => {
@@ -59,14 +121,17 @@
}
shownAccount = id;
untrack(() => {
ui.closePanel();
ui.resetPanel();
search.reset();
});
});
onMount(() => {
const tag = page.url.searchParams.get(NOTES_PARAM);
if (page.url.searchParams.has(ALERTS_PARAM)) {
ui.openPanel("alerts");
} else if (tag !== null) {
notes.openTag(tag || null);
}
});
</script>
@@ -136,6 +201,8 @@
{/if}
</div>
<Hotkeys />
<PolicyDialog />
<ShareDialog