Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aa8690e33 | ||
|
|
dadba4d545 | ||
|
|
fffbb08d36 | ||
|
|
c247dd0217 | ||
|
|
b9a0604426 | ||
|
|
9f16e2aa56 | ||
|
|
010fa29ce0 | ||
|
|
395d58464d | ||
|
|
83c57ac96b | ||
|
|
45c19cac20 |
No files matched your search
@@ -19,12 +19,12 @@ Nothing is ever deleted from the archive.
|
|||||||
## Parts
|
## Parts
|
||||||
|
|
||||||
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
|
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
|
||||||
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs.
|
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, a feed of what was deleted or edited across chats, search, stories, presence graphs.
|
||||||
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
|
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
|
||||||
|
|
||||||
## MCP
|
## MCP
|
||||||
|
|
||||||
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `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: `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.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""edit versions everywhere
|
||||||
|
|
||||||
|
Revision ID: a2c4e6f8b1d3
|
||||||
|
Revises: e8b3f1a6c2d4
|
||||||
|
Create Date: 2026-10-19 02:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "a2c4e6f8b1d3"
|
||||||
|
down_revision: str | None = "e8b3f1a6c2d4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE capture_policy SET track_edits_deletes = TRUE, updated_at = now() "
|
||||||
|
"WHERE scope_type IN ('default_group', 'default_channel') "
|
||||||
|
"AND NOT track_edits_deletes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE capture_policy SET track_edits_deletes = FALSE, updated_at = now() "
|
||||||
|
"WHERE scope_type IN ('default_group', 'default_channel') "
|
||||||
|
"AND track_edits_deletes"
|
||||||
|
)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""feed of deleted and edited messages
|
||||||
|
|
||||||
|
Revision ID: b3d5f7a9c2e4
|
||||||
|
Revises: a2c4e6f8b1d3
|
||||||
|
Create Date: 2026-10-19 02:40:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b3d5f7a9c2e4"
|
||||||
|
down_revision: str | None = "a2c4e6f8b1d3"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_ITEMS = """
|
||||||
|
CREATE TABLE feed_items (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
account_id integer NOT NULL,
|
||||||
|
kind text NOT NULL,
|
||||||
|
chat_id bigint NOT NULL,
|
||||||
|
chat_kind text NOT NULL,
|
||||||
|
message_id bigint NOT NULL,
|
||||||
|
message_date timestamptz,
|
||||||
|
at timestamptz NOT NULL,
|
||||||
|
since timestamptz NOT NULL,
|
||||||
|
message_count integer NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SEEN = """
|
||||||
|
CREATE TABLE feed_seen (
|
||||||
|
account_id integer PRIMARY KEY,
|
||||||
|
seen_at timestamptz NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CHAT_KIND = """
|
||||||
|
CREATE FUNCTION feed_chat_kind(chat_id bigint, chat_type text)
|
||||||
|
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN chat_id > 0 THEN 'dm'
|
||||||
|
WHEN chat_type = 'ChatType.CHANNEL' THEN 'channel'
|
||||||
|
ELSE 'group' END
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MESSAGE_DATE = """
|
||||||
|
CREATE FUNCTION feed_message_date(stamp text)
|
||||||
|
RETURNS timestamptz LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN stamp ~ '^\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$'
|
||||||
|
THEN stamp::timestamp AT TIME ZONE 'UTC' END
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ON_DELETE = """
|
||||||
|
CREATE FUNCTION feed_on_delete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
item feed_items%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO item FROM feed_items
|
||||||
|
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
|
||||||
|
AND kind = 'deleted'
|
||||||
|
ORDER BY at DESC, id DESC LIMIT 1;
|
||||||
|
IF FOUND AND NEW.deleted_at BETWEEN item.since - interval '5 seconds'
|
||||||
|
AND item.at + interval '5 seconds' THEN
|
||||||
|
IF (NEW.date, NEW.message_id) > (item.message_date, item.message_id) THEN
|
||||||
|
item.message_id := NEW.message_id;
|
||||||
|
item.message_date := NEW.date;
|
||||||
|
END IF;
|
||||||
|
UPDATE feed_items SET
|
||||||
|
message_count = message_count + 1,
|
||||||
|
at = greatest(at, NEW.deleted_at),
|
||||||
|
since = least(since, NEW.deleted_at),
|
||||||
|
message_id = item.message_id,
|
||||||
|
message_date = item.message_date
|
||||||
|
WHERE id = item.id;
|
||||||
|
ELSE
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, message_date,
|
||||||
|
at, since)
|
||||||
|
VALUES (NEW.account_id, 'deleted', NEW.chat_id,
|
||||||
|
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
|
||||||
|
NEW.message_id, NEW.date, NEW.deleted_at, NEW.deleted_at);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ON_VERSION = """
|
||||||
|
CREATE FUNCTION feed_on_version() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.edit_date IS NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM message_versions v
|
||||||
|
WHERE v.account_id = NEW.account_id AND v.chat_id = NEW.chat_id
|
||||||
|
AND v.message_id = NEW.message_id AND v.observed_at < NEW.observed_at
|
||||||
|
) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since)
|
||||||
|
VALUES (NEW.account_id, 'edited', NEW.chat_id,
|
||||||
|
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
|
||||||
|
NEW.message_id, feed_message_date(NEW.raw->>'date'),
|
||||||
|
NEW.observed_at, NEW.observed_at);
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BACKFILL = """
|
||||||
|
WITH d AS (
|
||||||
|
SELECT account_id, chat_id, message_id, date, deleted_at,
|
||||||
|
raw->'chat'->>'type' AS chat_type,
|
||||||
|
deleted_at - lag(deleted_at) OVER (
|
||||||
|
PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id
|
||||||
|
) AS gap
|
||||||
|
FROM messages WHERE deleted_at IS NOT NULL
|
||||||
|
), b AS (
|
||||||
|
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '5 seconds')
|
||||||
|
OVER (PARTITION BY account_id, chat_id ORDER BY deleted_at, message_id)
|
||||||
|
AS burst
|
||||||
|
FROM d
|
||||||
|
), deleted AS (
|
||||||
|
SELECT account_id, 'deleted' AS kind, chat_id,
|
||||||
|
feed_chat_kind(chat_id,
|
||||||
|
(array_agg(chat_type ORDER BY date DESC, message_id DESC))[1])
|
||||||
|
AS chat_kind,
|
||||||
|
(array_agg(message_id ORDER BY date DESC, message_id DESC))[1]
|
||||||
|
AS message_id,
|
||||||
|
max(date) AS message_date, max(deleted_at) AS at,
|
||||||
|
min(deleted_at) AS since, count(*)::integer AS message_count
|
||||||
|
FROM b GROUP BY account_id, chat_id, burst
|
||||||
|
), edited AS (
|
||||||
|
SELECT v.account_id, 'edited' AS kind, v.chat_id,
|
||||||
|
feed_chat_kind(v.chat_id, v.raw->'chat'->>'type') AS chat_kind,
|
||||||
|
v.message_id, feed_message_date(v.raw->>'date') AS message_date,
|
||||||
|
v.observed_at AS at, v.observed_at AS since, 1 AS message_count
|
||||||
|
FROM message_versions v
|
||||||
|
WHERE v.edit_date IS NOT NULL OR EXISTS (
|
||||||
|
SELECT 1 FROM message_versions p
|
||||||
|
WHERE p.account_id = v.account_id AND p.chat_id = v.chat_id
|
||||||
|
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, message_date, at, since,
|
||||||
|
message_count)
|
||||||
|
SELECT * FROM (SELECT * FROM deleted UNION ALL SELECT * FROM edited) items
|
||||||
|
ORDER BY at, chat_id, message_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("SET LOCAL jit = off")
|
||||||
|
op.execute(_ITEMS)
|
||||||
|
op.execute(_SEEN)
|
||||||
|
op.execute(_CHAT_KIND)
|
||||||
|
op.execute(_MESSAGE_DATE)
|
||||||
|
op.execute(_ON_DELETE)
|
||||||
|
op.execute(_ON_VERSION)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER messages_feed_deleted AFTER UPDATE OF deleted_at "
|
||||||
|
"ON messages FOR EACH ROW "
|
||||||
|
"WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL) "
|
||||||
|
"EXECUTE FUNCTION feed_on_delete()"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER message_versions_feed AFTER INSERT ON message_versions "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION feed_on_version()"
|
||||||
|
)
|
||||||
|
op.execute(_BACKFILL)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_feed_items_recent ON feed_items (account_id, at DESC, id DESC)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_feed_items_chat ON feed_items "
|
||||||
|
"(account_id, chat_id, at DESC, id DESC)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO feed_seen (account_id, seen_at) SELECT account_id, now() "
|
||||||
|
"FROM accounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS message_versions_feed ON message_versions")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_feed_deleted ON messages")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_on_version()")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_on_delete()")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_message_date(text)")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_chat_kind(bigint, text)")
|
||||||
|
op.execute("DROP TABLE IF EXISTS feed_seen")
|
||||||
|
op.execute("DROP TABLE IF EXISTS feed_items")
|
||||||
@@ -25,6 +25,7 @@ from api.routers import (
|
|||||||
custom_emoji,
|
custom_emoji,
|
||||||
discover,
|
discover,
|
||||||
events,
|
events,
|
||||||
|
feed,
|
||||||
files,
|
files,
|
||||||
folders,
|
folders,
|
||||||
media,
|
media,
|
||||||
@@ -96,6 +97,7 @@ app.include_router(folders.router)
|
|||||||
app.include_router(backfill.router)
|
app.include_router(backfill.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
app.include_router(chats.router)
|
app.include_router(chats.router)
|
||||||
|
app.include_router(feed.router)
|
||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(avatars.router)
|
app.include_router(avatars.router)
|
||||||
app.include_router(custom_emoji.router)
|
app.include_router(custom_emoji.router)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -23,6 +24,7 @@ from utils.files import resolve_mime
|
|||||||
from utils.read import (
|
from utils.read import (
|
||||||
annotations,
|
annotations,
|
||||||
chats,
|
chats,
|
||||||
|
feed,
|
||||||
media,
|
media,
|
||||||
peers,
|
peers,
|
||||||
people,
|
people,
|
||||||
@@ -32,6 +34,7 @@ from utils.read import (
|
|||||||
watches,
|
watches,
|
||||||
)
|
)
|
||||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||||
|
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
|
||||||
from utils.read.models import DEFAULT_LIMIT, Page
|
from utils.read.models import DEFAULT_LIMIT, Page
|
||||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
||||||
from utils.read.read_receipts import read_info
|
from utils.read.read_receipts import read_info
|
||||||
@@ -73,8 +76,10 @@ Typical flows:
|
|||||||
transcripts; supports chat/sender/date filters and regex).
|
transcripts; supports chat/sender/date filters and regex).
|
||||||
- People: `get_person` gives one person in full: profile changes, shared groups,
|
- People: `get_person` gives one person in full: profile changes, shared groups,
|
||||||
joins and leaves, deleted message count, the private chat.
|
joins and leaves, deleted message count, the private chat.
|
||||||
- Forensics: `get_deleted_messages` and `get_message_versions` recover content
|
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
|
||||||
removed or edited in Telegram but kept in the archive.
|
all chats, newest first, with the text before and after each edit; a bulk
|
||||||
|
deletion is one item with `message_count`. `get_deleted_messages` and
|
||||||
|
`get_message_versions` recover the content of one chat or one message.
|
||||||
- Files: `download_media` gets any message attachment (photo, video, voice,
|
- Files: `download_media` gets any message attachment (photo, video, voice,
|
||||||
document, ...). It pulls the file from Telegram if the archive lacks it and
|
document, ...). It pulls the file from Telegram if the archive lacks it and
|
||||||
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
|
returns a public link valid 24h that needs no auth (`curl -O <link>`), plus
|
||||||
@@ -255,6 +260,38 @@ async def get_deleted_messages(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
async def get_feed(
|
||||||
|
account_id: int,
|
||||||
|
kind: FeedKind = FeedKind.ALL,
|
||||||
|
since: datetime | None = None,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
chat_kind: FeedChatKind | None = None,
|
||||||
|
limit: int = DEFAULT_LIMIT,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Deleted and edited messages across chats, newest first.
|
||||||
|
|
||||||
|
`kind` picks deleted, edited or all; `since` keeps items at or after that
|
||||||
|
time; `chat_id` or `chat_kind` (dm, group, channel) narrow it down. Edited
|
||||||
|
items carry `before` and `after` texts. A deleted item with
|
||||||
|
`message_count` above 1 is a bulk deletion in one chat; read its messages
|
||||||
|
with `get_deleted_messages` for that chat. Pass `next_cursor` back as
|
||||||
|
`cursor` for the next page.
|
||||||
|
"""
|
||||||
|
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
|
||||||
|
msg = "cursor must be a next_cursor returned by get_feed"
|
||||||
|
raise ToolError(msg)
|
||||||
|
page = await feed.list_feed(
|
||||||
|
await _pool(),
|
||||||
|
account_id,
|
||||||
|
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
return page.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
async def get_message_versions(
|
async def get_message_versions(
|
||||||
account_id: int, chat_id: int, message_id: int
|
account_id: int, chat_id: int, message_id: int
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from utils.read import feed
|
||||||
|
from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
|
||||||
|
from utils.read.models import DEFAULT_LIMIT, FeedMessagesPage, FeedPage, FeedUnseen
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/feed", tags=["feed"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
Limit = Annotated[int, Query(ge=1)]
|
||||||
|
Cursor = Annotated[str | None, Query(pattern=CURSOR_PATTERN)]
|
||||||
|
|
||||||
|
|
||||||
|
class SeenRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_feed(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: AccountId,
|
||||||
|
kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
|
||||||
|
chat_id: Annotated[int | None, Query()] = None,
|
||||||
|
chat_kind: Annotated[FeedChatKind | None, Query()] = None,
|
||||||
|
since: Annotated[datetime | None, Query()] = None,
|
||||||
|
cursor: Cursor = None,
|
||||||
|
limit: Limit = DEFAULT_LIMIT,
|
||||||
|
) -> FeedPage:
|
||||||
|
return await feed.list_feed(
|
||||||
|
pool,
|
||||||
|
account_id,
|
||||||
|
FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
|
||||||
|
limit=limit,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unseen")
|
||||||
|
async def feed_unseen(
|
||||||
|
pool: FromDishka[asyncpg.Pool], account_id: AccountId
|
||||||
|
) -> FeedUnseen:
|
||||||
|
return await feed.unseen(pool, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/seen")
|
||||||
|
async def feed_seen(pool: FromDishka[asyncpg.Pool], body: SeenRequest) -> FeedUnseen:
|
||||||
|
return await feed.mark_seen(pool, body.account_id, body.at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{item_id}/messages")
|
||||||
|
async def feed_item_messages(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
item_id: int,
|
||||||
|
account_id: AccountId,
|
||||||
|
cursor: Cursor = None,
|
||||||
|
limit: Limit = DEFAULT_LIMIT,
|
||||||
|
) -> FeedMessagesPage:
|
||||||
|
page = await feed.item_messages(
|
||||||
|
pool, account_id, item_id, limit=limit, cursor=cursor
|
||||||
|
)
|
||||||
|
if page is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Feed item not found")
|
||||||
|
return page
|
||||||
@@ -25,7 +25,7 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
meta = meta_from_chat(chat, ctx.contacts.ids)
|
meta = meta_from_chat(chat, ctx.contacts.ids)
|
||||||
await ctx.watches.on_edit(seen_message(chat_id, message))
|
await ctx.watches.on_edit(seen_message(chat_id, message))
|
||||||
toggles = ctx.resolve(meta)
|
toggles = ctx.resolve(meta)
|
||||||
if not toggles.track_edits_deletes:
|
if not (toggles.messages and toggles.track_edits_deletes):
|
||||||
return
|
return
|
||||||
changed = await repository.add_version(
|
changed = await repository.add_version(
|
||||||
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
ctx.pool, ctx.account_id, message_row(message, chat_id, message.date)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from utils.policy.models import CaptureToggles, ChatKind
|
from utils.policy.models import CaptureToggles, ChatKind
|
||||||
|
|
||||||
DEFAULTS: dict[ChatKind, CaptureToggles] = {
|
DEFAULTS: dict[ChatKind, CaptureToggles] = {
|
||||||
ChatKind.CHANNEL: CaptureToggles(),
|
ChatKind.CHANNEL: CaptureToggles(track_edits_deletes=True),
|
||||||
ChatKind.GROUP: CaptureToggles(messages=True),
|
ChatKind.GROUP: CaptureToggles(messages=True, track_edits_deletes=True),
|
||||||
ChatKind.DM: CaptureToggles(
|
ChatKind.DM: CaptureToggles(
|
||||||
messages=True,
|
messages=True,
|
||||||
media=True,
|
media=True,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.policy.models import FolderSpec
|
from utils.policy.models import FolderSpec
|
||||||
@@ -331,6 +333,12 @@ async def get_deleted_messages(
|
|||||||
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
|
f"ORDER BY deleted_at DESC LIMIT ${len(params) - 1} OFFSET ${len(params)}",
|
||||||
*params,
|
*params,
|
||||||
)
|
)
|
||||||
|
return await _single_views(pool, account_id, rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _single_views(
|
||||||
|
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
|
||||||
|
) -> list[MessageView]:
|
||||||
media_by_key = await _media_map(pool, account_id, rows)
|
media_by_key = await _media_map(pool, account_id, rows)
|
||||||
views: list[MessageView] = []
|
views: list[MessageView] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -341,6 +349,67 @@ async def get_deleted_messages(
|
|||||||
return views
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
async def _rows_at(
|
||||||
|
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime]]
|
||||||
|
) -> list[asyncpg.Record]:
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
params: list[object] = [account_id]
|
||||||
|
clauses: list[str] = []
|
||||||
|
for key in keys:
|
||||||
|
params.extend(key)
|
||||||
|
n = len(params)
|
||||||
|
clauses.append(
|
||||||
|
f"(chat_id = ${n - 2} AND message_id = ${n - 1} AND date = ${n})"
|
||||||
|
)
|
||||||
|
return await pool.fetch(
|
||||||
|
f"SELECT {_MESSAGE_COLS} FROM messages " # noqa: S608
|
||||||
|
f"WHERE account_id = $1 AND ({' OR '.join(clauses)})",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_messages_at(
|
||||||
|
pool: asyncpg.Pool, account_id: int, keys: list[tuple[int, int, datetime | None]]
|
||||||
|
) -> dict[tuple[int, int], MessageView]:
|
||||||
|
dated = list(
|
||||||
|
dict.fromkeys((chat, message, date) for chat, message, date in keys if date)
|
||||||
|
)
|
||||||
|
rows = await _rows_at(pool, account_id, dated)
|
||||||
|
views = {
|
||||||
|
(view.chat_id, view.message_id): view
|
||||||
|
for view in await _single_views(pool, account_id, rows)
|
||||||
|
}
|
||||||
|
for chat_id, message_id, _ in keys:
|
||||||
|
if (chat_id, message_id) not in views:
|
||||||
|
view = await get_message(pool, account_id, chat_id, message_id)
|
||||||
|
if view is not None:
|
||||||
|
views[chat_id, message_id] = view
|
||||||
|
return views
|
||||||
|
|
||||||
|
|
||||||
|
async def get_deleted_between( # noqa: PLR0913
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
chat_id: int,
|
||||||
|
since: datetime,
|
||||||
|
until: datetime,
|
||||||
|
limit: int,
|
||||||
|
before: tuple[datetime, int] | None = None,
|
||||||
|
) -> list[MessageView]:
|
||||||
|
params: list[object] = [account_id, chat_id, since, until, limit]
|
||||||
|
where = "account_id = $1 AND chat_id = $2 AND deleted_at BETWEEN $3 AND $4"
|
||||||
|
if before is not None:
|
||||||
|
params.extend(before)
|
||||||
|
where += " AND (date, message_id) < ($6, $7)"
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"SELECT {_MESSAGE_COLS} FROM messages WHERE {where} " # noqa: S608
|
||||||
|
"ORDER BY date DESC, message_id DESC LIMIT $5",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return await _single_views(pool, account_id, rows)
|
||||||
|
|
||||||
|
|
||||||
async def get_message_versions(
|
async def get_message_versions(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||||
) -> list[MessageVersionView]:
|
) -> list[MessageVersionView]:
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read import chats, peers
|
||||||
|
from utils.read.accounts import self_user_id
|
||||||
|
from utils.read.models import (
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
MAX_LIMIT,
|
||||||
|
ChatListItem,
|
||||||
|
FeedChat,
|
||||||
|
FeedItem,
|
||||||
|
FeedMessagesPage,
|
||||||
|
FeedPage,
|
||||||
|
FeedUnseen,
|
||||||
|
MessageView,
|
||||||
|
PeerView,
|
||||||
|
)
|
||||||
|
|
||||||
|
CURSOR_PATTERN = r"^\d+\.\d+$"
|
||||||
|
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
||||||
|
_MICROSECOND = timedelta(microseconds=1)
|
||||||
|
|
||||||
|
_ITEM_COLS = (
|
||||||
|
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, message_count"
|
||||||
|
)
|
||||||
|
|
||||||
|
_VERSIONS = """
|
||||||
|
SELECT v.chat_id, v.message_id, v.observed_at, v.edit_date, v.text,
|
||||||
|
p.text AS before, p.observed_at IS NOT NULL AS has_before
|
||||||
|
FROM unnest($2::bigint[], $3::bigint[], $4::timestamptz[])
|
||||||
|
AS k(chat_id, message_id, observed_at)
|
||||||
|
JOIN message_versions v
|
||||||
|
ON v.account_id = $1 AND v.chat_id = k.chat_id
|
||||||
|
AND v.message_id = k.message_id AND v.observed_at = k.observed_at
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT text, observed_at FROM message_versions p
|
||||||
|
WHERE p.account_id = $1 AND p.chat_id = v.chat_id
|
||||||
|
AND p.message_id = v.message_id AND p.observed_at < v.observed_at
|
||||||
|
ORDER BY p.observed_at DESC LIMIT 1
|
||||||
|
) p ON true
|
||||||
|
"""
|
||||||
|
|
||||||
|
_UNSEEN = """
|
||||||
|
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
|
||||||
|
count(*) FILTER (WHERE kind = 'edited') AS edited
|
||||||
|
FROM feed_items
|
||||||
|
WHERE account_id = $1 AND at > coalesce($2, '-infinity'::timestamptz)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MARK_SEEN = """
|
||||||
|
INSERT INTO feed_seen (account_id, seen_at) VALUES ($1, coalesce($2, now()))
|
||||||
|
ON CONFLICT (account_id) DO UPDATE
|
||||||
|
SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FeedKind(StrEnum):
|
||||||
|
ALL = "all"
|
||||||
|
DELETED = "deleted"
|
||||||
|
EDITED = "edited"
|
||||||
|
|
||||||
|
|
||||||
|
class FeedChatKind(StrEnum):
|
||||||
|
DM = "dm"
|
||||||
|
GROUP = "group"
|
||||||
|
CHANNEL = "channel"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FeedFilter:
|
||||||
|
kind: FeedKind = FeedKind.ALL
|
||||||
|
chat_id: int | None = None
|
||||||
|
chat_kind: FeedChatKind | None = None
|
||||||
|
since: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(at: datetime, key: int) -> str:
|
||||||
|
return f"{(at - _EPOCH) // _MICROSECOND}.{key}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
|
micros, _, key = cursor.partition(".")
|
||||||
|
return _EPOCH + int(micros) * _MICROSECOND, int(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _where(
|
||||||
|
account_id: int, feed: FeedFilter, cursor: str | None
|
||||||
|
) -> tuple[str, list[object]]:
|
||||||
|
params: list[object] = [account_id]
|
||||||
|
clauses = ["account_id = $1"]
|
||||||
|
conditions = (
|
||||||
|
("kind = {}", None if feed.kind is FeedKind.ALL else feed.kind.value),
|
||||||
|
("chat_id = {}", feed.chat_id),
|
||||||
|
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
|
||||||
|
("at >= {}", feed.since),
|
||||||
|
)
|
||||||
|
for template, value in conditions:
|
||||||
|
if value is not None:
|
||||||
|
params.append(value)
|
||||||
|
clauses.append(template.format(f"${len(params)}"))
|
||||||
|
if cursor is not None:
|
||||||
|
params.extend(decode_cursor(cursor))
|
||||||
|
clauses.append(f"(at, id) < (${len(params) - 1}, ${len(params)})")
|
||||||
|
return " AND ".join(clauses), params
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_name(peer: PeerView) -> str | None:
|
||||||
|
name = " ".join(part for part in (peer.first_name, peer.last_name) if part)
|
||||||
|
return name or peer.username
|
||||||
|
|
||||||
|
|
||||||
|
async def _versions(
|
||||||
|
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
|
||||||
|
) -> dict[tuple[int, int, datetime], asyncpg.Record]:
|
||||||
|
edited = [row for row in rows if row["kind"] == FeedKind.EDITED]
|
||||||
|
if not edited:
|
||||||
|
return {}
|
||||||
|
found = await pool.fetch(
|
||||||
|
_VERSIONS,
|
||||||
|
account_id,
|
||||||
|
[row["chat_id"] for row in edited],
|
||||||
|
[row["message_id"] for row in edited],
|
||||||
|
[row["at"] for row in edited],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
(row["chat_id"], row["message_id"], row["observed_at"]): row for row in found
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sender_names(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
messages: dict[tuple[int, int], MessageView],
|
||||||
|
chat_views: dict[int, ChatListItem],
|
||||||
|
) -> dict[int, str | None]:
|
||||||
|
ids = {m.sender_id for m in messages.values() if m.sender_id is not None}
|
||||||
|
found = await peers.get_peers(pool, account_id, list(ids))
|
||||||
|
names: dict[int, str | None] = {
|
||||||
|
chat_id: chat.title for chat_id, chat in chat_views.items()
|
||||||
|
}
|
||||||
|
names.update({peer.peer_id: _peer_name(peer) for peer in found})
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_items(
|
||||||
|
pool: asyncpg.Pool, account_id: int, rows: list[asyncpg.Record]
|
||||||
|
) -> list[FeedItem]:
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
messages = await chats.get_messages_at(
|
||||||
|
pool,
|
||||||
|
account_id,
|
||||||
|
[(row["chat_id"], row["message_id"], row["message_date"]) for row in rows],
|
||||||
|
)
|
||||||
|
versions = await _versions(pool, account_id, rows)
|
||||||
|
chat_views = {
|
||||||
|
chat.chat_id: chat
|
||||||
|
for chat in await chats.get_chats(
|
||||||
|
pool, account_id, list({row["chat_id"] for row in rows})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
names = await _sender_names(pool, account_id, messages, chat_views)
|
||||||
|
self_id = await self_user_id(pool, account_id)
|
||||||
|
items: list[FeedItem] = []
|
||||||
|
for row in rows:
|
||||||
|
message = messages.get((row["chat_id"], row["message_id"]))
|
||||||
|
chat = chat_views.get(row["chat_id"])
|
||||||
|
version = versions.get((row["chat_id"], row["message_id"], row["at"]))
|
||||||
|
sender_id = message.sender_id if message else None
|
||||||
|
items.append(
|
||||||
|
FeedItem(
|
||||||
|
id=row["id"],
|
||||||
|
kind=row["kind"],
|
||||||
|
at=row["at"],
|
||||||
|
since=row["since"],
|
||||||
|
message_count=row["message_count"],
|
||||||
|
chat=FeedChat(
|
||||||
|
chat_id=row["chat_id"],
|
||||||
|
title=chat.title if chat else None,
|
||||||
|
kind=row["chat_kind"],
|
||||||
|
has_avatar=chat.has_avatar if chat else False,
|
||||||
|
),
|
||||||
|
message_id=row["message_id"],
|
||||||
|
message=message,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=names.get(sender_id) if sender_id is not None else None,
|
||||||
|
outgoing=self_id is not None and sender_id == self_id,
|
||||||
|
before=version["before"] if version else None,
|
||||||
|
after=version["text"] if version else None,
|
||||||
|
has_before=bool(version and version["has_before"]),
|
||||||
|
edit_date=version["edit_date"] if version else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def list_feed(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
feed: FeedFilter,
|
||||||
|
*,
|
||||||
|
limit: int = DEFAULT_LIMIT,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> FeedPage:
|
||||||
|
size = max(1, min(limit, MAX_LIMIT))
|
||||||
|
where, params = _where(account_id, feed, cursor)
|
||||||
|
params.append(size + 1)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
|
||||||
|
f"ORDER BY at DESC, id DESC LIMIT ${len(params)}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
page = rows[:size]
|
||||||
|
more = len(rows) > size
|
||||||
|
return FeedPage(
|
||||||
|
items=await _build_items(pool, account_id, page),
|
||||||
|
next_cursor=encode_cursor(page[-1]["at"], page[-1]["id"]) if more else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
|
||||||
|
seen_at = await pool.fetchval(
|
||||||
|
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
|
||||||
|
)
|
||||||
|
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
|
||||||
|
latest = await list_feed(pool, account_id, FeedFilter(), limit=1)
|
||||||
|
return FeedUnseen(
|
||||||
|
count=counts["deleted"] + counts["edited"],
|
||||||
|
deleted=counts["deleted"],
|
||||||
|
edited=counts["edited"],
|
||||||
|
seen_at=seen_at,
|
||||||
|
latest=latest.items[0] if latest.items else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_seen(
|
||||||
|
pool: asyncpg.Pool, account_id: int, at: datetime | None
|
||||||
|
) -> FeedUnseen:
|
||||||
|
await pool.execute(_MARK_SEEN, account_id, at)
|
||||||
|
return await unseen(pool, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def item_messages(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
item_id: int,
|
||||||
|
*,
|
||||||
|
limit: int = DEFAULT_LIMIT,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> FeedMessagesPage | None:
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT chat_id, since, at FROM feed_items "
|
||||||
|
"WHERE id = $1 AND account_id = $2 AND kind = 'deleted'",
|
||||||
|
item_id,
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
size = max(1, min(limit, MAX_LIMIT))
|
||||||
|
views = await chats.get_deleted_between(
|
||||||
|
pool,
|
||||||
|
account_id,
|
||||||
|
row["chat_id"],
|
||||||
|
row["since"],
|
||||||
|
row["at"],
|
||||||
|
size + 1,
|
||||||
|
decode_cursor(cursor) if cursor else None,
|
||||||
|
)
|
||||||
|
page = views[:size]
|
||||||
|
more = len(views) > size
|
||||||
|
return FeedMessagesPage(
|
||||||
|
messages=page,
|
||||||
|
next_cursor=encode_cursor(page[-1].date, page[-1].message_id) if more else None,
|
||||||
|
)
|
||||||
@@ -212,6 +212,49 @@ class MessageVersionView(BaseModel):
|
|||||||
text: str | None
|
text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedChat(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
title: str | None
|
||||||
|
kind: str
|
||||||
|
has_avatar: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FeedItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
kind: str
|
||||||
|
at: datetime
|
||||||
|
since: datetime
|
||||||
|
message_count: int
|
||||||
|
chat: FeedChat
|
||||||
|
message_id: int
|
||||||
|
message: MessageView | None
|
||||||
|
sender_id: int | None
|
||||||
|
sender_name: str | None
|
||||||
|
outgoing: bool
|
||||||
|
before: str | None = None
|
||||||
|
after: str | None = None
|
||||||
|
has_before: bool = False
|
||||||
|
edit_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedPage(BaseModel):
|
||||||
|
items: list[FeedItem]
|
||||||
|
next_cursor: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedUnseen(BaseModel):
|
||||||
|
count: int
|
||||||
|
deleted: int
|
||||||
|
edited: int
|
||||||
|
seen_at: datetime | None
|
||||||
|
latest: FeedItem | None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedMessagesPage(BaseModel):
|
||||||
|
messages: list[MessageView]
|
||||||
|
next_cursor: str | None
|
||||||
|
|
||||||
|
|
||||||
class MediaView(BaseModel):
|
class MediaView(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
account_id: int
|
account_id: int
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type {
|
|||||||
ChatLinkView,
|
ChatLinkView,
|
||||||
DayCount,
|
DayCount,
|
||||||
DiscoverItem,
|
DiscoverItem,
|
||||||
|
FeedChatKind,
|
||||||
|
FeedKind,
|
||||||
|
FeedMessagesPage,
|
||||||
|
FeedPage,
|
||||||
|
FeedUnseen,
|
||||||
Folder,
|
Folder,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
JobView,
|
JobView,
|
||||||
@@ -274,6 +279,39 @@ export function listDeleted(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FeedQuery {
|
||||||
|
chat_id?: number;
|
||||||
|
chat_kind?: FeedChatKind;
|
||||||
|
cursor?: string;
|
||||||
|
kind?: FeedKind;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFeed(query: FeedQuery = {}): Promise<FeedPage> {
|
||||||
|
return request<FeedPage>("/feed", { account: true, query: { ...query } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeedUnseen(): Promise<FeedUnseen> {
|
||||||
|
return request<FeedUnseen>("/feed/unseen", { account: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markFeedSeen(at?: string): Promise<FeedUnseen> {
|
||||||
|
return request<FeedUnseen>("/feed/seen", {
|
||||||
|
body: { account_id: accounts.selectedId, at },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFeedMessages(
|
||||||
|
itemId: number,
|
||||||
|
query: { cursor?: string; limit?: number } = {}
|
||||||
|
): Promise<FeedMessagesPage> {
|
||||||
|
return request<FeedMessagesPage>(`/feed/${itemId}/messages`, {
|
||||||
|
account: true,
|
||||||
|
query: { ...query },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getPeer(peerId: number): Promise<PeerView> {
|
export function getPeer(peerId: number): Promise<PeerView> {
|
||||||
return request<PeerView>(`/peers/${peerId}`, { account: true });
|
return request<PeerView>(`/peers/${peerId}`, { account: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,52 @@ export interface MessageVersion {
|
|||||||
text: string | null;
|
text: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FeedKind = "all" | "deleted" | "edited";
|
||||||
|
export type FeedChatKind = "dm" | "group" | "channel";
|
||||||
|
|
||||||
|
export interface FeedChat {
|
||||||
|
chat_id: number;
|
||||||
|
has_avatar: boolean;
|
||||||
|
kind: FeedChatKind;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedItem {
|
||||||
|
after: string | null;
|
||||||
|
at: string;
|
||||||
|
before: string | null;
|
||||||
|
chat: FeedChat;
|
||||||
|
edit_date: string | null;
|
||||||
|
has_before: boolean;
|
||||||
|
id: number;
|
||||||
|
kind: Exclude<FeedKind, "all">;
|
||||||
|
message: MessageView | null;
|
||||||
|
message_count: number;
|
||||||
|
message_id: number;
|
||||||
|
outgoing: boolean;
|
||||||
|
sender_id: number | null;
|
||||||
|
sender_name: string | null;
|
||||||
|
since: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPage {
|
||||||
|
items: FeedItem[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedUnseen {
|
||||||
|
count: number;
|
||||||
|
deleted: number;
|
||||||
|
edited: number;
|
||||||
|
latest: FeedItem | null;
|
||||||
|
seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedMessagesPage {
|
||||||
|
messages: MessageView[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaVersion {
|
export interface MediaVersion {
|
||||||
file_size: number | null;
|
file_size: number | null;
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -8,14 +8,17 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { peerName } from "#lib/format/peer.js";
|
import { peerName } from "#lib/format/peer.js";
|
||||||
import { formatPresence, isOnline } from "#lib/format/presence.js";
|
import { formatPresence, isOnline } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { discover } from "#lib/stores/discover.svelte.js";
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
|
import { withoutMessage } from "#lib/stores/feed.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { presence } from "#lib/stores/presence.svelte.js";
|
import { presence } from "#lib/stores/presence.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
@@ -36,14 +39,20 @@
|
|||||||
backfilling = true;
|
backfilling = true;
|
||||||
try {
|
try {
|
||||||
await enqueueBackfill(chatId, true);
|
await enqueueBackfill(chatId, true);
|
||||||
toasts.success("Догружаем новые сообщения");
|
toasts.success(t("chat.backfill.started"));
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось запустить бэкфилл");
|
toasts.error(t("chat.backfill.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
backfilling = false;
|
backfilling = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function back() {
|
||||||
|
const target = new URL(page.url.href);
|
||||||
|
target.pathname = "/app";
|
||||||
|
goto(withoutMessage(target));
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (accounts.selectedId === null) {
|
if (accounts.selectedId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -82,7 +91,9 @@
|
|||||||
const fallbackTitle = $derived(
|
const fallbackTitle = $derived(
|
||||||
chat?.title ??
|
chat?.title ??
|
||||||
discovered?.title ??
|
discovered?.title ??
|
||||||
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
|
(isDm
|
||||||
|
? t("chat.deletedAccount")
|
||||||
|
: t("chat.fallbackTitle", { id: chatId }))
|
||||||
);
|
);
|
||||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||||
const subtitle = $derived.by(() => {
|
const subtitle = $derived.by(() => {
|
||||||
@@ -97,9 +108,11 @@
|
|||||||
}
|
}
|
||||||
const count = chat?.message_count ?? discovered?.message_count ?? 0;
|
const count = chat?.message_count ?? discovered?.message_count ?? 0;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
return `${count} messages`;
|
return t("chat.messages", { count });
|
||||||
}
|
}
|
||||||
return discovered?.kind === "channel" ? "channel" : "group";
|
return discovered?.kind === "channel"
|
||||||
|
? t("chat.kind.channel")
|
||||||
|
: t("chat.kind.group");
|
||||||
});
|
});
|
||||||
const avatarKind = $derived(isDm ? "peer" : "chat");
|
const avatarKind = $derived(isDm ? "peer" : "chat");
|
||||||
const hasAvatar = $derived(
|
const hasAvatar = $derived(
|
||||||
@@ -114,8 +127,8 @@
|
|||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div class="back">
|
<div class="back">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Назад"
|
aria-label={t("common.back")}
|
||||||
onclick={() => goto("/app")}
|
onclick={back}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
variant="translucent"
|
variant="translucent"
|
||||||
@@ -129,7 +142,7 @@
|
|||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label="Открыть профиль"
|
aria-label={t("chat.openProfile")}
|
||||||
class="peek"
|
class="peek"
|
||||||
onclick={() => ui.openPanel("profile")}
|
onclick={() => ui.openPanel("profile")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -157,23 +170,26 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
|
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
|
||||||
>Открыть профиль</ContextMenuItem
|
>{t("chat.openProfile")}</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
|
||||||
|
>{t("feed.changes")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="play-story"
|
icon="play-story"
|
||||||
onselect={() => ui.openPanel("stories")}
|
onselect={() => ui.openPanel("stories")}
|
||||||
>Сторис</ContextMenuItem
|
>{t("settings.stories")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="settings"
|
icon="settings"
|
||||||
onselect={() => policyUi.edit({ chatId }, title)}
|
onselect={() => policyUi.edit({ chatId }, title)}
|
||||||
>Политика захвата</ContextMenuItem
|
>{t("settings.policy")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Поиск в чате"
|
aria-label={t("chat.search")}
|
||||||
onclick={() => ui.openPanel("search")}
|
onclick={() => ui.openPanel("search")}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -182,7 +198,16 @@
|
|||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Аналитика"
|
aria-label={t("feed.changes")}
|
||||||
|
onclick={() => ui.openPanel("changes")}
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
variant="translucent"
|
||||||
|
>
|
||||||
|
<Icon name="recent" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={t("chat.analytics")}
|
||||||
onclick={() => ui.openPanel("presence")}
|
onclick={() => ui.openPanel("presence")}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -191,7 +216,7 @@
|
|||||||
<Icon name="stats" />
|
<Icon name="stats" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Догрузить новые сообщения"
|
aria-label={t("chat.backfill")}
|
||||||
loading={backfilling}
|
loading={backfilling}
|
||||||
onclick={backfill}
|
onclick={backfill}
|
||||||
round
|
round
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||||
|
import FeedRow from "#lib/components/feed/FeedRow.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Skeleton from "#lib/components/ui/Skeleton.svelte";
|
import Skeleton from "#lib/components/ui/Skeleton.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { folders } from "#lib/stores/folders.svelte.js";
|
import { folders } from "#lib/stores/folders.svelte.js";
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
untrack(() => folders.load()).catch(() =>
|
untrack(() => folders.load()).catch(() =>
|
||||||
toasts.error("Failed to load folders")
|
toasts.error(t("chats.foldersFailed"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
scrollTop = 0;
|
scrollTop = 0;
|
||||||
}
|
}
|
||||||
untrack(() => chats.load(folderId)).catch(() =>
|
untrack(() => chats.load(folderId)).catch(() =>
|
||||||
toasts.error("Failed to load chats")
|
toasts.error(t("chats.loadFailed"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +101,9 @@
|
|||||||
bind:this={viewport}
|
bind:this={viewport}
|
||||||
bind:clientHeight={viewportHeight}
|
bind:clientHeight={viewportHeight}
|
||||||
>
|
>
|
||||||
|
{#if folders.selectedId === null}
|
||||||
|
<FeedRow />
|
||||||
|
{/if}
|
||||||
{#if chats.loading && list.length === 0}
|
{#if chats.loading && list.length === 0}
|
||||||
{#each skeletonRows as index (index)}
|
{#each skeletonRows as index (index)}
|
||||||
<div class="row-skeleton">
|
<div class="row-skeleton">
|
||||||
@@ -113,8 +118,10 @@
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
description={folders.selectedId === null
|
description={folders.selectedId === null
|
||||||
? undefined
|
? undefined
|
||||||
: "No chats match this folder yet"}
|
: t("chats.emptyFolderHint")}
|
||||||
title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
|
title={folders.selectedId === null
|
||||||
|
? t("chats.empty")
|
||||||
|
: t("chats.emptyFolder")}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
|
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
|
|
||||||
const title = $derived(
|
const title = $derived(
|
||||||
chat.title ??
|
chat.title ??
|
||||||
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`)
|
(chat.chat_id > 0
|
||||||
|
? t("chat.deletedAccount")
|
||||||
|
: t("chat.fallbackTitle", { id: chat.chat_id }))
|
||||||
);
|
);
|
||||||
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
@@ -45,7 +48,7 @@
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (chat.last_sender_id === ownId) {
|
if (chat.last_sender_id === ownId) {
|
||||||
return "You: ";
|
return `${t("chat.you")}: `;
|
||||||
}
|
}
|
||||||
const peer = peers.get(chat.last_sender_id as number);
|
const peer = peers.get(chat.last_sender_id as number);
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const preview = $derived(
|
const preview = $derived(
|
||||||
chat.last_text ?? (chat.message_count > 0 ? "Media" : "")
|
chat.last_text ?? (chat.message_count > 0 ? t("chat.media") : "")
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -95,24 +98,27 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={onclick}
|
<ContextMenuItem icon="open-in-new-tab" onselect={onclick}
|
||||||
>Открыть</ContextMenuItem
|
>{t("chat.open")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="info" onselect={() => openWith("profile")}
|
<ContextMenuItem icon="info" onselect={() => openWith("profile")}
|
||||||
>Профиль</ContextMenuItem
|
>{t("chat.profile")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="search" onselect={() => openWith("search")}
|
<ContextMenuItem icon="search" onselect={() => openWith("search")}
|
||||||
>Поиск в чате</ContextMenuItem
|
>{t("chat.search")}</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem icon="recent" onselect={() => openWith("changes")}
|
||||||
|
>{t("feed.changes")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="stats" onselect={() => openWith("presence")}
|
<ContextMenuItem icon="stats" onselect={() => openWith("presence")}
|
||||||
>Аналитика</ContextMenuItem
|
>{t("chat.analytics")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
||||||
>Сторис</ContextMenuItem
|
>{t("settings.stories")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="settings"
|
icon="settings"
|
||||||
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
|
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
|
||||||
>Политика захвата</ContextMenuItem
|
>{t("settings.policy")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatDay } from "#lib/format/datetime.js";
|
import { formatDay } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
@@ -117,16 +118,81 @@
|
|||||||
peers.ensure(ids);
|
peers.ensure(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
function jumpToMessage(messageId: number) {
|
function memberIds(message: MessageView): number[] {
|
||||||
|
return [message.message_id, ...message.media.map((ref) => ref.message_id)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function holds(message: MessageView, messageId: number): boolean {
|
||||||
|
return memberIds(message).includes(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAlbum(a: MessageView, b: MessageView): boolean {
|
||||||
|
return (
|
||||||
|
a.media_group_id !== null &&
|
||||||
|
a.media_group_id === b.media_group_id &&
|
||||||
|
a.chat_id === b.chat_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumPrimary(base: MessageView, incoming: MessageView) {
|
||||||
|
if (
|
||||||
|
base.message_id === incoming.message_id ||
|
||||||
|
(incoming.text && !base.text)
|
||||||
|
) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
if (base.text) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return incoming.message_id < base.message_id ? incoming : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinAlbum(base: MessageView, incoming: MessageView): MessageView {
|
||||||
|
const media = new Map(base.media.map((ref) => [ref.message_id, ref]));
|
||||||
|
for (const ref of incoming.media) {
|
||||||
|
media.set(ref.message_id, ref);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...albumPrimary(base, incoming),
|
||||||
|
media: [...media.values()].sort((a, b) => a.message_id - b.message_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAlbums(list: MessageView[]): MessageView[] {
|
||||||
|
const out: MessageView[] = [];
|
||||||
|
for (const message of list) {
|
||||||
|
const prev = out.at(-1);
|
||||||
|
if (prev && sameAlbum(prev, message)) {
|
||||||
|
out[out.length - 1] = joinAlbum(prev, message);
|
||||||
|
} else {
|
||||||
|
out.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownIds(): Set<number> {
|
||||||
|
return new Set(messages.flatMap(memberIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerOf(messageId: number): number | null {
|
||||||
|
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToMessage(messageId: number, smooth = true) {
|
||||||
|
const owner = ownerOf(messageId) ?? messageId;
|
||||||
const target = container?.querySelector<HTMLElement>(
|
const target = container?.querySelector<HTMLElement>(
|
||||||
`[data-message-id="${messageId}"]`
|
`[data-message-id="${owner}"]`
|
||||||
);
|
);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
toasts.error("Message not loaded");
|
toasts.error(t("thread.notFound"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
target.scrollIntoView({
|
||||||
highlightId = messageId;
|
behavior: smooth ? "smooth" : "auto",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
highlightId = owner;
|
||||||
if (highlightTimer) {
|
if (highlightTimer) {
|
||||||
clearTimeout(highlightTimer);
|
clearTimeout(highlightTimer);
|
||||||
}
|
}
|
||||||
@@ -160,20 +226,28 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
hasNewer = false;
|
hasNewer = false;
|
||||||
|
const pending = ui.jumpTarget?.chatId === chatId ? ui.jumpTarget : null;
|
||||||
try {
|
try {
|
||||||
|
if (pending) {
|
||||||
|
ui.clearJump();
|
||||||
|
await loadAround(pending.messageId);
|
||||||
|
await tick();
|
||||||
|
jumpToMessage(pending.messageId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
include_deleted: true,
|
include_deleted: true,
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
});
|
});
|
||||||
messages = [...page].reverse();
|
messages = mergeAlbums([...page].reverse());
|
||||||
hasMore = page.length > 0;
|
hasMore = page.length > 0;
|
||||||
ensurePeers(messages);
|
ensurePeers(messages);
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
updateStuck();
|
updateStuck();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Failed to load messages");
|
toasts.error(t("thread.loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -215,16 +289,18 @@
|
|||||||
const prevTop = el.scrollTop;
|
const prevTop = el.scrollTop;
|
||||||
try {
|
try {
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
before_id: messages[0].message_id,
|
before_id: Math.min(...memberIds(messages[0])),
|
||||||
include_deleted: true,
|
include_deleted: true,
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
});
|
});
|
||||||
const known = new Set(messages.map((m) => m.message_id));
|
const known = knownIds();
|
||||||
const fresh = page.filter((m) => !known.has(m.message_id));
|
const fresh = page.filter((m) =>
|
||||||
|
memberIds(m).some((id) => !known.has(id))
|
||||||
|
);
|
||||||
hasMore = fresh.length > 0;
|
hasMore = fresh.length > 0;
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
suppressAppear = true;
|
suppressAppear = true;
|
||||||
messages = [...[...fresh].reverse(), ...messages];
|
messages = mergeAlbums([...[...fresh].reverse(), ...messages]);
|
||||||
ensurePeers(fresh);
|
ensurePeers(fresh);
|
||||||
await tick();
|
await tick();
|
||||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||||
@@ -242,17 +318,20 @@
|
|||||||
}
|
}
|
||||||
loadingNewer = true;
|
loadingNewer = true;
|
||||||
try {
|
try {
|
||||||
|
const last = messages.at(-1);
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
after_id: messages.at(-1)?.message_id,
|
after_id: last ? Math.max(...memberIds(last)) : undefined,
|
||||||
include_deleted: true,
|
include_deleted: true,
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
});
|
});
|
||||||
const known = new Set(messages.map((m) => m.message_id));
|
const known = knownIds();
|
||||||
const fresh = page.filter((m) => !known.has(m.message_id));
|
const fresh = page.filter((m) =>
|
||||||
|
memberIds(m).some((id) => !known.has(id))
|
||||||
|
);
|
||||||
hasNewer = fresh.length > 0;
|
hasNewer = fresh.length > 0;
|
||||||
if (fresh.length > 0) {
|
if (fresh.length > 0) {
|
||||||
suppressAppear = true;
|
suppressAppear = true;
|
||||||
messages = [...messages, ...fresh];
|
messages = mergeAlbums([...messages, ...fresh]);
|
||||||
ensurePeers(fresh);
|
ensurePeers(fresh);
|
||||||
await tick();
|
await tick();
|
||||||
await trimHead();
|
await trimHead();
|
||||||
@@ -278,7 +357,7 @@
|
|||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
messages = [...[...older].reverse(), ...newer];
|
messages = mergeAlbums([...[...older].reverse(), ...newer]);
|
||||||
hasMore = older.length > 0;
|
hasMore = older.length > 0;
|
||||||
hasNewer = newer.length > 0;
|
hasNewer = newer.length > 0;
|
||||||
ensurePeers(messages);
|
ensurePeers(messages);
|
||||||
@@ -331,13 +410,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function replaceMessage(message: MessageView) {
|
function replaceMessage(message: MessageView) {
|
||||||
messages = messages.map((m) =>
|
messages = messages.map((m) => {
|
||||||
m.message_id === message.message_id ? message : m
|
if (sameAlbum(m, message)) {
|
||||||
);
|
return joinAlbum(m, message);
|
||||||
|
}
|
||||||
|
return m.message_id === message.message_id ? message : m;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function appendMessage(message: MessageView) {
|
async function appendMessage(message: MessageView) {
|
||||||
if (messages.some((m) => m.message_id === message.message_id)) {
|
const last = messages.at(-1);
|
||||||
|
if (
|
||||||
|
messages.some((m) => holds(m, message.message_id)) ||
|
||||||
|
(last && sameAlbum(last, message))
|
||||||
|
) {
|
||||||
replaceMessage(message);
|
replaceMessage(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -357,7 +443,7 @@
|
|||||||
function applyDelete(ids: number[]) {
|
function applyDelete(ids: number[]) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
messages = messages.map((m) =>
|
messages = messages.map((m) =>
|
||||||
ids.includes(m.message_id) && m.deleted_at === null
|
m.deleted_at === null && ids.some((id) => holds(m, id))
|
||||||
? { ...m, deleted_at: now }
|
? { ...m, deleted_at: now }
|
||||||
: m
|
: m
|
||||||
);
|
);
|
||||||
@@ -403,20 +489,24 @@
|
|||||||
});
|
});
|
||||||
const fresh = [...page].reverse();
|
const fresh = [...page].reverse();
|
||||||
const known = new Map(messages.map((m) => [m.message_id, m]));
|
const known = new Map(messages.map((m) => [m.message_id, m]));
|
||||||
const maxId = messages.reduce((acc, m) => Math.max(acc, m.message_id), 0);
|
const maxId = Math.max(0, ...messages.flatMap(memberIds));
|
||||||
const stick = isNearBottom();
|
const stick = isNearBottom();
|
||||||
const appended: MessageView[] = [];
|
const appended: MessageView[] = [];
|
||||||
for (const message of fresh) {
|
for (const message of fresh) {
|
||||||
if (known.has(message.message_id)) {
|
const current = known.get(message.message_id);
|
||||||
known.set(message.message_id, message);
|
if (current) {
|
||||||
|
known.set(
|
||||||
|
message.message_id,
|
||||||
|
sameAlbum(current, message) ? joinAlbum(current, message) : message
|
||||||
|
);
|
||||||
} else if (message.message_id > maxId) {
|
} else if (message.message_id > maxId) {
|
||||||
appended.push(message);
|
appended.push(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages = [
|
messages = mergeAlbums([
|
||||||
...messages.map((m) => known.get(m.message_id) ?? m),
|
...messages.map((m) => known.get(m.message_id) ?? m),
|
||||||
...appended,
|
...appended,
|
||||||
];
|
]);
|
||||||
ensurePeers(fresh);
|
ensurePeers(fresh);
|
||||||
await trimHead();
|
await trimHead();
|
||||||
if (appended.length > 0 && stick) {
|
if (appended.length > 0 && stick) {
|
||||||
@@ -425,12 +515,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function jumpToTarget(messageId: number) {
|
async function goTo(messageId: number) {
|
||||||
if (!messages.some((m) => m.message_id === messageId)) {
|
try {
|
||||||
|
const loaded = ownerOf(messageId) !== null;
|
||||||
|
if (!loaded) {
|
||||||
await loadAround(messageId);
|
await loadAround(messageId);
|
||||||
}
|
}
|
||||||
await tick();
|
await tick();
|
||||||
jumpToMessage(messageId);
|
jumpToMessage(messageId, loaded);
|
||||||
|
} catch {
|
||||||
|
toasts.error(t("thread.loadFailed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -439,7 +534,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.clearJump();
|
ui.clearJump();
|
||||||
jumpToTarget(target.messageId);
|
goTo(target.messageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -460,7 +555,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="message-pane">
|
<div class="message-pane">
|
||||||
<PinnedBar {chatId} onjump={jumpToMessage} />
|
<PinnedBar {chatId} onjump={goTo} />
|
||||||
<div
|
<div
|
||||||
class="message-list custom-scroll"
|
class="message-list custom-scroll"
|
||||||
onscroll={onScroll}
|
onscroll={onScroll}
|
||||||
@@ -490,7 +585,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if row.message.service}
|
{#if row.message.service}
|
||||||
<ActionMessage message={row.message} onjump={jumpToMessage} />
|
<ActionMessage message={row.message} onjump={goTo} />
|
||||||
{:else}
|
{:else}
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
animate={!suppressAppear}
|
animate={!suppressAppear}
|
||||||
@@ -499,7 +594,7 @@
|
|||||||
{isGroupChat}
|
{isGroupChat}
|
||||||
lastInGroup={row.lastInGroup}
|
lastInGroup={row.lastInGroup}
|
||||||
message={row.message}
|
message={row.message}
|
||||||
onjump={jumpToMessage}
|
onjump={goTo}
|
||||||
onmedia={(index) => openMedia(row.message, index)}
|
onmedia={(index) => openMedia(row.message, index)}
|
||||||
onversions={() => openVersions(row.message.message_id)}
|
onversions={() => openVersions(row.message.message_id)}
|
||||||
own={row.own}
|
own={row.own}
|
||||||
@@ -514,7 +609,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if hasNewer}
|
{#if hasNewer}
|
||||||
<button
|
<button
|
||||||
aria-label="К последним сообщениям"
|
aria-label={t("thread.toLatest")}
|
||||||
class="to-latest"
|
class="to-latest"
|
||||||
onclick={goToLatest}
|
onclick={goToLatest}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
||||||
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
|
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
|
||||||
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
|
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
|
||||||
|
import ChatChanges from "#lib/components/feed/ChatChanges.svelte";
|
||||||
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
|
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
|
||||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
||||||
@@ -21,35 +22,37 @@
|
|||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||||
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
|
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = $derived({
|
const titles: Record<RightPanel, MessageKey> = {
|
||||||
alerts: t("settings.alerts"),
|
alerts: "settings.alerts",
|
||||||
annotations: "Заметки",
|
annotations: "panel.annotations",
|
||||||
bots: "Боты",
|
bots: "settings.bots",
|
||||||
callbacks: "Callback-данные",
|
callbacks: "panel.callbacks",
|
||||||
jobs: "Бэкфилл и задачи",
|
changes: "feed.changes",
|
||||||
links: "Ссылки",
|
jobs: "settings.jobs",
|
||||||
policy: "Политика захвата",
|
links: "panel.links",
|
||||||
presence: "Аналитика",
|
policy: "settings.policy",
|
||||||
profile: "Профиль",
|
presence: "chat.analytics",
|
||||||
reactions: "Реакции",
|
profile: "chat.profile",
|
||||||
readers: t("read.readers.title"),
|
reactions: "panel.reactions",
|
||||||
search: "Поиск",
|
readers: "read.readers.title",
|
||||||
shares: "Файлы по ссылке",
|
search: "panel.search",
|
||||||
storage: "Хранилище",
|
shares: "settings.shares",
|
||||||
"storage-chat": "Файлы чата",
|
storage: "settings.storage",
|
||||||
"storage-trash": "Корзина",
|
"storage-chat": "panel.chatFiles",
|
||||||
stories: "Сторис",
|
"storage-trash": "panel.trash",
|
||||||
"stories-all": "Все сторис",
|
stories: "settings.stories",
|
||||||
versions: "Версии",
|
"stories-all": "panel.allStories",
|
||||||
watches: t("settings.watches"),
|
versions: "panel.versions",
|
||||||
webhooks: t("watches.webhooks"),
|
watches: "settings.watches",
|
||||||
});
|
webhooks: "watches.webhooks",
|
||||||
|
};
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
"storage-chat": "storage",
|
"storage-chat": "storage",
|
||||||
@@ -66,14 +69,14 @@
|
|||||||
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
||||||
? chats.byId(storageUi.chatId)
|
? chats.byId(storageUi.chatId)
|
||||||
: undefined;
|
: undefined;
|
||||||
return chat?.title ?? titles[ui.rightPanel];
|
return chat?.title ?? t(titles[ui.rightPanel]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="right-header">
|
<header class="right-header">
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<Button
|
<Button
|
||||||
aria-label="Назад"
|
aria-label={t("common.back")}
|
||||||
onclick={() => ui.openPanel(parent)}
|
onclick={() => ui.openPanel(parent)}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -83,7 +86,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
aria-label="Закрыть"
|
aria-label={t("common.close")}
|
||||||
onclick={() => ui.closePanel()}
|
onclick={() => ui.closePanel()}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -98,6 +101,10 @@
|
|||||||
<div class="right-body custom-scroll">
|
<div class="right-body custom-scroll">
|
||||||
{#if ui.rightPanel === "profile"}
|
{#if ui.rightPanel === "profile"}
|
||||||
<ProfilePanel />
|
<ProfilePanel />
|
||||||
|
{:else if ui.rightPanel === "changes"}
|
||||||
|
{#key accounts.selectedId}
|
||||||
|
<ChatChanges />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "policy"}
|
{:else if ui.rightPanel === "policy"}
|
||||||
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
||||||
<PolicyEditor />
|
<PolicyEditor />
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FeedKind } from "#lib/api/types.js";
|
||||||
|
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
|
||||||
|
import FeedList from "#lib/components/feed/FeedList.svelte";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { isMobile } from "#lib/viewport.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const chatId = $derived(
|
||||||
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
let kind = $state<FeedKind>("all");
|
||||||
|
|
||||||
|
function pick(next: FeedKind) {
|
||||||
|
kind = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(messageId: number) {
|
||||||
|
if (chatId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.requestJump(chatId, messageId);
|
||||||
|
if (isMobile()) {
|
||||||
|
ui.closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if chatId !== null}
|
||||||
|
<FeedFilters {kind} onkind={pick} />
|
||||||
|
{#key chatId}
|
||||||
|
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
import { listFeedMessages } from "#lib/api/endpoints.js";
|
||||||
|
import type { FeedItem, MessageView } from "#lib/api/types.js";
|
||||||
|
import FeedText from "#lib/components/feed/FeedText.svelte";
|
||||||
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { formatDayTime, formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: FeedItem;
|
||||||
|
onopen: (messageId: number) => void;
|
||||||
|
senderOf: (message: MessageView) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item, onopen, senderOf }: Props = $props();
|
||||||
|
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
let messages = $state<MessageView[]>([]);
|
||||||
|
let cursor = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
|
||||||
|
async function load(next: string | null) {
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
try {
|
||||||
|
const page = await listFeedMessages(item.id, {
|
||||||
|
cursor: next ?? undefined,
|
||||||
|
limit: PAGE,
|
||||||
|
});
|
||||||
|
messages = [...messages, ...page.messages];
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
load(null);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bulk">
|
||||||
|
{#each messages as message (message.message_id)}
|
||||||
|
<button
|
||||||
|
class="member"
|
||||||
|
onclick={() => onopen(message.message_id)}
|
||||||
|
type="button"
|
||||||
|
use:ripple
|
||||||
|
>
|
||||||
|
<FeedText deleted {message} prefix={senderOf(message)} />
|
||||||
|
<time
|
||||||
|
class="date"
|
||||||
|
datetime={message.date}
|
||||||
|
title={formatFull(message.date)}
|
||||||
|
>
|
||||||
|
{formatDayTime(message.date)}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if loading}
|
||||||
|
<div class="state"><Spinner /></div>
|
||||||
|
{:else if failed}
|
||||||
|
<button class="more" onclick={() => load(cursor)} type="button">
|
||||||
|
{t("feed.retry")}
|
||||||
|
</button>
|
||||||
|
{:else if cursor !== null}
|
||||||
|
<button class="more" onclick={() => load(cursor)} type="button">
|
||||||
|
{t("feed.more")}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.bulk {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
|
||||||
|
margin: 0 0 0.375rem 4.125rem;
|
||||||
|
padding-inline-start: 0.625rem;
|
||||||
|
border-inline-start: 2px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius-default-small);
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
--ripple-color: var(--color-interactive-element-hover);
|
||||||
|
|
||||||
|
:global(.feed-text) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
|
||||||
|
background-color: var(--color-background-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
import type { FeedItem, MessageView } from "#lib/api/types.js";
|
||||||
|
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
|
||||||
|
import FeedText from "#lib/components/feed/FeedText.svelte";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||||
|
import { wordDiff } from "#lib/format/diff.js";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inChat?: boolean;
|
||||||
|
item: FeedItem;
|
||||||
|
onopen: (messageId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item, onopen, inChat = false }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
|
const bulk = $derived(item.kind === "deleted" && item.message_count > 1);
|
||||||
|
const isGroup = $derived(item.chat.kind === "group");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (item.sender_id !== null && item.sender_id > 0) {
|
||||||
|
peers.ensure([item.sender_id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function senderName(senderId: number | null, fallback: string | null) {
|
||||||
|
if (senderId === null) {
|
||||||
|
return fallback ?? "";
|
||||||
|
}
|
||||||
|
if (senderId === ownId) {
|
||||||
|
return t("chat.you");
|
||||||
|
}
|
||||||
|
const peer = peers.get(senderId);
|
||||||
|
if (peer) {
|
||||||
|
return peerName(peer);
|
||||||
|
}
|
||||||
|
return fallback ?? String(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(senderId: number | null, fallback: string | null) {
|
||||||
|
if (senderId === ownId) {
|
||||||
|
return t("chat.you");
|
||||||
|
}
|
||||||
|
const peer = senderId === null ? undefined : peers.get(senderId);
|
||||||
|
return peer?.first_name || senderName(senderId, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixOf(message: MessageView): string {
|
||||||
|
if (message.sender_id === ownId) {
|
||||||
|
return t("chat.you");
|
||||||
|
}
|
||||||
|
return isGroup ? shortName(message.sender_id, null) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatTitle = $derived(
|
||||||
|
item.chat.title ??
|
||||||
|
(item.chat.chat_id > 0
|
||||||
|
? t("chat.deletedAccount")
|
||||||
|
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
|
||||||
|
);
|
||||||
|
const title = $derived.by(() => {
|
||||||
|
if (!inChat) {
|
||||||
|
return chatTitle;
|
||||||
|
}
|
||||||
|
if (bulk) {
|
||||||
|
return t("feed.bulk", { count: item.message_count });
|
||||||
|
}
|
||||||
|
return senderName(item.sender_id, item.sender_name);
|
||||||
|
});
|
||||||
|
const avatar = $derived.by(() => {
|
||||||
|
if (!inChat) {
|
||||||
|
return {
|
||||||
|
has: item.chat.has_avatar,
|
||||||
|
id: item.chat.chat_id,
|
||||||
|
kind: item.chat.chat_id > 0 ? ("peer" as const) : ("chat" as const),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const id = item.sender_id ?? item.chat.chat_id;
|
||||||
|
return {
|
||||||
|
has: id > 0 ? (peers.get(id)?.has_avatar ?? false) : item.chat.has_avatar,
|
||||||
|
id,
|
||||||
|
kind: id > 0 ? ("peer" as const) : ("chat" as const),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const prefix = $derived.by(() => {
|
||||||
|
if (inChat) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (item.outgoing) {
|
||||||
|
return t("chat.you");
|
||||||
|
}
|
||||||
|
return isGroup ? shortName(item.sender_id, item.sender_name) : "";
|
||||||
|
});
|
||||||
|
const sameText = $derived(item.has_before && item.before === item.after);
|
||||||
|
const diff = $derived(
|
||||||
|
item.kind === "edited" && item.has_before && !sameText
|
||||||
|
? wordDiff(item.before ?? "", item.after ?? "")
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (bulk) {
|
||||||
|
expanded = !expanded;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onopen(item.message_id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="feed-entry">
|
||||||
|
<button
|
||||||
|
aria-expanded={bulk ? expanded : undefined}
|
||||||
|
class="row"
|
||||||
|
onclick={open}
|
||||||
|
type="button"
|
||||||
|
use:ripple
|
||||||
|
>
|
||||||
|
{#if inChat && bulk}
|
||||||
|
<span class="glyph"><Icon name="delete" size="1.5rem" /></span>
|
||||||
|
{:else}
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: avatar.id, kind: avatar.kind }}
|
||||||
|
colorKey={avatar.id}
|
||||||
|
hasAvatar={avatar.has}
|
||||||
|
name={title}
|
||||||
|
size={3}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="body">
|
||||||
|
<span class="top">
|
||||||
|
<span class="title">{title}</span>
|
||||||
|
<time datetime={item.at} title={formatFull(item.at)}>
|
||||||
|
{formatListDate(item.at)}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
{#if bulk}
|
||||||
|
<span class="mark">
|
||||||
|
<Icon name="delete" size="0.875rem" />
|
||||||
|
{inChat
|
||||||
|
? t("feed.deleted")
|
||||||
|
: t("feed.bulk", { count: item.message_count })}
|
||||||
|
<span class="chevron" class:open={expanded}>
|
||||||
|
<Icon name="down" size="1rem" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{#if !expanded}
|
||||||
|
<FeedText deleted message={item.message} {prefix} />
|
||||||
|
{/if}
|
||||||
|
{:else if item.kind === "deleted"}
|
||||||
|
<FeedText
|
||||||
|
deleted
|
||||||
|
mark={t("feed.deleted")}
|
||||||
|
message={item.message}
|
||||||
|
{prefix}
|
||||||
|
/>
|
||||||
|
{:else if sameText}
|
||||||
|
<span class="mark edited">
|
||||||
|
<Icon name="edit" size="0.875rem" />
|
||||||
|
{t("feed.mediaChanged")}
|
||||||
|
</span>
|
||||||
|
<FeedText message={item.message} {prefix} />
|
||||||
|
{:else}
|
||||||
|
<span class="change">
|
||||||
|
<span class="tag">{t("feed.before")}</span>
|
||||||
|
{#if !item.has_before}
|
||||||
|
<span class="missing">{t("feed.lostText")}</span>
|
||||||
|
{:else if !item.before}
|
||||||
|
<span class="missing">{t("feed.noText")}</span>
|
||||||
|
{:else if diff}
|
||||||
|
{#each diff.before as part, index (index)}
|
||||||
|
<span class:removed={part.changed}>{part.text}</span>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{item.before}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="change now">
|
||||||
|
<span class="tag">{t("feed.after")}</span>
|
||||||
|
{#if prefix}
|
||||||
|
<span class="prefix">{prefix}:</span>
|
||||||
|
{/if}
|
||||||
|
{#if !item.after}
|
||||||
|
<span class="missing">{t("feed.noText")}</span>
|
||||||
|
{:else if diff}
|
||||||
|
{#each diff.after as part, index (index)}
|
||||||
|
<span class:added={part.changed}>{part.text}</span>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{item.after}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if bulk && expanded}
|
||||||
|
{#key `${item.id}:${item.message_count}`}
|
||||||
|
<FeedBulk {item} {onopen} senderOf={prefixOf} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5625rem 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius-default-small);
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
color: var(--color-white);
|
||||||
|
|
||||||
|
background-color: var(--color-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-error);
|
||||||
|
|
||||||
|
&.edited {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.change {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
|
||||||
|
&.now {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
margin-inline-end: 0.375rem;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix {
|
||||||
|
margin-inline-end: 0.25rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.added {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chats?: FeedChatKind | null;
|
||||||
|
kind: FeedKind;
|
||||||
|
onchats?: (chats: FeedChatKind | null) => void;
|
||||||
|
onkind: (kind: FeedKind) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { kind, chats = null, onkind, onchats }: Props = $props();
|
||||||
|
|
||||||
|
const KIND_KEYS = {
|
||||||
|
all: "feed.filter.all",
|
||||||
|
deleted: "feed.filter.deleted",
|
||||||
|
edited: "feed.filter.edited",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CHAT_KEYS = {
|
||||||
|
channel: "feed.chats.channel",
|
||||||
|
dm: "feed.chats.dm",
|
||||||
|
group: "feed.chats.group",
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="group">
|
||||||
|
{#each FEED_KINDS as option (option)}
|
||||||
|
<button
|
||||||
|
aria-pressed={kind === option}
|
||||||
|
class="chip"
|
||||||
|
onclick={() => onkind(option)}
|
||||||
|
type="button"
|
||||||
|
class:on={kind === option}
|
||||||
|
>
|
||||||
|
{t(KIND_KEYS[option])}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if onchats}
|
||||||
|
<div class="group">
|
||||||
|
{#each FEED_CHAT_KINDS as option (option)}
|
||||||
|
<button
|
||||||
|
aria-pressed={chats === option}
|
||||||
|
class="chip"
|
||||||
|
onclick={() => onchats(chats === option ? null : option)}
|
||||||
|
type="button"
|
||||||
|
class:on={chats === option}
|
||||||
|
>
|
||||||
|
{t(CHAT_KEYS[option])}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem 0.75rem;
|
||||||
|
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:not(.on):hover {
|
||||||
|
background-color: var(--color-background-secondary-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { visible } from "#lib/actions/visible.js";
|
||||||
|
import { type FeedQuery, listFeed } from "#lib/api/endpoints.js";
|
||||||
|
import type { FeedChatKind, FeedItem, FeedKind } from "#lib/api/types.js";
|
||||||
|
import FeedEntry from "#lib/components/feed/FeedEntry.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 { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { feed } from "#lib/stores/feed.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId?: number;
|
||||||
|
chats?: FeedChatKind | null;
|
||||||
|
kind: FeedKind;
|
||||||
|
onopen: (item: FeedItem, messageId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { kind, chats = null, chatId, onopen }: Props = $props();
|
||||||
|
|
||||||
|
const PAGE = 30;
|
||||||
|
const EMPTY_KEYS = {
|
||||||
|
all: "feed.empty.all",
|
||||||
|
deleted: "feed.empty.deleted",
|
||||||
|
edited: "feed.empty.edited",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let items = $state<FeedItem[]>([]);
|
||||||
|
let cursor = $state<string | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let failed = $state(false);
|
||||||
|
let token = 0;
|
||||||
|
let { revision } = feed;
|
||||||
|
|
||||||
|
const filter = $derived<FeedQuery>({
|
||||||
|
chat_id: chatId,
|
||||||
|
chat_kind: chats ?? undefined,
|
||||||
|
kind,
|
||||||
|
limit: PAGE,
|
||||||
|
});
|
||||||
|
let active: FeedQuery = {};
|
||||||
|
|
||||||
|
function byTime(a: FeedItem, b: FeedItem): number {
|
||||||
|
return Date.parse(b.at) - Date.parse(a.at) || b.id - a.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
token += 1;
|
||||||
|
const mine = token;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
try {
|
||||||
|
const page = await listFeed(active);
|
||||||
|
if (mine === token) {
|
||||||
|
({ items } = page);
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mine === token) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mine === token) {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function more() {
|
||||||
|
if (loading || cursor === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mine = token;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
try {
|
||||||
|
const page = await listFeed({ ...active, cursor });
|
||||||
|
if (mine === token) {
|
||||||
|
const known = new Set(items.map((item) => item.id));
|
||||||
|
items = [...items, ...page.items.filter((item) => !known.has(item.id))];
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mine === token) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mine === token) {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHead() {
|
||||||
|
const mine = token;
|
||||||
|
const page = await listFeed(active);
|
||||||
|
if (mine !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = new Set(page.items.map((item) => item.id));
|
||||||
|
const rest = items.filter((item) => !fresh.has(item.id));
|
||||||
|
items = [...page.items, ...rest].sort(byTime);
|
||||||
|
if (items.length === page.items.length) {
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = filter;
|
||||||
|
if (accounts.selectedId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active = next;
|
||||||
|
untrack(() => reload());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = feed.revision;
|
||||||
|
if (next === revision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revision = next;
|
||||||
|
untrack(() => refreshHead()).catch(() => undefined);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="feed-list">
|
||||||
|
{#if items.length === 0 && loading}
|
||||||
|
<div class="state"><Spinner /></div>
|
||||||
|
{:else if items.length === 0 && failed}
|
||||||
|
<div class="state">
|
||||||
|
<p>{t("feed.error")}</p>
|
||||||
|
<Button onclick={reload} variant="text">{t("feed.retry")}</Button>
|
||||||
|
</div>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<EmptyState title={t(EMPTY_KEYS[kind])} />
|
||||||
|
{:else}
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<FeedEntry
|
||||||
|
inChat={chatId !== undefined}
|
||||||
|
{item}
|
||||||
|
onopen={(messageId) => onopen(item, messageId)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if cursor !== null}
|
||||||
|
{#if failed}
|
||||||
|
<div class="state">
|
||||||
|
<Button onclick={more} variant="text">{t("feed.retry")}</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#key cursor}
|
||||||
|
<div class="state" use:visible={more}>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feed-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 0.25rem 0.4375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
import type { FeedItem } from "#lib/api/types.js";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
|
import { mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { feed } from "#lib/stores/feed.svelte.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const BADGE_LIMIT = 10_000;
|
||||||
|
|
||||||
|
const latest = $derived(feed.unseen?.latest ?? null);
|
||||||
|
const count = $derived(feed.unseen?.count ?? 0);
|
||||||
|
const badge = $derived(
|
||||||
|
count < BADGE_LIMIT
|
||||||
|
? String(count)
|
||||||
|
: new Intl.NumberFormat(locale.tag, { notation: "compact" }).format(count)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (accounts.selectedId !== null) {
|
||||||
|
feed.sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function chatTitle(item: FeedItem): string {
|
||||||
|
return (
|
||||||
|
item.chat.title ??
|
||||||
|
(item.chat.chat_id > 0
|
||||||
|
? t("chat.deletedAccount")
|
||||||
|
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(item: FeedItem): string {
|
||||||
|
if (item.kind === "deleted" && item.message_count > 1) {
|
||||||
|
return t("feed.bulk", { count: item.message_count });
|
||||||
|
}
|
||||||
|
const text = item.kind === "edited" ? item.after : item.message?.text;
|
||||||
|
const media = item.message?.media[0]?.kind ?? null;
|
||||||
|
return text?.trim() || mediaKindLabel(media) || t("feed.noText");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="feed-row"
|
||||||
|
onclick={() => feed.open(page.url)}
|
||||||
|
type="button"
|
||||||
|
use:ripple
|
||||||
|
>
|
||||||
|
<span class="avatar"><Icon name="recent" /></span>
|
||||||
|
<span class="info">
|
||||||
|
<span class="info-row">
|
||||||
|
<span class="title">{t("feed.title")}</span>
|
||||||
|
{#if latest}
|
||||||
|
<span class="date">{formatListDate(latest.at)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="subtitle">
|
||||||
|
<span class="preview">
|
||||||
|
{#if latest}
|
||||||
|
<Icon
|
||||||
|
class="kind"
|
||||||
|
name={latest.kind === "deleted" ? "delete" : "edit"}
|
||||||
|
size="0.875rem"
|
||||||
|
/>
|
||||||
|
<span class="chat">{chatTitle(latest)}:</span>
|
||||||
|
<span
|
||||||
|
class:struck={latest.kind === "deleted" &&
|
||||||
|
latest.message_count === 1}
|
||||||
|
>{summary(latest)}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{t("feed.empty.all")}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if count > 0}
|
||||||
|
<span class="badge">{badge}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feed-row {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 4.5rem;
|
||||||
|
padding: 0.5625rem 0.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 3.375rem;
|
||||||
|
height: 3.375rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--color-white);
|
||||||
|
|
||||||
|
background-color: var(--color-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
:global(.kind) {
|
||||||
|
margin-inline-end: 0.125rem;
|
||||||
|
vertical-align: -0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.struck {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0 0.4375rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
color: var(--color-white);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
background-color: var(--color-gray);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mediaUrl } from "#lib/api/client.js";
|
||||||
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deleted?: boolean;
|
||||||
|
mark?: string;
|
||||||
|
message: MessageView | null;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, deleted = false, mark = "", prefix = "" }: Props = $props();
|
||||||
|
|
||||||
|
const media = $derived(message?.media[0] ?? null);
|
||||||
|
const thumb = $derived(
|
||||||
|
media?.kind === "photo" && media.downloaded && media.id !== null
|
||||||
|
? mediaUrl(media.id)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const kind = $derived.by(() => {
|
||||||
|
if (message?.poll) {
|
||||||
|
return "poll";
|
||||||
|
}
|
||||||
|
if (message?.contact) {
|
||||||
|
return "contact";
|
||||||
|
}
|
||||||
|
if (message?.location) {
|
||||||
|
return "location";
|
||||||
|
}
|
||||||
|
if (message?.sticker) {
|
||||||
|
return "sticker";
|
||||||
|
}
|
||||||
|
return media?.kind ?? (message?.has_media ? "other" : null);
|
||||||
|
});
|
||||||
|
const label = $derived(
|
||||||
|
message?.sticker?.emoji
|
||||||
|
? `${message.sticker.emoji} ${mediaKindLabel(kind)}`
|
||||||
|
: mediaKindLabel(kind)
|
||||||
|
);
|
||||||
|
const text = $derived(message?.text?.trim() || message?.poll?.question || "");
|
||||||
|
const lead = $derived.by(() => {
|
||||||
|
if (!label || (thumb && text)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return text ? `${label}, ` : label;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="feed-text" class:deleted>
|
||||||
|
{#if mark}
|
||||||
|
<span class="mark"><Icon name="delete" size="0.875rem" /> {mark}</span>
|
||||||
|
{/if}
|
||||||
|
{#if prefix}
|
||||||
|
<span class="prefix">{prefix}: </span>
|
||||||
|
{/if}
|
||||||
|
{#if thumb}
|
||||||
|
<img alt="" class="thumb" decoding="async" loading="lazy" src={thumb}>
|
||||||
|
{/if}
|
||||||
|
{#if lead}
|
||||||
|
<span class="label">{lead}</span>
|
||||||
|
{/if}
|
||||||
|
{#if text}
|
||||||
|
<span class="body">{text}</span>
|
||||||
|
{:else if !lead}
|
||||||
|
<span class="empty">{t("feed.noText")}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feed-text {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
margin-inline-end: 0.375rem;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-error);
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
vertical-align: -0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted .body,
|
||||||
|
.deleted .label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-inline-end: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
vertical-align: -0.25rem;
|
||||||
|
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import type { FeedItem } from "#lib/api/types.js";
|
||||||
|
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
|
||||||
|
import FeedList from "#lib/components/feed/FeedList.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import {
|
||||||
|
type FeedFilter,
|
||||||
|
feed,
|
||||||
|
messageHref,
|
||||||
|
} from "#lib/stores/feed.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filter: FeedFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { filter }: Props = $props();
|
||||||
|
|
||||||
|
function open(item: FeedItem, messageId: number) {
|
||||||
|
goto(messageHref(page.url, item.chat.chat_id, messageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
feed.refresh().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { unseen } = feed;
|
||||||
|
if (unseen && unseen.count > 0) {
|
||||||
|
feed.markSeen(unseen.latest?.at).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="feed-view">
|
||||||
|
<header class="feed-header">
|
||||||
|
<Button
|
||||||
|
aria-label={t("common.back")}
|
||||||
|
onclick={() => feed.close(page.url)}
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
variant="translucent"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" />
|
||||||
|
</Button>
|
||||||
|
<h2>{t("feed.title")}</h2>
|
||||||
|
</header>
|
||||||
|
<FeedFilters
|
||||||
|
chats={filter.chats}
|
||||||
|
kind={filter.kind}
|
||||||
|
onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
|
||||||
|
onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
|
||||||
|
/>
|
||||||
|
<div class="feed-body custom-scroll">
|
||||||
|
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feed-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: var(--header-height);
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -79,3 +79,8 @@ export function timeZoneLabel(): string {
|
|||||||
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
|
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDayTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return `${format("day", date)}, ${format("time", date)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface DiffPart {
|
||||||
|
changed: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordDiff {
|
||||||
|
after: DiffPart[];
|
||||||
|
before: DiffPart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = /(\s+)/;
|
||||||
|
const MAX_TOKENS = 400;
|
||||||
|
|
||||||
|
function tokens(text: string): string[] {
|
||||||
|
return text.split(TOKEN).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(parts: DiffPart[], text: string, changed: boolean) {
|
||||||
|
const last = parts.at(-1);
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
parts.push({ changed, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lcsTable(a: string[], b: string[]): Uint16Array {
|
||||||
|
const width = b.length + 1;
|
||||||
|
const table = new Uint16Array((a.length + 1) * width);
|
||||||
|
for (let i = a.length - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = b.length - 1; j >= 0; j -= 1) {
|
||||||
|
table[i * width + j] =
|
||||||
|
a[i] === b[j]
|
||||||
|
? table[(i + 1) * width + j + 1] + 1
|
||||||
|
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordDiff(before: string, after: string): WordDiff | null {
|
||||||
|
const a = tokens(before);
|
||||||
|
const b = tokens(after);
|
||||||
|
if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const table = lcsTable(a, b);
|
||||||
|
const width = b.length + 1;
|
||||||
|
const left: DiffPart[] = [];
|
||||||
|
const right: DiffPart[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < a.length && j < b.length) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
push(left, a[i], false);
|
||||||
|
push(right, b[j], false);
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
||||||
|
push(left, a[i], true);
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
push(right, b[j], true);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const token of a.slice(i)) {
|
||||||
|
push(left, token, true);
|
||||||
|
}
|
||||||
|
for (const token of b.slice(j)) {
|
||||||
|
push(right, token, true);
|
||||||
|
}
|
||||||
|
return { after: right, before: left };
|
||||||
|
}
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
const MEDIA_KIND_LABELS: Record<string, string> = {
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
animation: "GIF",
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
audio: "Аудио",
|
|
||||||
contact: "Контакт",
|
const MEDIA_KIND_KEYS: Record<string, MessageKey> = {
|
||||||
dice: "Кубик",
|
animation: "media.kind.gif",
|
||||||
document: "Файл",
|
audio: "media.kind.audio",
|
||||||
game: "Игра",
|
contact: "media.kind.contact",
|
||||||
gif: "GIF",
|
dice: "media.kind.dice",
|
||||||
location: "Геопозиция",
|
document: "media.kind.document",
|
||||||
photo: "Фото",
|
game: "media.kind.game",
|
||||||
poll: "Опрос",
|
gif: "media.kind.gif",
|
||||||
sticker: "Стикер",
|
location: "media.kind.location",
|
||||||
story: "Сторис",
|
photo: "media.kind.photo",
|
||||||
venue: "Геопозиция",
|
poll: "media.kind.poll",
|
||||||
video: "Видео",
|
sticker: "media.kind.sticker",
|
||||||
video_note: "Кружок",
|
story: "media.kind.story",
|
||||||
voice: "Голосовое",
|
venue: "media.kind.location",
|
||||||
|
video: "media.kind.video",
|
||||||
|
video_note: "media.kind.videoNote",
|
||||||
|
voice: "media.kind.voice",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mediaKindLabel(kind: string | null): string | null {
|
export function mediaKindLabel(kind: string | null): string | null {
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return MEDIA_KIND_LABELS[kind] ?? "Медиа";
|
return t(MEDIA_KIND_KEYS[kind] ?? "media.kind.other");
|
||||||
}
|
}
|
||||||
|
|
||||||
const BYTE_UNITS = ["B", "KB", "MB", "GB"];
|
const BYTE_UNITS = ["B", "KB", "MB", "GB"];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { account } from "#lib/i18n/messages/account.js";
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
import { alerts } from "#lib/i18n/messages/alerts.js";
|
import { alerts } from "#lib/i18n/messages/alerts.js";
|
||||||
import { common } from "#lib/i18n/messages/common.js";
|
import { common } from "#lib/i18n/messages/common.js";
|
||||||
|
import { feed } from "#lib/i18n/messages/feed.js";
|
||||||
import { menu } from "#lib/i18n/messages/menu.js";
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
import { profile } from "#lib/i18n/messages/profile.js";
|
import { profile } from "#lib/i18n/messages/profile.js";
|
||||||
import { read } from "#lib/i18n/messages/read.js";
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
@@ -11,6 +12,7 @@ export const messages = {
|
|||||||
...account,
|
...account,
|
||||||
...alerts,
|
...alerts,
|
||||||
...common,
|
...common,
|
||||||
|
...feed,
|
||||||
...menu,
|
...menu,
|
||||||
...profile,
|
...profile,
|
||||||
...read,
|
...read,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const feed = {
|
||||||
|
"chat.analytics": { en: "Analytics", ru: "Аналитика" },
|
||||||
|
"chat.backfill": {
|
||||||
|
en: "Fetch new messages",
|
||||||
|
ru: "Догрузить новые сообщения",
|
||||||
|
},
|
||||||
|
"chat.backfill.failed": {
|
||||||
|
en: "Couldn't start fetching new messages",
|
||||||
|
ru: "Не удалось начать догрузку",
|
||||||
|
},
|
||||||
|
"chat.backfill.started": {
|
||||||
|
en: "Fetching new messages",
|
||||||
|
ru: "Догружаем новые сообщения",
|
||||||
|
},
|
||||||
|
"chat.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" },
|
||||||
|
"chat.fallbackTitle": { en: "Chat {id}", ru: "Чат {id}" },
|
||||||
|
"chat.kind.channel": { en: "channel", ru: "канал" },
|
||||||
|
"chat.kind.group": { en: "group", ru: "группа" },
|
||||||
|
"chat.media": { en: "Media", ru: "Медиа" },
|
||||||
|
"chat.messages": {
|
||||||
|
en: { one: "{count} message", other: "{count} messages" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} сообщения",
|
||||||
|
many: "{count} сообщений",
|
||||||
|
one: "{count} сообщение",
|
||||||
|
other: "{count} сообщения",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"chat.open": { en: "Open", ru: "Открыть" },
|
||||||
|
"chat.openProfile": { en: "Open profile", ru: "Открыть профиль" },
|
||||||
|
"chat.profile": { en: "Profile", ru: "Профиль" },
|
||||||
|
"chat.search": { en: "Search in chat", ru: "Поиск в чате" },
|
||||||
|
"chat.you": { en: "You", ru: "Вы" },
|
||||||
|
"chats.empty": { en: "No chats yet", ru: "Чатов пока нет" },
|
||||||
|
"chats.emptyFolder": { en: "Empty folder", ru: "Папка пуста" },
|
||||||
|
"chats.emptyFolderHint": {
|
||||||
|
en: "No chats match this folder yet",
|
||||||
|
ru: "В этой папке пока нет чатов",
|
||||||
|
},
|
||||||
|
"chats.foldersFailed": {
|
||||||
|
en: "Couldn't load folders",
|
||||||
|
ru: "Не удалось загрузить папки",
|
||||||
|
},
|
||||||
|
"chats.loadFailed": {
|
||||||
|
en: "Couldn't load chats",
|
||||||
|
ru: "Не удалось загрузить чаты",
|
||||||
|
},
|
||||||
|
"feed.after": { en: "Now", ru: "Стало" },
|
||||||
|
"feed.before": { en: "Was", ru: "Было" },
|
||||||
|
"feed.bulk": {
|
||||||
|
en: { one: "{count} message deleted", other: "{count} messages deleted" },
|
||||||
|
ru: {
|
||||||
|
few: "Удалено {count} сообщения",
|
||||||
|
many: "Удалено {count} сообщений",
|
||||||
|
one: "Удалено {count} сообщение",
|
||||||
|
other: "Удалено {count} сообщения",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"feed.changes": { en: "Changes", ru: "Изменения" },
|
||||||
|
"feed.chats.channel": { en: "Channels", ru: "Каналы" },
|
||||||
|
"feed.chats.dm": { en: "Private", ru: "Личка" },
|
||||||
|
"feed.chats.group": { en: "Groups", ru: "Группы" },
|
||||||
|
"feed.deleted": { en: "deleted", ru: "удалено" },
|
||||||
|
"feed.edited": { en: "edited", ru: "изменено" },
|
||||||
|
"feed.empty.all": {
|
||||||
|
en: "No deleted or edited messages yet",
|
||||||
|
ru: "Удалённых и изменённых пока нет",
|
||||||
|
},
|
||||||
|
"feed.empty.deleted": {
|
||||||
|
en: "No deleted messages yet",
|
||||||
|
ru: "Удалённых пока нет",
|
||||||
|
},
|
||||||
|
"feed.empty.edited": {
|
||||||
|
en: "No edited messages yet",
|
||||||
|
ru: "Изменённых пока нет",
|
||||||
|
},
|
||||||
|
"feed.error": {
|
||||||
|
en: "Couldn't load the feed",
|
||||||
|
ru: "Не удалось загрузить ленту",
|
||||||
|
},
|
||||||
|
"feed.filter.all": { en: "All", ru: "Все" },
|
||||||
|
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
|
||||||
|
"feed.filter.edited": { en: "Edited", ru: "Изменённые" },
|
||||||
|
"feed.lostText": {
|
||||||
|
en: "The earlier text wasn't saved",
|
||||||
|
ru: "Прежний текст не сохранился",
|
||||||
|
},
|
||||||
|
"feed.markSeen": { en: "Mark as seen", ru: "Отметить просмотренным" },
|
||||||
|
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
|
||||||
|
"feed.more": { en: "Show more", ru: "Показать ещё" },
|
||||||
|
"feed.noText": { en: "No text", ru: "Без текста" },
|
||||||
|
"feed.retry": { en: "Retry", ru: "Повторить" },
|
||||||
|
"feed.title": { en: "Feed", ru: "Лента" },
|
||||||
|
"feed.unseen": {
|
||||||
|
en: { one: "{count} new", other: "{count} new" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} новых",
|
||||||
|
many: "{count} новых",
|
||||||
|
one: "{count} новое",
|
||||||
|
other: "{count} новых",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"feed.versions": { en: "All versions", ru: "Все версии" },
|
||||||
|
"media.kind.audio": { en: "Audio", ru: "Аудио" },
|
||||||
|
"media.kind.contact": { en: "Contact", ru: "Контакт" },
|
||||||
|
"media.kind.dice": { en: "Dice", ru: "Кубик" },
|
||||||
|
"media.kind.document": { en: "File", ru: "Файл" },
|
||||||
|
"media.kind.game": { en: "Game", ru: "Игра" },
|
||||||
|
"media.kind.gif": { en: "GIF", ru: "GIF" },
|
||||||
|
"media.kind.location": { en: "Location", ru: "Геопозиция" },
|
||||||
|
"media.kind.other": { en: "Media", ru: "Медиа" },
|
||||||
|
"media.kind.photo": { en: "Photo", ru: "Фото" },
|
||||||
|
"media.kind.poll": { en: "Poll", ru: "Опрос" },
|
||||||
|
"media.kind.sticker": { en: "Sticker", ru: "Стикер" },
|
||||||
|
"media.kind.story": { en: "Story", ru: "Сторис" },
|
||||||
|
"media.kind.video": { en: "Video", ru: "Видео" },
|
||||||
|
"media.kind.videoNote": { en: "Video message", ru: "Кружок" },
|
||||||
|
"media.kind.voice": { en: "Voice message", ru: "Голосовое" },
|
||||||
|
"panel.allStories": { en: "All stories", ru: "Все сторис" },
|
||||||
|
"panel.annotations": { en: "Notes", ru: "Заметки" },
|
||||||
|
"panel.callbacks": { en: "Callback data", ru: "Callback-данные" },
|
||||||
|
"panel.chatFiles": { en: "Chat files", ru: "Файлы чата" },
|
||||||
|
"panel.links": { en: "Links", ru: "Ссылки" },
|
||||||
|
"panel.reactions": { en: "Reactions", ru: "Реакции" },
|
||||||
|
"panel.search": { en: "Search", ru: "Поиск" },
|
||||||
|
"panel.trash": { en: "Trash", ru: "Корзина" },
|
||||||
|
"panel.versions": { en: "Versions", ru: "Версии" },
|
||||||
|
"thread.loadFailed": {
|
||||||
|
en: "Couldn't load messages",
|
||||||
|
ru: "Не удалось загрузить сообщения",
|
||||||
|
},
|
||||||
|
"thread.notFound": {
|
||||||
|
en: "This message isn't in the archive",
|
||||||
|
ru: "Этого сообщения нет в архиве",
|
||||||
|
},
|
||||||
|
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { getFeedUnseen, markFeedSeen } from "#lib/api/endpoints.js";
|
||||||
|
import type { FeedChatKind, FeedKind, FeedUnseen } from "#lib/api/types.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
|
import { browser } from "$app/env";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const LIVE_DELAY = 600;
|
||||||
|
const KIND_PARAM = "feed";
|
||||||
|
const CHATS_PARAM = "chats";
|
||||||
|
const MESSAGE_PARAM = "message";
|
||||||
|
|
||||||
|
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"];
|
||||||
|
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
|
||||||
|
|
||||||
|
export interface FeedFilter {
|
||||||
|
chats: FeedChatKind | null;
|
||||||
|
kind: FeedKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UrlLike {
|
||||||
|
readonly href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedFilterOf(url: UrlLike): FeedFilter | null {
|
||||||
|
const { searchParams } = new URL(url.href);
|
||||||
|
const kind = searchParams.get(KIND_PARAM);
|
||||||
|
if (!FEED_KINDS.includes(kind as FeedKind)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const chats = searchParams.get(CHATS_PARAM);
|
||||||
|
return {
|
||||||
|
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
|
||||||
|
? (chats as FeedChatKind)
|
||||||
|
: null,
|
||||||
|
kind: kind as FeedKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
|
||||||
|
const next = new URL(url.href);
|
||||||
|
next.searchParams.delete(KIND_PARAM);
|
||||||
|
next.searchParams.delete(CHATS_PARAM);
|
||||||
|
next.searchParams.delete(MESSAGE_PARAM);
|
||||||
|
if (filter) {
|
||||||
|
next.searchParams.set(KIND_PARAM, filter.kind);
|
||||||
|
if (filter.chats) {
|
||||||
|
next.searchParams.set(CHATS_PARAM, filter.chats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function href(url: UrlLike): string {
|
||||||
|
const { pathname, search } = new URL(url.href);
|
||||||
|
return `${pathname}${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageHref(
|
||||||
|
url: UrlLike,
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): string {
|
||||||
|
const next = withFilter(url, feedFilterOf(url));
|
||||||
|
next.pathname = `/app/${chatId}`;
|
||||||
|
next.searchParams.set(MESSAGE_PARAM, String(messageId));
|
||||||
|
return href(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withoutMessage(url: UrlLike): string {
|
||||||
|
const next = new URL(url.href);
|
||||||
|
next.searchParams.delete(MESSAGE_PARAM);
|
||||||
|
return href(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeed() {
|
||||||
|
let unseen = $state<FeedUnseen | null>(null);
|
||||||
|
let revision = $state(0);
|
||||||
|
let account: number | null = null;
|
||||||
|
let openedHref: string | null = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const id = accounts.selectedId;
|
||||||
|
if (id === null) {
|
||||||
|
unseen = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = await getFeedUnseen();
|
||||||
|
if (accounts.selectedId === id) {
|
||||||
|
unseen = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (timer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
revision += 1;
|
||||||
|
refresh().catch(() => undefined);
|
||||||
|
}, LIVE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
events.subscribe((event) => {
|
||||||
|
if (event.type === "delete" || event.type === "edit") {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
events.onReconnect(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async close(url: UrlLike) {
|
||||||
|
if (openedHref === href(url)) {
|
||||||
|
openedHref = null;
|
||||||
|
history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto(href(withFilter(url, null)), { replace: true, reset: false });
|
||||||
|
},
|
||||||
|
async filter(url: UrlLike, filter: FeedFilter) {
|
||||||
|
const wasOpened = openedHref === href(url);
|
||||||
|
const target = href(withFilter(url, filter));
|
||||||
|
if (wasOpened) {
|
||||||
|
openedHref = target;
|
||||||
|
}
|
||||||
|
await goto(target, { replace: true, reset: false });
|
||||||
|
},
|
||||||
|
async markSeen(at?: string) {
|
||||||
|
const id = accounts.selectedId;
|
||||||
|
const next = await markFeedSeen(at);
|
||||||
|
if (accounts.selectedId === id) {
|
||||||
|
unseen = next;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async open(url: UrlLike) {
|
||||||
|
const target = href(withFilter(url, { chats: null, kind: "all" }));
|
||||||
|
openedHref = target;
|
||||||
|
await goto(target, { reset: false });
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
get revision() {
|
||||||
|
return revision;
|
||||||
|
},
|
||||||
|
sync() {
|
||||||
|
if (accounts.selectedId === account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
account = accounts.selectedId;
|
||||||
|
unseen = null;
|
||||||
|
refresh().catch(() => undefined);
|
||||||
|
},
|
||||||
|
get unseen() {
|
||||||
|
return unseen;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const feed = createFeed();
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type RightPanel =
|
export type RightPanel =
|
||||||
|
| "changes"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "search"
|
| "search"
|
||||||
| "versions"
|
| "versions"
|
||||||
|
|||||||
@@ -112,9 +112,9 @@
|
|||||||
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
|
--color-primary-shade: #{color.mix($color-primary, $color-black, 92%)};
|
||||||
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
|
--color-primary-shade-darker: #{color.mix($color-primary, $color-black, 84%)};
|
||||||
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
|
--color-primary-shade-rgb: #{toRGB(color.mix($color-primary, $color-black, 92%))};
|
||||||
--color-primary-opacity: rgba(var(--color-primary), 0.2);
|
--color-primary-opacity: #{color.change($color-primary, $alpha: 0.2)};
|
||||||
--color-primary-opacity-hover: rgba(var(--color-primary), 0.25);
|
--color-primary-opacity-hover: #{color.change($color-primary, $alpha: 0.25)};
|
||||||
--color-primary-tint: rgba(var(--color-primary), 0.1);
|
--color-primary-tint: #{color.change($color-primary, $alpha: 0.1)};
|
||||||
--color-active: #{$color-green};
|
--color-active: #{$color-green};
|
||||||
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
|
--color-active-darker: #{color.mix($color-green, $color-black, 84%)};
|
||||||
--color-success: #{$color-green};
|
--color-success: #{$color-green};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import ChatList from "#lib/components/ChatList.svelte";
|
import ChatList from "#lib/components/ChatList.svelte";
|
||||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||||
|
import FeedView from "#lib/components/feed/FeedView.svelte";
|
||||||
import MainMenu from "#lib/components/MainMenu.svelte";
|
import MainMenu from "#lib/components/MainMenu.svelte";
|
||||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
||||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
|
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
|
||||||
import { auth } from "#lib/stores/auth.svelte.js";
|
import { auth } from "#lib/stores/auth.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
|
import { feedFilterOf } from "#lib/stores/feed.svelte.js";
|
||||||
import { search } from "#lib/stores/search.svelte.js";
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
|
|
||||||
// biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent
|
// biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent
|
||||||
const chatOpen = $derived(page.params.chatId != null);
|
const chatOpen = $derived(page.params.chatId != null);
|
||||||
|
const feedFilter = $derived(feedFilterOf(page.url));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!accounts.loaded) {
|
if (!accounts.loaded) {
|
||||||
@@ -81,6 +84,13 @@
|
|||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if feedFilter}
|
||||||
|
<div
|
||||||
|
class="left-view"
|
||||||
|
in:fly={{ duration: 200, easing: cubicOut, x: 64 }}
|
||||||
|
>
|
||||||
|
<FeedView filter={feedFilter} />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="left-view"
|
class="left-view"
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import ChatHeader from "#lib/components/ChatHeader.svelte";
|
import ChatHeader from "#lib/components/ChatHeader.svelte";
|
||||||
import MessageList from "#lib/components/MessageList.svelte";
|
import MessageList from "#lib/components/MessageList.svelte";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { withoutMessage } from "#lib/stores/feed.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const chatId = $derived(Number(page.params.chatId));
|
const chatId = $derived(Number(page.params.chatId));
|
||||||
|
const linkedMessage = $derived(page.url.searchParams.get("message"));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (accounts.selectedId === null) {
|
if (accounts.selectedId === null) {
|
||||||
@@ -14,6 +19,19 @@
|
|||||||
chats.ensure(chatId);
|
chats.ensure(chatId);
|
||||||
chats.enrich(chatId);
|
chats.enrich(chatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (linkedMessage === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageId = Number(linkedMessage);
|
||||||
|
untrack(() => {
|
||||||
|
if (Number.isSafeInteger(messageId)) {
|
||||||
|
ui.requestJump(chatId, messageId);
|
||||||
|
}
|
||||||
|
goto(withoutMessage(page.url), { replace: true, reset: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="chat-view">
|
<div class="chat-view">
|
||||||
|
|||||||
Reference in new issue
Block a user