Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
543e6eaa50 | ||
|
|
7baa32f400 | ||
|
|
e977ab77a8 | ||
|
|
8a1723e99f | ||
|
|
c06597aba2 | ||
|
|
7f3458b3ef | ||
|
|
4a32e32114 | ||
|
|
9c556ff8b9 | ||
|
|
171d8d922d | ||
|
|
848d2aca1a | ||
|
|
7f7fc1296e | ||
|
|
f62c345e2b | ||
|
|
9ae8b34005 | ||
|
|
a0e134756c | ||
|
|
97f9082f5e |
No files matched your search
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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]))
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await retry
|
||||
for task in (retry, beat):
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await listener.stop()
|
||||
await registry.close()
|
||||
await container.close()
|
||||
|
||||
Vendored
+6
-5
@@ -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 {};
|
||||
@@ -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(",") },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
<Avatar
|
||||
avatar={{ id: chat.chat_id, kind: avatarKind }}
|
||||
colorKey={chat.chat_id}
|
||||
hasAvatar={chat.has_avatar}
|
||||
name={title}
|
||||
/>
|
||||
<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}
|
||||
{preview}
|
||||
{#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;
|
||||
|
||||
@@ -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} />
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="recent"
|
||||
onselect={() => ui.openMessagePanel("versions", message.message_id)}
|
||||
>Версии медиа</ContextMenuItem
|
||||
>{t("chat.media.fullscreen")}</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
goto(`/app/${chatId}`);
|
||||
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>
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user