Compare commits
32
Commits
e5a5edcaae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e8a96774 | ||
|
|
d660079c02 | ||
|
|
acfa726e61 | ||
|
|
d9378056f4 | ||
|
|
c307426f4e | ||
|
|
977dee3cb2 | ||
|
|
bca0b34636 | ||
|
|
e228d999dd | ||
|
|
aff8a9e443 | ||
|
|
25364f4523 | ||
|
|
0cd34f3037 | ||
|
|
cff493f5dd | ||
|
|
543e6eaa50 | ||
|
|
7baa32f400 | ||
|
|
e977ab77a8 | ||
|
|
8a1723e99f | ||
|
|
c06597aba2 | ||
|
|
7f3458b3ef | ||
|
|
4a32e32114 | ||
|
|
9c556ff8b9 | ||
|
|
171d8d922d | ||
|
|
848d2aca1a | ||
|
|
7f7fc1296e | ||
|
|
f62c345e2b | ||
|
|
9ae8b34005 | ||
|
|
a0e134756c | ||
|
|
97f9082f5e | ||
|
|
90b37ec708 | ||
|
|
4082adba25 | ||
|
|
b3805123b5 | ||
|
|
896b89f3b0 | ||
|
|
4a5c92f0e9 |
No files matched your search
@@ -11,7 +11,7 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
|
|||||||
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
|
- **Voice** transcribed through Telegram's own transcription (Premium), queued when the quota runs out.
|
||||||
- **Presence** as a time series (TimescaleDB), read receipts, stories.
|
- **Presence** as a time series (TimescaleDB), read receipts, stories.
|
||||||
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
|
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
|
||||||
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client.
|
- **Annotations and watches**: your notes on messages, with `#tags` and one list of them all, and rules that fire alerts, both yours to write in the web client.
|
||||||
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
|
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
|
||||||
|
|
||||||
Nothing is ever deleted from the archive.
|
Nothing is ever deleted from the archive.
|
||||||
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
|
|||||||
|
|
||||||
## MCP
|
## MCP
|
||||||
|
|
||||||
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_feed`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
|
Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (a readable transcript, deleted messages included) and `get_chat_history_raw` (the same as JSON), `get_deleted_messages`, `get_feed` (deleted and edited messages and profile changes across chats), `get_message_versions`, `get_media`, `get_callbacks`, `get_read_info`, `presence_history`, `presence_stats`, `get_peer_history`, `get_person`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. Two tools write: `set_watch`, and `download_media`, which pulls any attachment and leaves a public link to it for a day, visible and revocable in the web client. There are no send, edit or delete tools and there will not be.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:7dd72af08bdef012fdf433082190d2b5133a96b6a43aca0bb71c2aa793e47e20
|
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim@sha256:331fbb158258608b9391e802305a9a2c9e42d689435c1d98706647122a122ab0
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""chat stats last deleted
|
||||||
|
|
||||||
|
Revision ID: f1c4a8e2d6b3
|
||||||
|
Revises: d8f1a6c3e5b7
|
||||||
|
Create Date: 2026-10-19 04:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "f1c4a8e2d6b3"
|
||||||
|
down_revision: str | None = "d8f1a6c3e5b7"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_APPLY = """
|
||||||
|
CREATE OR REPLACE FUNCTION chat_stats_apply() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
INSERT INTO chat_stats AS cs (account_id, chat_id, message_count,
|
||||||
|
last_date, last_message_id,
|
||||||
|
last_text, last_sender_id{column})
|
||||||
|
VALUES (NEW.account_id, NEW.chat_id, 1,
|
||||||
|
CASE WHEN NEW.date <= now() + interval '1 day'
|
||||||
|
THEN NEW.date END,
|
||||||
|
CASE WHEN NEW.date <= now() + interval '1 day'
|
||||||
|
THEN NEW.message_id END,
|
||||||
|
NEW.text, NEW.sender_id{value})
|
||||||
|
ON CONFLICT (account_id, chat_id) DO UPDATE SET
|
||||||
|
message_count = cs.message_count + 1,
|
||||||
|
last_date = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
||||||
|
THEN EXCLUDED.last_date ELSE cs.last_date END,
|
||||||
|
last_message_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
||||||
|
THEN EXCLUDED.last_message_id
|
||||||
|
ELSE cs.last_message_id END,
|
||||||
|
last_text = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
||||||
|
THEN EXCLUDED.last_text ELSE cs.last_text END,
|
||||||
|
last_sender_id = CASE WHEN chat_stats_newer(cs, EXCLUDED)
|
||||||
|
THEN EXCLUDED.last_sender_id
|
||||||
|
ELSE cs.last_sender_id END{merge};
|
||||||
|
ELSE
|
||||||
|
UPDATE chat_stats
|
||||||
|
SET last_text = NEW.text, last_sender_id = NEW.sender_id{update}
|
||||||
|
WHERE account_id = NEW.account_id
|
||||||
|
AND chat_id = NEW.chat_id
|
||||||
|
AND last_message_id = NEW.message_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_DELETED_PARTS = {
|
||||||
|
"column": ", last_deleted",
|
||||||
|
"value": ", NEW.deleted_at IS NOT NULL",
|
||||||
|
"merge": (
|
||||||
|
",\n last_deleted = CASE WHEN chat_stats_newer(cs, EXCLUDED)"
|
||||||
|
" THEN EXCLUDED.last_deleted ELSE cs.last_deleted END"
|
||||||
|
),
|
||||||
|
"update": ", last_deleted = NEW.deleted_at IS NOT NULL",
|
||||||
|
"deleted": " OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at",
|
||||||
|
}
|
||||||
|
_PLAIN_PARTS = dict.fromkeys(_DELETED_PARTS, "")
|
||||||
|
|
||||||
|
_UPDATE_TRIGGER = (
|
||||||
|
"CREATE TRIGGER messages_chat_stats_update AFTER UPDATE ON messages "
|
||||||
|
"FOR EACH ROW WHEN (OLD.text IS DISTINCT FROM NEW.text "
|
||||||
|
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id{deleted}) "
|
||||||
|
"EXECUTE FUNCTION chat_stats_apply()"
|
||||||
|
)
|
||||||
|
|
||||||
|
_BACKFILL = """
|
||||||
|
UPDATE chat_stats cs SET last_deleted = true
|
||||||
|
FROM messages m
|
||||||
|
WHERE m.account_id = cs.account_id AND m.chat_id = cs.chat_id
|
||||||
|
AND m.message_id = cs.last_message_id AND m.date = cs.last_date
|
||||||
|
AND m.deleted_at IS NOT NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE chat_stats ADD COLUMN last_deleted boolean NOT NULL DEFAULT false"
|
||||||
|
)
|
||||||
|
op.execute(_BACKFILL)
|
||||||
|
op.execute(_APPLY.format(**_DELETED_PARTS))
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
|
||||||
|
op.execute(_UPDATE_TRIGGER.format(**_DELETED_PARTS))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_chat_stats_update ON messages")
|
||||||
|
op.execute(_UPDATE_TRIGGER.format(**_PLAIN_PARTS))
|
||||||
|
op.execute(_APPLY.format(**_PLAIN_PARTS))
|
||||||
|
op.execute("ALTER TABLE chat_stats DROP COLUMN last_deleted")
|
||||||
@@ -21,6 +21,7 @@ from api.routers import (
|
|||||||
avatars,
|
avatars,
|
||||||
backfill,
|
backfill,
|
||||||
bots,
|
bots,
|
||||||
|
capture,
|
||||||
chats,
|
chats,
|
||||||
custom_emoji,
|
custom_emoji,
|
||||||
discover,
|
discover,
|
||||||
@@ -114,6 +115,7 @@ app.include_router(webhooks.router)
|
|||||||
app.include_router(shares.router)
|
app.include_router(shares.router)
|
||||||
app.include_router(storage.router)
|
app.include_router(storage.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
|
app.include_router(capture.router)
|
||||||
|
|
||||||
app.mount("/mcp", mcp_app)
|
app.mount("/mcp", mcp_app)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Query
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from utils.read import annotations
|
from utils.read import annotations
|
||||||
from utils.read.models import DEFAULT_LIMIT, AnnotationView, Page
|
from utils.read.models import DEFAULT_LIMIT, AnnotationView, NoteTag, NoteView, Page
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
|
prefix="/api/annotations", tags=["annotations"], route_class=DishkaRoute
|
||||||
@@ -42,6 +42,26 @@ async def list_annotations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notes")
|
||||||
|
async def list_notes(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
tag: Annotated[str | None, Query()] = None,
|
||||||
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
|
offset: Annotated[int, Query()] = 0,
|
||||||
|
) -> list[NoteView]:
|
||||||
|
return await annotations.list_notes(
|
||||||
|
pool, account_id, Page(limit=limit, offset=offset), tag=tag
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
async def list_tags(
|
||||||
|
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
||||||
|
) -> list[NoteTag]:
|
||||||
|
return await annotations.list_tags(pool, account_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_annotation(
|
async def create_annotation(
|
||||||
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
|
pool: FromDishka[asyncpg.Pool], body: AnnotationCreate
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from utils.read import capture
|
||||||
|
from utils.read.models import CaptureGap, CaptureStatus
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/capture", tags=["capture"], route_class=DishkaRoute)
|
||||||
|
|
||||||
|
AccountId = Annotated[int, Query()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def capture_status(
|
||||||
|
pool: FromDishka[asyncpg.Pool], account_id: AccountId
|
||||||
|
) -> CaptureStatus:
|
||||||
|
return await capture.capture_status(pool, account_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gaps")
|
||||||
|
async def capture_gaps(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: AccountId,
|
||||||
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
|
) -> list[CaptureGap]:
|
||||||
|
return await capture.capture_gaps(
|
||||||
|
pool, account_id, date_from=date_from, date_to=date_to
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ from api.timezone import checked_tz
|
|||||||
from utils.read import presence, presence_stats
|
from utils.read import presence, presence_stats
|
||||||
from utils.read.models import (
|
from utils.read.models import (
|
||||||
DEFAULT_LIMIT,
|
DEFAULT_LIMIT,
|
||||||
|
MAX_LIMIT,
|
||||||
Page,
|
Page,
|
||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
@@ -54,6 +55,16 @@ async def current_presence(
|
|||||||
return await presence.current_presence(pool, account_id, peer_id)
|
return await presence.current_presence(pool, account_id, peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/online")
|
||||||
|
async def online_now(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: AccountId,
|
||||||
|
peer_ids: Annotated[str, Query()],
|
||||||
|
) -> list[PresenceSample]:
|
||||||
|
parsed = [int(part) for part in peer_ids.split(",") if part.strip()]
|
||||||
|
return await presence.online_now(pool, account_id, parsed[:MAX_LIMIT])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hourly")
|
@router.get("/hourly")
|
||||||
async def presence_hourly(
|
async def presence_hourly(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from utils.files import content_disposition, story_file_name
|
from utils.files import content_disposition, sniff_media_kind, story_file_name
|
||||||
from utils.read import peers
|
from utils.read import peers
|
||||||
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
|
from utils.read.models import DEFAULT_LIMIT, Page, StoryView
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -17,17 +17,34 @@ AccountId = Annotated[int, Query()]
|
|||||||
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
|
_STORY_MIME = {"photo": "image/jpeg", "video": "video/mp4"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_kind(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
storage: ContentAddressedStorage,
|
||||||
|
account_id: int,
|
||||||
|
story: StoryView,
|
||||||
|
) -> StoryView:
|
||||||
|
if story.media_kind or not story.storage_key:
|
||||||
|
return story
|
||||||
|
if not storage.exists(story.storage_key):
|
||||||
|
return story
|
||||||
|
kind = sniff_media_kind(storage.head(story.storage_key))
|
||||||
|
await peers.set_story_kind(pool, account_id, story.peer_id, story.story_id, kind)
|
||||||
|
return story.model_copy(update={"media_kind": kind})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stories")
|
@router.get("/stories")
|
||||||
async def list_stories(
|
async def list_stories(
|
||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
storage: FromDishka[ContentAddressedStorage],
|
||||||
account_id: AccountId,
|
account_id: AccountId,
|
||||||
peer_id: Annotated[int | None, Query()] = None,
|
peer_id: Annotated[int | None, Query()] = None,
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
offset: Annotated[int, Query()] = 0,
|
offset: Annotated[int, Query()] = 0,
|
||||||
) -> list[StoryView]:
|
) -> list[StoryView]:
|
||||||
return await peers.get_stories(
|
stories = await peers.get_stories(
|
||||||
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
pool, account_id, Page(limit=limit, offset=offset), peer_id=peer_id
|
||||||
)
|
)
|
||||||
|
return [await _with_kind(pool, storage, account_id, story) for story in stories]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stories/{peer_id}/{story_id}/media")
|
@router.get("/stories/{peer_id}/{story_id}/media")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ _UPSERT_STORY = """
|
|||||||
INSERT INTO stories
|
INSERT INTO stories
|
||||||
(account_id, peer_id, story_id, date, expire_date, caption, media_kind,
|
(account_id, peer_id, story_id, date, expire_date, caption, media_kind,
|
||||||
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
|
storage_key, file_size, downloaded, views, pinned, deleted, raw, observed_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, $13::jsonb, now())
|
||||||
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
|
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
|
||||||
date = EXCLUDED.date,
|
date = EXCLUDED.date,
|
||||||
expire_date = EXCLUDED.expire_date,
|
expire_date = EXCLUDED.expire_date,
|
||||||
@@ -17,10 +17,17 @@ ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET
|
|||||||
downloaded = stories.downloaded OR EXCLUDED.downloaded,
|
downloaded = stories.downloaded OR EXCLUDED.downloaded,
|
||||||
views = EXCLUDED.views,
|
views = EXCLUDED.views,
|
||||||
pinned = EXCLUDED.pinned,
|
pinned = EXCLUDED.pinned,
|
||||||
deleted = EXCLUDED.deleted,
|
deleted = false,
|
||||||
raw = EXCLUDED.raw
|
raw = EXCLUDED.raw
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_MARK_DELETED = """
|
||||||
|
INSERT INTO stories
|
||||||
|
(account_id, peer_id, story_id, downloaded, pinned, deleted, raw, observed_at)
|
||||||
|
VALUES ($1, $2, $3, false, false, true, '{}'::jsonb, now())
|
||||||
|
ON CONFLICT (account_id, peer_id, story_id) DO UPDATE SET deleted = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def is_downloaded(
|
async def is_downloaded(
|
||||||
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||||
@@ -51,7 +58,6 @@ async def upsert_story( # noqa: PLR0913
|
|||||||
raw: str,
|
raw: str,
|
||||||
*,
|
*,
|
||||||
pinned: bool,
|
pinned: bool,
|
||||||
deleted: bool,
|
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
@@ -68,6 +74,11 @@ async def upsert_story( # noqa: PLR0913
|
|||||||
downloaded,
|
downloaded,
|
||||||
views,
|
views,
|
||||||
pinned,
|
pinned,
|
||||||
deleted,
|
|
||||||
raw,
|
raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_deleted(
|
||||||
|
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int
|
||||||
|
) -> None:
|
||||||
|
await pool.execute(_MARK_DELETED, account_id, peer_id, story_id)
|
||||||
@@ -17,13 +17,18 @@ def story_peer_id(story: Story) -> int:
|
|||||||
|
|
||||||
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
|
async def save_story(client: Client, capture: CaptureContext, story: Story) -> None:
|
||||||
peer_id = story_peer_id(story)
|
peer_id = story_peer_id(story)
|
||||||
|
if story.deleted:
|
||||||
|
await repository.mark_deleted(
|
||||||
|
capture.pool, capture.account_id, peer_id, story.id
|
||||||
|
)
|
||||||
|
return
|
||||||
storage_key: str | None = None
|
storage_key: str | None = None
|
||||||
file_size: int | None = None
|
file_size: int | None = None
|
||||||
downloaded = False
|
downloaded = False
|
||||||
stored = await repository.is_downloaded(
|
stored = await repository.is_downloaded(
|
||||||
capture.pool, capture.account_id, peer_id, story.id
|
capture.pool, capture.account_id, peer_id, story.id
|
||||||
)
|
)
|
||||||
if not (stored or story.deleted or story.media is None):
|
if not (stored or story.media is None):
|
||||||
data = await download_bytes(client, story)
|
data = await download_bytes(client, story)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
storage_key = capture.storage.put(data)
|
storage_key = capture.storage.put(data)
|
||||||
@@ -43,6 +48,5 @@ async def save_story(client: Client, capture: CaptureContext, story: Story) -> N
|
|||||||
story.views,
|
story.views,
|
||||||
raw_json(story.raw),
|
raw_json(story.raw),
|
||||||
pinned=bool(story.pinned),
|
pinned=bool(story.pinned),
|
||||||
deleted=bool(story.deleted),
|
|
||||||
downloaded=downloaded,
|
downloaded=downloaded,
|
||||||
)
|
)
|
||||||
@@ -191,6 +191,11 @@ def story_file_name(peer_id: int, story_id: int, media_kind: str | None) -> str:
|
|||||||
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
|
return f"story_{peer_id}_{story_id}{extension_for(media_kind or 'photo', None)}"
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_media_kind(head: bytes) -> str:
|
||||||
|
is_video = head[4:8] == b"ftyp" or head.startswith(b"\x1aE\xdf\xa3")
|
||||||
|
return "video" if is_video else "photo"
|
||||||
|
|
||||||
|
|
||||||
def resolve_mime(
|
def resolve_mime(
|
||||||
kind: str | None, mime: str | None, file_name: str | None = None
|
kind: str | None, mime: str | None, file_name: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.models import AnnotationView, Page
|
from utils.read.chats import get_chats
|
||||||
|
from utils.read.models import AnnotationView, NoteTag, NoteView, Page
|
||||||
|
|
||||||
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
|
_COLS = "id, account_id, chat_id, message_id, text, created_at, updated_at"
|
||||||
|
|
||||||
|
TAG = re.compile(r"(?<!\w)#(\w+)")
|
||||||
|
WORD = re.compile(r"\w+")
|
||||||
|
|
||||||
|
_NOTES = """
|
||||||
|
SELECT a.id, a.account_id, a.chat_id, a.message_id, a.text,
|
||||||
|
a.created_at, a.updated_at,
|
||||||
|
m.text AS message_text, m.date AS message_date,
|
||||||
|
COALESCE(m.has_media, false) AS message_has_media,
|
||||||
|
m.deleted_at IS NOT NULL AS message_deleted
|
||||||
|
FROM annotations a
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT text, date, has_media, deleted_at FROM messages
|
||||||
|
WHERE account_id = a.account_id AND chat_id = a.chat_id
|
||||||
|
AND message_id = a.message_id
|
||||||
|
LIMIT 1
|
||||||
|
) m ON true
|
||||||
|
WHERE a.account_id = $1 AND ($4::text IS NULL OR a.text ~* $4)
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def list_annotations(
|
async def list_annotations(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
@@ -70,3 +95,49 @@ async def update_annotation(
|
|||||||
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
|
async def delete_annotation(pool: asyncpg.Pool, annotation_id: int) -> bool:
|
||||||
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
|
result = await pool.execute("DELETE FROM annotations WHERE id = $1", annotation_id)
|
||||||
return result.endswith("1")
|
return result.endswith("1")
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_pattern(tag: str | None) -> str | None:
|
||||||
|
if tag is None:
|
||||||
|
return None
|
||||||
|
return f"(^|[^[:alnum:]_])#{tag}($|[^[:alnum:]_])"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_notes(
|
||||||
|
pool: asyncpg.Pool, account_id: int, page: Page, *, tag: str | None = None
|
||||||
|
) -> list[NoteView]:
|
||||||
|
if tag is not None and not WORD.fullmatch(tag):
|
||||||
|
return []
|
||||||
|
rows = await pool.fetch(
|
||||||
|
_NOTES, account_id, page.capped_limit, page.offset, _tag_pattern(tag)
|
||||||
|
)
|
||||||
|
chats = {
|
||||||
|
chat.chat_id: chat
|
||||||
|
for chat in await get_chats(
|
||||||
|
pool, account_id, list({row["chat_id"] for row in rows})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notes: list[NoteView] = []
|
||||||
|
for row in rows:
|
||||||
|
chat = chats.get(row["chat_id"])
|
||||||
|
notes.append(
|
||||||
|
NoteView(
|
||||||
|
**dict(row),
|
||||||
|
chat_title=chat.title if chat else None,
|
||||||
|
chat_has_avatar=chat.has_avatar if chat else False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tags(pool: asyncpg.Pool, account_id: int) -> list[NoteTag]:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT text FROM annotations WHERE account_id = $1", account_id
|
||||||
|
)
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
for row in rows:
|
||||||
|
counts.update({tag.lower() for tag in TAG.findall(row["text"])})
|
||||||
|
return [
|
||||||
|
NoteTag(tag=tag, count=count)
|
||||||
|
for tag, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.models import CaptureGap, CaptureStatus
|
||||||
|
|
||||||
|
_STATUS = """
|
||||||
|
SELECT started_at, seen_at, seen_at >= now() - interval '150 seconds' AS live
|
||||||
|
FROM capture_uptime WHERE account_id = $1
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_GAPS = """
|
||||||
|
SELECT seen_at AS start, next_start AS "end"
|
||||||
|
FROM (
|
||||||
|
SELECT seen_at,
|
||||||
|
lead(started_at) OVER (ORDER BY started_at) AS next_start
|
||||||
|
FROM capture_uptime WHERE account_id = $1
|
||||||
|
) stretches
|
||||||
|
WHERE next_start - seen_at > interval '30 minutes'
|
||||||
|
AND ($2::timestamptz IS NULL OR next_start >= $2)
|
||||||
|
AND ($3::timestamptz IS NULL OR seen_at <= $3)
|
||||||
|
ORDER BY seen_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_status(pool: asyncpg.Pool, account_id: int) -> CaptureStatus:
|
||||||
|
row = await pool.fetchrow(_STATUS, account_id)
|
||||||
|
if row is None:
|
||||||
|
return CaptureStatus(live=False, since=None, last_seen=None)
|
||||||
|
return CaptureStatus(
|
||||||
|
live=row["live"], since=row["started_at"], last_seen=row["seen_at"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_gaps(
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
*,
|
||||||
|
date_from: datetime | None = None,
|
||||||
|
date_to: datetime | None = None,
|
||||||
|
) -> list[CaptureGap]:
|
||||||
|
rows = await pool.fetch(_GAPS, account_id, date_from, date_to)
|
||||||
|
return [CaptureGap(**dict(row)) for row in rows]
|
||||||
@@ -80,7 +80,8 @@ _CHAT_ROWS = """
|
|||||||
WITH ids AS ({ids})
|
WITH ids AS ({ids})
|
||||||
SELECT ids.chat_id,
|
SELECT ids.chat_id,
|
||||||
COALESCE(cs.message_count, 0) AS message_count,
|
COALESCE(cs.message_count, 0) AS message_count,
|
||||||
cs.last_date, cs.last_text, cs.last_sender_id,
|
cs.last_date, cs.last_text, cs.last_sender_id, cs.last_message_id,
|
||||||
|
COALESCE(cs.last_deleted, false) AS last_deleted,
|
||||||
COALESCE(named.title,
|
COALESCE(named.title,
|
||||||
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
|
NULLIF(trim(concat_ws(' ', p.first_name, p.last_name)), ''),
|
||||||
p.username) AS title,
|
p.username) AS title,
|
||||||
@@ -149,6 +150,8 @@ def _chat_item(row: asyncpg.Record) -> ChatListItem:
|
|||||||
last_date=row["last_date"],
|
last_date=row["last_date"],
|
||||||
last_text=row["last_text"],
|
last_text=row["last_text"],
|
||||||
last_sender_id=row["last_sender_id"],
|
last_sender_id=row["last_sender_id"],
|
||||||
|
last_message_id=row["last_message_id"],
|
||||||
|
last_deleted=row["last_deleted"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,6 +262,7 @@ async def get_chat_history( # noqa: PLR0913
|
|||||||
views.append(_build_album(members, media_by_key))
|
views.append(_build_album(members, media_by_key))
|
||||||
index = end
|
index = end
|
||||||
await _apply_read_status(pool, account_id, chat_id, views)
|
await _apply_read_status(pool, account_id, chat_id, views)
|
||||||
|
await _apply_notes(pool, account_id, chat_id, views)
|
||||||
return views
|
return views
|
||||||
|
|
||||||
|
|
||||||
@@ -278,6 +282,7 @@ async def get_message(
|
|||||||
raw = load_raw(row["raw"])
|
raw = load_raw(row["raw"])
|
||||||
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
|
view = build_message_view(row, raw, _single_media(row, raw, media_by_key))
|
||||||
await _apply_read_status(pool, account_id, chat_id, [view])
|
await _apply_read_status(pool, account_id, chat_id, [view])
|
||||||
|
await _apply_notes(pool, account_id, chat_id, [view])
|
||||||
return view
|
return view
|
||||||
|
|
||||||
|
|
||||||
@@ -301,6 +306,23 @@ async def _apply_read_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_notes(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int, views: list[MessageView]
|
||||||
|
) -> None:
|
||||||
|
if not views:
|
||||||
|
return
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT DISTINCT message_id FROM annotations "
|
||||||
|
"WHERE account_id = $1 AND chat_id = $2 AND message_id = ANY($3::bigint[])",
|
||||||
|
account_id,
|
||||||
|
chat_id,
|
||||||
|
[view.message_id for view in views],
|
||||||
|
)
|
||||||
|
noted = {row["message_id"] for row in rows}
|
||||||
|
for view in views:
|
||||||
|
view.has_note = view.message_id in noted
|
||||||
|
|
||||||
|
|
||||||
def _build_album(
|
def _build_album(
|
||||||
members: list[tuple[asyncpg.Record, dict]],
|
members: list[tuple[asyncpg.Record, dict]],
|
||||||
media_by_key: dict[tuple[int, int], asyncpg.Record],
|
media_by_key: dict[tuple[int, int], asyncpg.Record],
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class ChatListItem(BaseModel):
|
|||||||
last_date: datetime | None
|
last_date: datetime | None
|
||||||
last_text: str | None
|
last_text: str | None
|
||||||
last_sender_id: int | None
|
last_sender_id: int | None
|
||||||
|
last_message_id: int | None = None
|
||||||
|
last_deleted: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DiscoverItem(BaseModel):
|
class DiscoverItem(BaseModel):
|
||||||
@@ -204,6 +206,7 @@ class MessageView(BaseModel):
|
|||||||
is_animated_emoji: bool = False
|
is_animated_emoji: bool = False
|
||||||
read: bool = False
|
read: bool = False
|
||||||
read_at: datetime | None = None
|
read_at: datetime | None = None
|
||||||
|
has_note: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MessageVersionView(BaseModel):
|
class MessageVersionView(BaseModel):
|
||||||
@@ -611,6 +614,31 @@ class AnnotationView(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NoteView(AnnotationView):
|
||||||
|
chat_title: str | None
|
||||||
|
chat_has_avatar: bool
|
||||||
|
message_text: str | None
|
||||||
|
message_date: datetime | None
|
||||||
|
message_has_media: bool
|
||||||
|
message_deleted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class NoteTag(BaseModel):
|
||||||
|
tag: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureStatus(BaseModel):
|
||||||
|
live: bool
|
||||||
|
since: datetime | None
|
||||||
|
last_seen: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureGap(BaseModel):
|
||||||
|
start: datetime
|
||||||
|
end: datetime
|
||||||
|
|
||||||
|
|
||||||
class WatchView(BaseModel):
|
class WatchView(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
account_id: int
|
account_id: int
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ async def get_stories(
|
|||||||
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
|
"SELECT peer_id, story_id, date, expire_date, caption, media_kind, " # noqa: S608
|
||||||
"storage_key, downloaded, views, pinned, deleted FROM stories "
|
"storage_key, downloaded, views, pinned, deleted FROM stories "
|
||||||
f"WHERE {where} "
|
f"WHERE {where} "
|
||||||
f"ORDER BY date DESC NULLS LAST LIMIT ${len(params) - 1} "
|
f"ORDER BY date DESC NULLS LAST, story_id DESC LIMIT ${len(params) - 1} "
|
||||||
f"OFFSET ${len(params)}",
|
f"OFFSET ${len(params)}",
|
||||||
*params,
|
*params,
|
||||||
)
|
)
|
||||||
@@ -95,3 +95,16 @@ async def get_story(
|
|||||||
story_id,
|
story_id,
|
||||||
)
|
)
|
||||||
return StoryView(**dict(row)) if row else None
|
return StoryView(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_story_kind(
|
||||||
|
pool: asyncpg.Pool, account_id: int, peer_id: int, story_id: int, kind: str
|
||||||
|
) -> None:
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE stories SET media_kind = $4 "
|
||||||
|
"WHERE account_id = $1 AND peer_id = $2 AND story_id = $3",
|
||||||
|
account_id,
|
||||||
|
peer_id,
|
||||||
|
story_id,
|
||||||
|
kind,
|
||||||
|
)
|
||||||
@@ -48,6 +48,29 @@ async def current_presence(
|
|||||||
return PresenceSample(**dict(row)) if row is not None else None
|
return PresenceSample(**dict(row)) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
_ONLINE = """
|
||||||
|
SELECT peer_id, ts, status, last_online_date, next_offline_date
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (peer_id)
|
||||||
|
peer_id, ts, status, last_online_date, next_offline_date
|
||||||
|
FROM presence
|
||||||
|
WHERE account_id = $1 AND peer_id = ANY($2::bigint[])
|
||||||
|
AND ts > now() - interval '1 hour'
|
||||||
|
ORDER BY peer_id, ts DESC
|
||||||
|
) latest
|
||||||
|
WHERE status = 'online' AND next_offline_date > now()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def online_now(
|
||||||
|
pool: asyncpg.Pool, account_id: int, peer_ids: list[int]
|
||||||
|
) -> list[PresenceSample]:
|
||||||
|
if not peer_ids:
|
||||||
|
return []
|
||||||
|
rows = await pool.fetch(_ONLINE, account_id, peer_ids)
|
||||||
|
return [PresenceSample(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def presence_hourly(
|
async def presence_hourly(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.search.models import Found, FoundWhere
|
||||||
|
|
||||||
|
HIDDEN_KEYS = ("chat", "from_user", "sender_chat")
|
||||||
|
SKIPPED_KEYS = ("_", "file_id", "file_unique_id")
|
||||||
|
ENUM = re.compile(r"^[A-Z][A-Za-z]+\.[A-Z_]+$")
|
||||||
|
|
||||||
|
_HEADS: dict[str, FoundWhere] = {
|
||||||
|
"text": "text",
|
||||||
|
"caption": "text",
|
||||||
|
"web_page": "preview",
|
||||||
|
}
|
||||||
|
_LEAVES: dict[str, FoundWhere] = {"url": "link", "file_name": "file"}
|
||||||
|
|
||||||
|
type Path = tuple[str | int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _scalars(node: object, path: Path = ()) -> Iterator[tuple[Path, str, object]]:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
items = ((k, v) for k, v in node.items() if k not in SKIPPED_KEYS)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
items = enumerate(node)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
for key, value in items:
|
||||||
|
if isinstance(value, dict | list):
|
||||||
|
yield from _scalars(value, (*path, key))
|
||||||
|
elif isinstance(value, str | int | float) and not isinstance(value, bool):
|
||||||
|
yield (*path, key), str(value), node
|
||||||
|
|
||||||
|
|
||||||
|
def _where(path: Path) -> FoundWhere:
|
||||||
|
head, leaf = str(path[0]), str(path[-1])
|
||||||
|
if head == "reply_markup":
|
||||||
|
return "button"
|
||||||
|
if leaf in _LEAVES:
|
||||||
|
return _LEAVES[leaf]
|
||||||
|
if head.startswith("forward"):
|
||||||
|
return "forward"
|
||||||
|
if head.startswith("reply_to"):
|
||||||
|
return "reply"
|
||||||
|
return _HEADS.get(head, "data")
|
||||||
|
|
||||||
|
|
||||||
|
def find(raw: dict[str, Any], needle: str) -> Found | None:
|
||||||
|
folded = needle.casefold()
|
||||||
|
fallback = None
|
||||||
|
for path, value, owner in _scalars(raw):
|
||||||
|
if folded not in value.casefold() or ENUM.match(value):
|
||||||
|
continue
|
||||||
|
where = _where(path)
|
||||||
|
button = owner.get("text") if isinstance(owner, dict) else None
|
||||||
|
found = Found(
|
||||||
|
where=where,
|
||||||
|
path=".".join(map(str, path)),
|
||||||
|
value=value,
|
||||||
|
button=button if where == "button" else None,
|
||||||
|
)
|
||||||
|
if where != "text":
|
||||||
|
return found
|
||||||
|
fallback = fallback or found
|
||||||
|
return fallback
|
||||||
@@ -10,7 +10,10 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|||||||
_MICROSECOND = timedelta(microseconds=1)
|
_MICROSECOND = timedelta(microseconds=1)
|
||||||
|
|
||||||
ChatKind = Literal["private", "group", "channel"]
|
ChatKind = Literal["private", "group", "channel"]
|
||||||
HitSource = Literal["text", "stt", "version"]
|
HitSource = Literal["text", "stt", "version", "data"]
|
||||||
|
FoundWhere = Literal[
|
||||||
|
"text", "button", "link", "forward", "reply", "file", "preview", "data"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
|
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
|
||||||
@@ -34,6 +37,7 @@ class SearchFilters(BaseModel):
|
|||||||
date_from: datetime | None = None
|
date_from: datetime | None = None
|
||||||
date_to: datetime | None = None
|
date_to: datetime | None = None
|
||||||
regex: str | None = None
|
regex: str | None = None
|
||||||
|
everywhere: bool = False
|
||||||
cursor: str | None = None
|
cursor: str | None = None
|
||||||
limit: int = DEFAULT_LIMIT
|
limit: int = DEFAULT_LIMIT
|
||||||
|
|
||||||
@@ -45,6 +49,13 @@ class SearchFilters(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Found(BaseModel):
|
||||||
|
where: FoundWhere
|
||||||
|
path: str
|
||||||
|
value: str
|
||||||
|
button: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SearchHit(BaseModel):
|
class SearchHit(BaseModel):
|
||||||
chat_id: int
|
chat_id: int
|
||||||
message_id: int
|
message_id: int
|
||||||
@@ -56,12 +67,14 @@ class SearchHit(BaseModel):
|
|||||||
edited_at: datetime | None
|
edited_at: datetime | None
|
||||||
deleted_at: datetime | None
|
deleted_at: datetime | None
|
||||||
source: HitSource
|
source: HitSource
|
||||||
extracted_text: str | None
|
extracted_text: str | None = None
|
||||||
version_text: str | None
|
version_text: str | None = None
|
||||||
version_at: datetime | None
|
version_at: datetime | None = None
|
||||||
|
found: Found | None = None
|
||||||
|
|
||||||
|
|
||||||
class SearchPage(BaseModel):
|
class SearchPage(BaseModel):
|
||||||
hits: list[SearchHit]
|
hits: list[SearchHit]
|
||||||
next_cursor: str | None
|
next_cursor: str | None
|
||||||
terms: list[str]
|
terms: list[str]
|
||||||
|
scanned_to: datetime | None = None
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.read.message_view import load_raw
|
||||||
|
from utils.search.found import ENUM, HIDDEN_KEYS, SKIPPED_KEYS, find
|
||||||
from utils.search.models import (
|
from utils.search.models import (
|
||||||
MAX_LIMIT,
|
MAX_LIMIT,
|
||||||
ChatKind,
|
ChatKind,
|
||||||
@@ -10,6 +17,12 @@ from utils.search.models import (
|
|||||||
encode_cursor,
|
encode_cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MIN_NEEDLE = 3
|
||||||
|
_WINDOW = timedelta(days=7)
|
||||||
|
_BUDGET_SECONDS = 2.5
|
||||||
|
_BOTTOM = -(2**63)
|
||||||
|
_LIKE_SPECIAL = re.compile(r"([\\%_])")
|
||||||
|
|
||||||
_PARSE = """
|
_PARSE = """
|
||||||
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
|
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
|
||||||
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|
||||||
@@ -76,6 +89,24 @@ _WITH_MEDIA = (
|
|||||||
|
|
||||||
_SOURCES = ("text", "stt", "version")
|
_SOURCES = ("text", "stt", "version")
|
||||||
|
|
||||||
|
_OLDEST = "SELECT min(date) FROM message_search WHERE account_id = $1"
|
||||||
|
|
||||||
|
_VALUE_MATCH = (
|
||||||
|
'strict $.** ? (@.type() == "object").keyvalue() ? ('
|
||||||
|
+ "".join(f'@.key != "{key}" && ' for key in SKIPPED_KEYS)
|
||||||
|
+ '(@.value.type() == "string" || @.value.type() == "number") && '
|
||||||
|
f"!(@.value.string() like_regex {json.dumps(ENUM.pattern)}) && "
|
||||||
|
'@.value.string() like_regex {needle} flag "iq")'
|
||||||
|
)
|
||||||
|
|
||||||
|
_EVERYWHERE = (
|
||||||
|
f"SELECT {_COLS}, s.raw - {{hidden}}::text[] AS raw " # noqa: S608
|
||||||
|
"FROM messages s WHERE {where} "
|
||||||
|
"AND CASE WHEN s.raw::text ILIKE {like} "
|
||||||
|
"THEN jsonb_path_exists(s.raw - {hidden}::text[], {path}::jsonpath) "
|
||||||
|
f"ELSE false END ORDER BY {_KEY_ORDER} LIMIT {{limit}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Binder:
|
class _Binder:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -130,9 +161,72 @@ def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
|
|||||||
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
|
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
await conn.execute("SET LOCAL jit = off")
|
await conn.execute("SET LOCAL jit = off")
|
||||||
|
if filters.everywhere:
|
||||||
|
return await _everywhere(conn, filters)
|
||||||
return await _search(conn, filters)
|
return await _search(conn, filters)
|
||||||
|
|
||||||
|
|
||||||
|
async def _channels(conn: asyncpg.Connection, filters: SearchFilters) -> list[int]:
|
||||||
|
if filters.kind in {"group", "channel"}:
|
||||||
|
return await conn.fetchval(_CHANNELS, filters.account_id)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _data_hit(row: asyncpg.Record, needle: str) -> SearchHit:
|
||||||
|
data = dict(row)
|
||||||
|
raw = load_raw(data.pop("raw"))
|
||||||
|
return SearchHit(**data, source="data", found=find(raw, needle))
|
||||||
|
|
||||||
|
|
||||||
|
async def _everywhere(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
|
||||||
|
needle = (filters.query or "").strip()
|
||||||
|
floor = filters.date_from or await conn.fetchval(_OLDEST, filters.account_id)
|
||||||
|
if len(needle) < MIN_NEEDLE or floor is None:
|
||||||
|
return SearchPage(hits=[], next_cursor=None, terms=[])
|
||||||
|
channels = await _channels(conn, filters)
|
||||||
|
quoted = json.dumps(needle, ensure_ascii=False)
|
||||||
|
like = _LIKE_SPECIAL.sub(r"\\\1", quoted[1:-1])
|
||||||
|
path = _VALUE_MATCH.format(needle=quoted)
|
||||||
|
size = min(filters.limit, MAX_LIMIT)
|
||||||
|
cursor = filters.cursor
|
||||||
|
top = decode_cursor(cursor)[0] if cursor else None
|
||||||
|
hits: list[SearchHit] = []
|
||||||
|
started = monotonic()
|
||||||
|
while True:
|
||||||
|
lower = max((top or filters.date_to or datetime.now(UTC)) - _WINDOW, floor)
|
||||||
|
bind = _Binder()
|
||||||
|
conds = _conditions(
|
||||||
|
filters.model_copy(update={"cursor": cursor}), channels, bind
|
||||||
|
)
|
||||||
|
conds.append(f"s.date >= {bind(lower)}")
|
||||||
|
if top is not None:
|
||||||
|
conds.append(f"s.date <= {bind(top)}")
|
||||||
|
limit = size - len(hits)
|
||||||
|
sql = _EVERYWHERE.format(
|
||||||
|
where=" AND ".join(conds),
|
||||||
|
hidden=bind(list(HIDDEN_KEYS)),
|
||||||
|
like=bind(f"%{like}%"),
|
||||||
|
path=bind(path),
|
||||||
|
limit=bind(limit),
|
||||||
|
)
|
||||||
|
account = bind(filters.account_id)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
_WITH_MEDIA.format(hits=sql, account=account), *bind.params
|
||||||
|
)
|
||||||
|
hits += [_data_hit(row, needle) for row in rows]
|
||||||
|
if len(rows) >= limit:
|
||||||
|
last = rows[-1]
|
||||||
|
top = last["date"]
|
||||||
|
cursor = encode_cursor(top, last["chat_id"], last["message_id"])
|
||||||
|
elif lower > floor:
|
||||||
|
top = lower
|
||||||
|
cursor = encode_cursor(lower, _BOTTOM, _BOTTOM)
|
||||||
|
else:
|
||||||
|
return SearchPage(hits=hits, next_cursor=None, terms=[], scanned_to=floor)
|
||||||
|
if len(hits) >= size or monotonic() - started > _BUDGET_SECONDS:
|
||||||
|
return SearchPage(hits=hits, next_cursor=cursor, terms=[], scanned_to=top)
|
||||||
|
|
||||||
|
|
||||||
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
|
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
|
||||||
query = (filters.query or "").strip()
|
query = (filters.query or "").strip()
|
||||||
terms: list[str] = []
|
terms: list[str] = []
|
||||||
@@ -144,9 +238,7 @@ async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPag
|
|||||||
return SearchPage(hits=[], next_cursor=None, terms=terms)
|
return SearchPage(hits=[], next_cursor=None, terms=terms)
|
||||||
tsquery = parsed["tsquery"]
|
tsquery = parsed["tsquery"]
|
||||||
|
|
||||||
channels: list[int] = []
|
channels = await _channels(conn, filters)
|
||||||
if filters.kind in {"group", "channel"}:
|
|
||||||
channels = await conn.fetchval(_CHANNELS, filters.account_id)
|
|
||||||
bind = _Binder()
|
bind = _Binder()
|
||||||
where = " AND ".join(_conditions(filters, channels, bind))
|
where = " AND ".join(_conditions(filters, channels, bind))
|
||||||
size = min(filters.limit, MAX_LIMIT)
|
size = min(filters.limit, MAX_LIMIT)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class ContentAddressedStorage:
|
|||||||
def get(self, key: str) -> bytes:
|
def get(self, key: str) -> bytes:
|
||||||
return self._path(key).read_bytes()
|
return self._path(key).read_bytes()
|
||||||
|
|
||||||
|
def head(self, key: str, size: int = 16) -> bytes:
|
||||||
|
with self._path(key).open("rb") as file:
|
||||||
|
return file.read(size)
|
||||||
|
|
||||||
def exists(self, key: str) -> bool:
|
def exists(self, key: str) -> bool:
|
||||||
return self._path(key).exists()
|
return self._path(key).exists()
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ class BotRegistry:
|
|||||||
def running(self) -> list[int]:
|
def running(self) -> list[int]:
|
||||||
return list(self._running)
|
return list(self._running)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected_accounts(self) -> list[int]:
|
||||||
|
return [
|
||||||
|
bot.account_id for bot in self._running.values() if bot.client.is_connected
|
||||||
|
]
|
||||||
|
|
||||||
async def sync(self) -> None:
|
async def sync(self) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
await self._adopt_dropped_in()
|
await self._adopt_dropped_in()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asyncpg
|
|||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
|
from userbot.modules.uptime import repository as uptime
|
||||||
from utils.listen import Listener
|
from utils.listen import Listener
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||||
@@ -16,6 +17,7 @@ from watcher.registry import BotRegistry
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
RETRY_INTERVAL = 300
|
RETRY_INTERVAL = 300
|
||||||
|
UPTIME_INTERVAL = 60
|
||||||
|
|
||||||
|
|
||||||
async def _retry_loop(registry: BotRegistry) -> None:
|
async def _retry_loop(registry: BotRegistry) -> None:
|
||||||
@@ -27,6 +29,14 @@ async def _retry_loop(registry: BotRegistry) -> None:
|
|||||||
logger.exception("[red]Bot sync failed[/]")
|
logger.exception("[red]Bot sync failed[/]")
|
||||||
|
|
||||||
|
|
||||||
|
async def _keep_uptime(registry: BotRegistry, pool: asyncpg.Pool) -> None:
|
||||||
|
while True:
|
||||||
|
for account_id in registry.connected_accounts:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await uptime.heartbeat(pool, account_id)
|
||||||
|
await asyncio.sleep(UPTIME_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
|
def _change_listener(registry: BotRegistry, tasks: set[asyncio.Task]) -> Listener:
|
||||||
def on_change(_payload: str) -> None:
|
def on_change(_payload: str) -> None:
|
||||||
task = asyncio.create_task(registry.sync())
|
task = asyncio.create_task(registry.sync())
|
||||||
@@ -58,6 +68,7 @@ async def runner() -> None:
|
|||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||||
retry = asyncio.create_task(_retry_loop(registry))
|
retry = asyncio.create_task(_retry_loop(registry))
|
||||||
|
beat = asyncio.create_task(_keep_uptime(registry, pool))
|
||||||
try:
|
try:
|
||||||
await registry.sync()
|
await registry.sync()
|
||||||
if not registry.running:
|
if not registry.running:
|
||||||
@@ -66,9 +77,10 @@ async def runner() -> None:
|
|||||||
logger.info("[green]Bot watcher running.[/]")
|
logger.info("[green]Bot watcher running.[/]")
|
||||||
await stop.wait()
|
await stop.wait()
|
||||||
finally:
|
finally:
|
||||||
retry.cancel()
|
for task in (retry, beat):
|
||||||
with contextlib.suppress(asyncio.CancelledError):
|
task.cancel()
|
||||||
await retry
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
await listener.stop()
|
await listener.stop()
|
||||||
await registry.close()
|
await registry.close()
|
||||||
await container.close()
|
await container.close()
|
||||||
|
|||||||
Vendored
+6
-5
@@ -1,13 +1,14 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
import type { PanelState } from "#lib/stores/ui.svelte.js";
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
interface PageState {
|
||||||
|
inApp?: boolean;
|
||||||
|
panel?: PanelState;
|
||||||
|
}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { request } from "#lib/api/client.js";
|
||||||
|
import type { PresenceSample } from "#lib/api/types.js";
|
||||||
|
|
||||||
|
export interface CaptureStatus {
|
||||||
|
last_seen: string | null;
|
||||||
|
live: boolean;
|
||||||
|
since: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureGap {
|
||||||
|
end: string;
|
||||||
|
start: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaptureStatus(accountId: number): Promise<CaptureStatus> {
|
||||||
|
return request<CaptureStatus>("/capture/status", {
|
||||||
|
query: { account_id: accountId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCaptureGaps(accountId: number): Promise<CaptureGap[]> {
|
||||||
|
return request<CaptureGap[]>("/capture/gaps", {
|
||||||
|
query: { account_id: accountId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOnline(peerIds: number[]): Promise<PresenceSample[]> {
|
||||||
|
return request<PresenceSample[]>("/presence/online", {
|
||||||
|
account: true,
|
||||||
|
query: { peer_ids: peerIds.join(",") },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { request } from "#lib/api/client.js";
|
||||||
|
import type { Annotation } from "#lib/api/types.js";
|
||||||
|
|
||||||
|
export interface Note extends Annotation {
|
||||||
|
chat_has_avatar: boolean;
|
||||||
|
chat_title: string | null;
|
||||||
|
message_date: string | null;
|
||||||
|
message_deleted: boolean;
|
||||||
|
message_has_media: boolean;
|
||||||
|
message_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteTag {
|
||||||
|
count: number;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNotes(
|
||||||
|
tag: string | null,
|
||||||
|
offset: number,
|
||||||
|
limit: number
|
||||||
|
): Promise<Note[]> {
|
||||||
|
return request<Note[]>("/annotations/notes", {
|
||||||
|
account: true,
|
||||||
|
query: { limit, offset, tag },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNoteTags(): Promise<NoteTag[]> {
|
||||||
|
return request<NoteTag[]>("/annotations/tags", { account: true });
|
||||||
|
}
|
||||||
@@ -7,7 +7,24 @@ export type PolicyScopeType =
|
|||||||
| "default_channel"
|
| "default_channel"
|
||||||
| "folder"
|
| "folder"
|
||||||
| "chat";
|
| "chat";
|
||||||
export type SearchSource = "text" | "stt" | "version";
|
export type SearchSource = "text" | "stt" | "version" | "data";
|
||||||
|
|
||||||
|
export type FoundWhere =
|
||||||
|
| "text"
|
||||||
|
| "button"
|
||||||
|
| "link"
|
||||||
|
| "forward"
|
||||||
|
| "reply"
|
||||||
|
| "file"
|
||||||
|
| "preview"
|
||||||
|
| "data";
|
||||||
|
|
||||||
|
export interface SearchFound {
|
||||||
|
button: string | null;
|
||||||
|
path: string;
|
||||||
|
value: string;
|
||||||
|
where: FoundWhere;
|
||||||
|
}
|
||||||
|
|
||||||
export type SearchChatKind = "private" | "group" | "channel";
|
export type SearchChatKind = "private" | "group" | "channel";
|
||||||
export type JobStatus =
|
export type JobStatus =
|
||||||
@@ -45,6 +62,8 @@ export interface Chat {
|
|||||||
is_contact: boolean;
|
is_contact: boolean;
|
||||||
kind: ChatKind;
|
kind: ChatKind;
|
||||||
last_date: string | null;
|
last_date: string | null;
|
||||||
|
last_deleted: boolean;
|
||||||
|
last_message_id: number | null;
|
||||||
last_sender_id: number | null;
|
last_sender_id: number | null;
|
||||||
last_text: string | null;
|
last_text: string | null;
|
||||||
message_count: number;
|
message_count: number;
|
||||||
@@ -196,6 +215,7 @@ export interface MessageView {
|
|||||||
entities: EntityView[];
|
entities: EntityView[];
|
||||||
forward: ForwardView | null;
|
forward: ForwardView | null;
|
||||||
has_media: boolean;
|
has_media: boolean;
|
||||||
|
has_note: boolean;
|
||||||
inline_buttons: InlineButton[][];
|
inline_buttons: InlineButton[][];
|
||||||
is_animated_emoji: boolean;
|
is_animated_emoji: boolean;
|
||||||
is_self_destruct: boolean;
|
is_self_destruct: boolean;
|
||||||
@@ -286,6 +306,7 @@ export interface SearchHit {
|
|||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
edited_at: string | null;
|
edited_at: string | null;
|
||||||
extracted_text: string | null;
|
extracted_text: string | null;
|
||||||
|
found: SearchFound | null;
|
||||||
has_media: boolean;
|
has_media: boolean;
|
||||||
media_kind: string | null;
|
media_kind: string | null;
|
||||||
message_id: number;
|
message_id: number;
|
||||||
@@ -299,6 +320,7 @@ export interface SearchHit {
|
|||||||
export interface SearchPage {
|
export interface SearchPage {
|
||||||
hits: SearchHit[];
|
hits: SearchHit[];
|
||||||
next_cursor: string | null;
|
next_cursor: string | null;
|
||||||
|
scanned_to: string | null;
|
||||||
terms: string[];
|
terms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +331,7 @@ export interface SearchQuery {
|
|||||||
date_to?: string;
|
date_to?: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
everywhere?: boolean;
|
||||||
has_media?: boolean;
|
has_media?: boolean;
|
||||||
kind?: SearchChatKind;
|
kind?: SearchChatKind;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { MessageView } from "#lib/api/types.js";
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { accountName, peerName } from "#lib/format/peer.js";
|
import { accountName, peerName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
|
|
||||||
@@ -16,10 +17,12 @@
|
|||||||
|
|
||||||
function nameOf(id: number | null): string {
|
function nameOf(id: number | null): string {
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
return "Someone";
|
return t("service.someone");
|
||||||
}
|
}
|
||||||
if (id === ownId) {
|
if (id === ownId) {
|
||||||
return accounts.selected ? accountName(accounts.selected) : "You";
|
return accounts.selected
|
||||||
|
? accountName(accounts.selected)
|
||||||
|
: t("service.you");
|
||||||
}
|
}
|
||||||
const peer = peers.get(id);
|
const peer = peers.get(id);
|
||||||
return peer ? peerName(peer) : String(id);
|
return peer ? peerName(peer) : String(id);
|
||||||
@@ -44,35 +47,39 @@
|
|||||||
const selfJoin =
|
const selfJoin =
|
||||||
members.length === 1 && members[0] === message.sender_id;
|
members.length === 1 && members[0] === message.sender_id;
|
||||||
if (selfJoin) {
|
if (selfJoin) {
|
||||||
return `${actor} joined the group`;
|
return t("service.joined", { actor });
|
||||||
}
|
}
|
||||||
const names = members.map(nameOf).join(", ");
|
const names = members.map(nameOf).join(", ");
|
||||||
return names ? `${actor} added ${names}` : `${actor} joined the group`;
|
return names
|
||||||
|
? t("service.added", { actor, names })
|
||||||
|
: t("service.joined", { actor });
|
||||||
}
|
}
|
||||||
case "left_chat_members":
|
case "left_chat_members":
|
||||||
return `${actor} left the group`;
|
return t("service.left", { actor });
|
||||||
case "pinned_message":
|
case "pinned_message":
|
||||||
return `${actor} pinned a message`;
|
return t("service.pinned", { actor });
|
||||||
case "group_chat_created":
|
case "group_chat_created":
|
||||||
return `${actor} created the group`;
|
return t("service.groupCreated", { actor });
|
||||||
case "channel_chat_created":
|
case "channel_chat_created":
|
||||||
return "Channel created";
|
return t("service.channelCreated");
|
||||||
case "phone_call_ended":
|
case "phone_call_ended":
|
||||||
return service.duration === null
|
return service.duration === null
|
||||||
? "Call ended"
|
? t("service.callEnded")
|
||||||
: `Call ended · ${durationOf(service.duration)}`;
|
: t("service.callEndedAfter", {
|
||||||
|
duration: durationOf(service.duration),
|
||||||
|
});
|
||||||
case "phone_call_started":
|
case "phone_call_started":
|
||||||
return "Call started";
|
return t("service.callStarted");
|
||||||
case "poll_option_added":
|
case "poll_option_added":
|
||||||
return `${actor} added a poll option`;
|
return t("service.pollOption", { actor });
|
||||||
case "new_chat_title":
|
case "new_chat_title":
|
||||||
return `${actor} changed the group name`;
|
return t("service.titleChanged", { actor });
|
||||||
case "new_chat_photo":
|
case "new_chat_photo":
|
||||||
return `${actor} changed the group photo`;
|
return t("service.photoChanged", { actor });
|
||||||
case "delete_chat_photo":
|
case "delete_chat_photo":
|
||||||
return `${actor} removed the group photo`;
|
return t("service.photoRemoved", { actor });
|
||||||
default:
|
default:
|
||||||
return service.kind.replace(/_/g, " ") || "Service message";
|
return t("service.unknown");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover } from "bits-ui";
|
||||||
|
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchor: HTMLElement | null;
|
||||||
|
chatId: number;
|
||||||
|
month: { month: number; year: number } | null;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { anchor, chatId, month, open = $bindable() }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
aria-label={t("chat.calendar")}
|
||||||
|
class="bg-menu-content calendar-popover"
|
||||||
|
customAnchor={anchor}
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
{#key month}
|
||||||
|
<ChatCalendar
|
||||||
|
{chatId}
|
||||||
|
initial={month}
|
||||||
|
onjump={() => {
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.calendar-popover) {
|
||||||
|
width: min(20rem, calc(100vw - 1.5rem));
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,8 +29,11 @@
|
|||||||
const isDm = $derived(chatId > 0);
|
const isDm = $derived(chatId > 0);
|
||||||
const chat = $derived(chats.byId(chatId));
|
const chat = $derived(chats.byId(chatId));
|
||||||
const discovered = $derived(discover.get(chatId));
|
const discovered = $derived(discover.get(chatId));
|
||||||
|
const DONE_DURATION = 2500;
|
||||||
|
|
||||||
let peer = $state<PeerView | null>(null);
|
let peer = $state<PeerView | null>(null);
|
||||||
let backfilling = $state(false);
|
let backfilling = $state(false);
|
||||||
|
let backfilled = $state(false);
|
||||||
|
|
||||||
async function backfill() {
|
async function backfill() {
|
||||||
if (backfilling) {
|
if (backfilling) {
|
||||||
@@ -39,7 +42,10 @@
|
|||||||
backfilling = true;
|
backfilling = true;
|
||||||
try {
|
try {
|
||||||
await enqueueBackfill(chatId, true);
|
await enqueueBackfill(chatId, true);
|
||||||
toasts.success(t("chat.backfill.started"));
|
backfilled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
backfilled = false;
|
||||||
|
}, DONE_DURATION);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error(t("chat.backfill.failed"));
|
toasts.error(t("chat.backfill.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,6 +54,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function back() {
|
function back() {
|
||||||
|
if (page.state.inApp) {
|
||||||
|
history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = new URL(page.url.href);
|
const target = new URL(page.url.href);
|
||||||
target.pathname = "/app";
|
target.pathname = "/app";
|
||||||
goto(withoutMessage(target));
|
goto(withoutMessage(target));
|
||||||
@@ -175,6 +185,16 @@
|
|||||||
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
|
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
|
||||||
>{t("feed.changes")}</ContextMenuItem
|
>{t("feed.changes")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
|
<ContextMenuItem icon="stats" onselect={() => ui.openPanel("presence")}
|
||||||
|
>{t("chat.analytics")}</ContextMenuItem
|
||||||
|
>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={backfilled ? "check" : "cloud-download"}
|
||||||
|
onselect={backfill}
|
||||||
|
>{backfilled
|
||||||
|
? t("chat.backfill.started")
|
||||||
|
: t("chat.backfill")}</ContextMenuItem
|
||||||
|
>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="play-story"
|
icon="play-story"
|
||||||
onselect={() => ui.openPanel("stories")}
|
onselect={() => ui.openPanel("stories")}
|
||||||
@@ -199,6 +219,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("feed.changes")}
|
aria-label={t("feed.changes")}
|
||||||
|
class="wide-only"
|
||||||
onclick={() => ui.openPanel("changes")}
|
onclick={() => ui.openPanel("changes")}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -216,14 +237,16 @@
|
|||||||
<Icon name="stats" />
|
<Icon name="stats" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("chat.backfill")}
|
aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
|
||||||
|
class="wide-only"
|
||||||
loading={backfilling}
|
loading={backfilling}
|
||||||
onclick={backfill}
|
onclick={backfill}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
|
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
|
||||||
variant="translucent"
|
variant="translucent"
|
||||||
>
|
>
|
||||||
<Icon name="cloud-download" />
|
<Icon name={backfilled ? "check" : "cloud-download"} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -275,6 +298,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:global(.wide-only) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
@@ -287,8 +316,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&.online {
|
&.online {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
|||||||
@@ -76,6 +76,27 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = activeChatId;
|
||||||
|
untrack(() => {
|
||||||
|
const index = list.findIndex((chat) => chat.chat_id === id);
|
||||||
|
if (index === -1 || viewport === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = viewport.querySelector<HTMLElement>(".Chat.selected");
|
||||||
|
if (row) {
|
||||||
|
row.scrollIntoView({ block: "nearest" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const top = index * rowHeight;
|
||||||
|
if (top < viewport.scrollTop) {
|
||||||
|
viewport.scrollTop = top;
|
||||||
|
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
|
||||||
|
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function measure(el: HTMLElement) {
|
function measure(el: HTMLElement) {
|
||||||
({ scrollTop } = el);
|
({ scrollTop } = el);
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { online } from "#lib/stores/online.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -31,6 +33,14 @@
|
|||||||
? t("chat.deletedAccount")
|
? t("chat.deletedAccount")
|
||||||
: t("chat.fallbackTitle", { id: chat.chat_id }))
|
: t("chat.fallbackTitle", { id: chat.chat_id }))
|
||||||
);
|
);
|
||||||
|
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
|
||||||
|
const isOnline = $derived(person && online.isOnline(chat.chat_id));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (person) {
|
||||||
|
online.watch(chat.chat_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
const showSender = $derived(
|
const showSender = $derived(
|
||||||
@@ -74,12 +84,14 @@
|
|||||||
class:selected
|
class:selected
|
||||||
use:ripple
|
use:ripple
|
||||||
>
|
>
|
||||||
<Avatar
|
<span class="avatar" class:online={isOnline}>
|
||||||
avatar={{ id: chat.chat_id, kind: avatarKind }}
|
<Avatar
|
||||||
colorKey={chat.chat_id}
|
avatar={{ id: chat.chat_id, kind: avatarKind }}
|
||||||
hasAvatar={chat.has_avatar}
|
colorKey={chat.chat_id}
|
||||||
name={title}
|
hasAvatar={chat.has_avatar}
|
||||||
/>
|
name={title}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<h3 class="title">{title}</h3>
|
<h3 class="title">{title}</h3>
|
||||||
@@ -90,7 +102,13 @@
|
|||||||
{#if senderPrefix}
|
{#if senderPrefix}
|
||||||
<span class="sender">{senderPrefix}</span>
|
<span class="sender">{senderPrefix}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{preview}
|
{#if chat.last_deleted}
|
||||||
|
<span class="deleted-mark" title={t("chat.deletedPreview")}
|
||||||
|
><Icon name="delete" size="1rem" /></span
|
||||||
|
><s class="deleted-text">{preview}</s>
|
||||||
|
{:else}
|
||||||
|
{preview}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,11 +165,15 @@
|
|||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
|
--online-ring: var(--color-chat-hover);
|
||||||
|
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
|
--online-ring: var(--color-chat-active);
|
||||||
|
|
||||||
background-color: var(--color-chat-active);
|
background-color: var(--color-chat-active);
|
||||||
|
|
||||||
.title,
|
.title,
|
||||||
@@ -163,6 +185,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.online::after {
|
||||||
|
content: "";
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 0.0625rem;
|
||||||
|
bottom: 0.0625rem;
|
||||||
|
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
border: 2px solid var(--online-ring, var(--color-background));
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background-color: var(--color-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-inline-end: 0.1875rem;
|
||||||
|
vertical-align: -0.1875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -8,14 +8,16 @@
|
|||||||
jumboEmojiCount,
|
jumboEmojiCount,
|
||||||
linkHref,
|
linkHref,
|
||||||
} from "#lib/format/entities.js";
|
} from "#lib/format/entities.js";
|
||||||
|
import { markText } from "#lib/format/search.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entities: EntityView[];
|
entities: EntityView[];
|
||||||
|
marks?: string[];
|
||||||
own?: boolean;
|
own?: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { text, entities, own = false }: Props = $props();
|
let { text, entities, marks = [], own = false }: Props = $props();
|
||||||
|
|
||||||
const nodes = $derived(buildEntityTree(text, entities));
|
const nodes = $derived(buildEntityTree(text, entities));
|
||||||
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
|
const jumbo = $derived(entities.length === 0 ? jumboEmojiCount(text) : 0);
|
||||||
@@ -32,13 +34,29 @@
|
|||||||
)}
|
)}
|
||||||
{#each items as node, i (i)}
|
{#each items as node, i (i)}
|
||||||
{#if node.kind === "text"}
|
{#if node.kind === "text"}
|
||||||
{node.text}
|
{@render plain(node.text)}
|
||||||
{:else}
|
{:else}
|
||||||
{@render entity(node)}
|
{@render entity(node)}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet plain(
|
||||||
|
value: string
|
||||||
|
)}
|
||||||
|
{#if marks.length === 0}
|
||||||
|
{value}
|
||||||
|
{:else}
|
||||||
|
{#each markText(value, marks) as segment, i (i)}
|
||||||
|
{#if segment.hit}
|
||||||
|
<mark class="found" class:own>{segment.text}</mark>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet entity(
|
{#snippet entity(
|
||||||
node: EntityNode
|
node: EntityNode
|
||||||
)}
|
)}
|
||||||
@@ -186,6 +204,35 @@
|
|||||||
background-color: var(--color-primary-tint);
|
background-color: var(--color-primary-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
animation: found-flash 3s ease;
|
||||||
|
|
||||||
|
--found-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||||
|
|
||||||
|
&.own {
|
||||||
|
--found-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-own-links) 35%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes found-flash {
|
||||||
|
0%,
|
||||||
|
70% {
|
||||||
|
background-color: var(--found-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.spoiler {
|
.spoiler {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { folders } from "#lib/stores/folders.svelte.js";
|
import { folders } from "#lib/stores/folders.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = $derived<Tab[]>([
|
const tabs = $derived<Tab[]>([
|
||||||
{ id: null, title: "All Chats" },
|
{ id: null, title: t("folders.all") },
|
||||||
...folders.list.map((folder) => ({
|
...folders.list.map((folder) => ({
|
||||||
id: folder.folder_id,
|
id: folder.folder_id,
|
||||||
title: folder.title,
|
title: folder.title,
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
|
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
|
||||||
Политика захвата
|
{t("settings.policy")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ForwardView } from "#lib/api/types.js";
|
import type { ForwardView } from "#lib/api/types.js";
|
||||||
import { peerName } from "#lib/format/peer.js";
|
import { peerName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,20 +15,20 @@
|
|||||||
);
|
);
|
||||||
const name = $derived.by(() => {
|
const name = $derived.by(() => {
|
||||||
if (forward.kind === "channel") {
|
if (forward.kind === "channel") {
|
||||||
return forward.chat_title ?? "Channel";
|
return forward.chat_title ?? t("forward.channel");
|
||||||
}
|
}
|
||||||
if (forward.kind === "hidden") {
|
if (forward.kind === "hidden") {
|
||||||
return forward.from_name ?? "Hidden account";
|
return forward.from_name ?? t("forward.hidden");
|
||||||
}
|
}
|
||||||
if (peer) {
|
if (peer) {
|
||||||
return peerName(peer);
|
return peerName(peer);
|
||||||
}
|
}
|
||||||
return forward.from_name ?? "Unknown";
|
return forward.from_name ?? t("forward.unknown");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ForwardHeader">
|
<div class="ForwardHeader">
|
||||||
<span class="label">Forwarded from</span>
|
<span class="label">{t("forward.from")}</span>
|
||||||
<span class="name">{name}</span>
|
<span class="name">{name}</span>
|
||||||
{#if forward.signature}
|
{#if forward.signature}
|
||||||
<span class="signature">({forward.signature})</span>
|
<span class="signature">({forward.signature})</span>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const EDITABLE = "input, textarea, select, [contenteditable='true']";
|
||||||
|
const LAYERS = "[role='dialog'], [role='alertdialog'], [role='menu']";
|
||||||
|
|
||||||
|
const chatId = $derived(
|
||||||
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function focusIn(selector: string) {
|
||||||
|
await tick();
|
||||||
|
document.querySelector<HTMLInputElement>(selector)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusSearch() {
|
||||||
|
if (ui.leftView === "settings") {
|
||||||
|
ui.closeSettings();
|
||||||
|
}
|
||||||
|
await focusIn("#LeftColumn .left-header input");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepChat(step: number) {
|
||||||
|
const { list } = chats;
|
||||||
|
const index = list.findIndex((chat) => chat.chat_id === chatId);
|
||||||
|
if (index === -1 && step < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = index === -1 ? list[0] : list[index + step];
|
||||||
|
if (target) {
|
||||||
|
goto(`/app/${target.chat_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTop(editable: HTMLElement | null) {
|
||||||
|
if (editable) {
|
||||||
|
if (editable.closest("#LeftColumn .left-header")) {
|
||||||
|
search.close();
|
||||||
|
}
|
||||||
|
editable.blur();
|
||||||
|
} else if (ui.rightPanel !== null) {
|
||||||
|
ui.closePanel();
|
||||||
|
} else if (ui.leftView === "settings") {
|
||||||
|
ui.closeSettings();
|
||||||
|
} else if (chatId !== null) {
|
||||||
|
goto("/app");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCommand(event: KeyboardEvent): boolean {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "k") {
|
||||||
|
focusSearch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === "f" && chatId !== null) {
|
||||||
|
ui.openPanel("search");
|
||||||
|
focusIn("#RightColumn input");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPlain(event: KeyboardEvent): boolean {
|
||||||
|
if (event.key === "/" && !event.altKey) {
|
||||||
|
focusSearch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.altKey &&
|
||||||
|
(event.key === "ArrowUp" || event.key === "ArrowDown")
|
||||||
|
) {
|
||||||
|
stepChat(event.key === "ArrowUp" ? -1 : 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.defaultPrevented || document.querySelector(LAYERS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const editable = target?.closest<HTMLElement>(EDITABLE) ?? null;
|
||||||
|
const modified = event.ctrlKey || event.metaKey;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeTop(editable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handled = modified
|
||||||
|
? !event.altKey && onCommand(event)
|
||||||
|
: editable === null && onPlain(event);
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window {onkeydown} />
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { InlineButton } from "#lib/api/types.js";
|
import type { InlineButton } from "#lib/api/types.js";
|
||||||
|
import { buttonHasMark } from "#lib/format/search.js";
|
||||||
import { safeHref } from "#lib/format/url.js";
|
import { safeHref } from "#lib/format/url.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
marks?: string[];
|
||||||
rows: InlineButton[][];
|
rows: InlineButton[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { rows }: Props = $props();
|
let { marks = [], rows }: Props = $props();
|
||||||
|
|
||||||
let copied = $state<string | null>(null);
|
let copied = $state<string | null>(null);
|
||||||
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -26,12 +29,14 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
{#each row as button, colIndex (colIndex)}
|
{#each row as button, colIndex (colIndex)}
|
||||||
{@const key = `${rowIndex}:${colIndex}`}
|
{@const key = `${rowIndex}:${colIndex}`}
|
||||||
|
{@const lit = marks.length > 0 && buttonHasMark(button, marks)}
|
||||||
{#if button.kind === "url" && button.url}
|
{#if button.kind === "url" && button.url}
|
||||||
<a
|
<a
|
||||||
class="button"
|
class="button"
|
||||||
href={safeHref(button.url)}
|
href={safeHref(button.url)}
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
class:found={lit}
|
||||||
>
|
>
|
||||||
<span class="label">{button.text}</span>
|
<span class="label">{button.text}</span>
|
||||||
<span class="corner">↗</span>
|
<span class="corner">↗</span>
|
||||||
@@ -41,13 +46,14 @@
|
|||||||
class="button"
|
class="button"
|
||||||
onclick={() => copy(key, button.data ?? "")}
|
onclick={() => copy(key, button.data ?? "")}
|
||||||
type="button"
|
type="button"
|
||||||
|
class:found={lit}
|
||||||
>
|
>
|
||||||
<span class="label"
|
<span class="label"
|
||||||
>{copied === key ? "Скопировано" : button.text}</span
|
>{copied === key ? t("inline.copied") : button.text}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="button static">
|
<span class="button static" class:found={lit}>
|
||||||
<span class="label">{button.text}</span>
|
<span class="label">{button.text}</span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -103,6 +109,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
animation: found-flash 3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes found-flash {
|
||||||
|
0%,
|
||||||
|
70% {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 30%,
|
||||||
|
var(--color-message-reaction)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-color: var(--color-message-reaction);
|
||||||
|
box-shadow: inset 0 0 0 1px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { auth } from "#lib/stores/auth.svelte.js";
|
import { auth } from "#lib/stores/auth.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
await accounts.load();
|
await accounts.load();
|
||||||
await goto("/app");
|
await goto("/app");
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Invalid token");
|
toasts.error(t("login.invalid"));
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -33,16 +34,17 @@
|
|||||||
<Icon name="lock" size="2.75rem" />
|
<Icon name="lock" size="2.75rem" />
|
||||||
</div>
|
</div>
|
||||||
<h1>Beavergram</h1>
|
<h1>Beavergram</h1>
|
||||||
<p class="subtitle">Enter your access token to continue.</p>
|
<p class="subtitle">{t("login.subtitle")}</p>
|
||||||
<input
|
<input
|
||||||
|
aria-label={t("login.token")}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Access token"
|
placeholder={t("login.token")}
|
||||||
type="password"
|
type="password"
|
||||||
bind:value
|
bind:value
|
||||||
>
|
>
|
||||||
<Button disabled={busy || !value.trim()} loading={busy} type="submit">
|
<Button disabled={busy || !value.trim()} loading={busy} type="submit">
|
||||||
Log in
|
{t("login.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { captureLine } from "#lib/format/capture.js";
|
||||||
import { accountName } from "#lib/format/peer.js";
|
import { accountName } from "#lib/format/peer.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { capture } from "#lib/stores/capture.svelte.js";
|
||||||
import { theme } from "#lib/stores/theme.svelte.js";
|
import { theme } from "#lib/stores/theme.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
const entries: Entry[] = [
|
const entries: Entry[] = [
|
||||||
{ icon: "eye", label: "settings.watches", panel: "watches" },
|
{ icon: "eye", label: "settings.watches", panel: "watches" },
|
||||||
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
|
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
|
||||||
|
{ icon: "note", label: "notes.title", panel: "notes" },
|
||||||
{ icon: "data", label: "settings.storage", panel: "storage" },
|
{ icon: "data", label: "settings.storage", panel: "storage" },
|
||||||
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
|
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
|
||||||
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
|
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
|
||||||
@@ -31,6 +34,12 @@
|
|||||||
|
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
|
|
||||||
|
const quiet = $derived(
|
||||||
|
capture.status !== null && !capture.status.live
|
||||||
|
? captureLine(capture.status, accounts.selected?.is_bot ?? false)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
function onkeydown(event: KeyboardEvent) {
|
||||||
const match = DIGIT.exec(event.code);
|
const match = DIGIT.exec(event.code);
|
||||||
if (!(event.altKey && match)) {
|
if (!(event.altKey && match)) {
|
||||||
@@ -54,14 +63,18 @@
|
|||||||
<span class="menu-trigger">
|
<span class="menu-trigger">
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label={t("menu.open")}
|
aria-label={quiet ? `${t("menu.open")}. ${quiet}` : t("menu.open")}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
|
title={quiet ?? undefined}
|
||||||
variant="translucent"
|
variant="translucent"
|
||||||
>
|
>
|
||||||
<Icon name="menu" />
|
<Icon name="menu" />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertBadge />
|
<AlertBadge />
|
||||||
|
{#if quiet}
|
||||||
|
<span class="capture-dot"></span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -142,6 +155,21 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.capture-dot {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 0.125rem;
|
||||||
|
bottom: 0.125rem;
|
||||||
|
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background-color: var(--color-error);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.main-menu) {
|
:global(.main-menu) {
|
||||||
width: 17rem;
|
width: 17rem;
|
||||||
max-height: calc(100dvh - 5rem);
|
max-height: calc(100dvh - 5rem);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
|
import { createFlash } from "#lib/stores/flash.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";
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let token = 0;
|
let token = 0;
|
||||||
|
const queued = createFlash<number>();
|
||||||
|
|
||||||
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
|
const mime = $derived(result?.state === "ready" ? (result.mime ?? "") : "");
|
||||||
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
|
const isImage = $derived(mime.startsWith("image/") || kind === "photo");
|
||||||
@@ -61,7 +64,9 @@
|
|||||||
const canSave = $derived(
|
const canSave = $derived(
|
||||||
currentMediaId !== null && (fileOnly || result?.state === "ready")
|
currentMediaId !== null && (fileOnly || result?.state === "ready")
|
||||||
);
|
);
|
||||||
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
|
const title = $derived(
|
||||||
|
fileName ?? mediaKindLabel(kind) ?? t("media.kind.other")
|
||||||
|
);
|
||||||
const caption = $derived(items[index]?.caption);
|
const caption = $derived(items[index]?.caption);
|
||||||
|
|
||||||
function revoke() {
|
function revoke() {
|
||||||
@@ -124,7 +129,7 @@
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
toasts.error("Failed to load media");
|
toasts.error(t("media.loadFailed"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
@@ -137,11 +142,12 @@
|
|||||||
if (messageId === null) {
|
if (messageId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const target = messageId;
|
||||||
try {
|
try {
|
||||||
await fetchMedia(chatId, messageId);
|
await fetchMedia(chatId, target);
|
||||||
toasts.success("Скачивание поставлено в очередь");
|
queued.show(target);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось поставить в очередь");
|
toasts.error(t("media.queueFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +159,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadMedia(currentMediaId);
|
await downloadMedia(currentMediaId);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать файл");
|
toasts.error(t("ui.downloadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -225,7 +231,7 @@
|
|||||||
{#if canSave}
|
{#if canSave}
|
||||||
{#if ontrash}
|
{#if ontrash}
|
||||||
<button
|
<button
|
||||||
aria-label="В корзину"
|
aria-label={t("storage.toTrash")}
|
||||||
class="media-action"
|
class="media-action"
|
||||||
onclick={trash}
|
onclick={trash}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -239,7 +245,7 @@
|
|||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label="Скачать файл"
|
aria-label={t("ui.downloadFile")}
|
||||||
class="media-action"
|
class="media-action"
|
||||||
onclick={save}
|
onclick={save}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -249,15 +255,15 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="download" onselect={save}>
|
<ContextMenuItem icon="download" onselect={save}>
|
||||||
Скачать файл
|
{t("ui.downloadFile")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
<ContextMenuItem icon="allow-share" onselect={share}>
|
||||||
Доступ по ссылке
|
{t("ui.shareLink")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<button
|
<button
|
||||||
aria-label="Доступ по ссылке"
|
aria-label={t("ui.shareLink")}
|
||||||
class="media-action"
|
class="media-action"
|
||||||
onclick={share}
|
onclick={share}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -265,7 +271,7 @@
|
|||||||
<Icon name="allow-share" size="1.375rem" />
|
<Icon name="allow-share" size="1.375rem" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<Dialog.Close aria-label="Закрыть" class="media-close">
|
<Dialog.Close aria-label={t("common.close")} class="media-close">
|
||||||
<Icon name="close" size="1.5rem" />
|
<Icon name="close" size="1.5rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,7 +282,7 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<Spinner color="white" />
|
<Spinner color="white" />
|
||||||
{:else if result?.state === "ready" && isImage}
|
{:else if result?.state === "ready" && isImage}
|
||||||
<img alt={kind} class="media-image" src={result.url}>
|
<img alt={title} class="media-image" src={result.url}>
|
||||||
{:else if result?.state === "ready" && isVideo}
|
{:else if result?.state === "ready" && isVideo}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
<!-- biome-ignore lint/a11y/useMediaCaption: archived media has no captions -->
|
||||||
@@ -301,24 +307,29 @@
|
|||||||
{#if currentMediaId !== null}
|
{#if currentMediaId !== null}
|
||||||
<button class="media-download" onclick={save} type="button">
|
<button class="media-download" onclick={save} type="button">
|
||||||
<Icon name="download" />
|
<Icon name="download" />
|
||||||
Скачать файл
|
{t("ui.downloadFile")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if result?.state === "not-downloaded"}
|
{:else if result?.state === "not-downloaded"}
|
||||||
<div class="media-message">
|
<div class="media-message">
|
||||||
<p>Файл ещё не скачан в архив.</p>
|
<p>{t("media.notDownloaded")}</p>
|
||||||
<Button fluid onclick={queueFetch} variant="primary">
|
<Button fluid onclick={queueFetch} variant="primary">
|
||||||
Скачать в архив
|
{#if queued.current === messageId}
|
||||||
|
<Icon name="check" />
|
||||||
|
{t("chat.media.queued")}
|
||||||
|
{:else}
|
||||||
|
{t("media.downloadToArchive")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if result?.state === "missing"}
|
{:else if result?.state === "missing"}
|
||||||
<p class="media-message">Файл не найден.</p>
|
<p class="media-message">{t("media.missing")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if hasNav}
|
{#if hasNav}
|
||||||
<button
|
<button
|
||||||
aria-label="Previous"
|
aria-label={t("ui.previous")}
|
||||||
class="media-nav prev"
|
class="media-nav prev"
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
onclick={() => step(-1)}
|
onclick={() => step(-1)}
|
||||||
@@ -327,7 +338,7 @@
|
|||||||
<Icon name="previous" size="1.75rem" />
|
<Icon name="previous" size="1.75rem" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Next"
|
aria-label={t("ui.next")}
|
||||||
class="media-nav next"
|
class="media-nav next"
|
||||||
disabled={index === items.length - 1}
|
disabled={index === items.length - 1}
|
||||||
onclick={() => step(1)}
|
onclick={() => step(1)}
|
||||||
@@ -397,6 +408,11 @@
|
|||||||
|
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
top: auto;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.media-close) {
|
:global(.media-close) {
|
||||||
|
|||||||
@@ -15,14 +15,16 @@
|
|||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import CopyItem from "#lib/components/ui/CopyItem.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WebPage from "#lib/components/WebPage.svelte";
|
import WebPage from "#lib/components/WebPage.svelte";
|
||||||
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||||
|
import { buttonHasMark, hasMark } from "#lib/format/search.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { notes } from "#lib/stores/notes.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { appear } from "#lib/transitions/appear.js";
|
import { appear } from "#lib/transitions/appear.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
animate: boolean;
|
animate: boolean;
|
||||||
firstInGroup: boolean;
|
firstInGroup: boolean;
|
||||||
highlighted: boolean;
|
highlight: string[] | null;
|
||||||
isGroupChat: boolean;
|
isGroupChat: boolean;
|
||||||
lastInGroup: boolean;
|
lastInGroup: boolean;
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
isGroupChat,
|
isGroupChat,
|
||||||
firstInGroup,
|
firstInGroup,
|
||||||
lastInGroup,
|
lastInGroup,
|
||||||
highlighted,
|
highlight,
|
||||||
animate,
|
animate,
|
||||||
onjump,
|
onjump,
|
||||||
onmedia,
|
onmedia,
|
||||||
@@ -54,6 +56,14 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const deleted = $derived(message.deleted_at !== null);
|
const deleted = $derived(message.deleted_at !== null);
|
||||||
|
const marks = $derived(highlight ?? []);
|
||||||
|
const markedInside = $derived(
|
||||||
|
marks.length > 0 &&
|
||||||
|
(hasMark(message.text, marks) ||
|
||||||
|
message.inline_buttons.some((row) =>
|
||||||
|
row.some((button) => buttonHasMark(button, marks))
|
||||||
|
))
|
||||||
|
);
|
||||||
const hasText = $derived(Boolean(message.text));
|
const hasText = $derived(Boolean(message.text));
|
||||||
const special = $derived(
|
const special = $derived(
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -137,13 +147,8 @@
|
|||||||
onjump(message.reply.message_id);
|
onjump(message.reply.message_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function copyText() {
|
function messageLink(): string {
|
||||||
await navigator.clipboard.writeText(message.text ?? "");
|
return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
|
||||||
toasts.success(t("thread.copiedText"));
|
|
||||||
}
|
|
||||||
async function copyId() {
|
|
||||||
await navigator.clipboard.writeText(String(message.message_id));
|
|
||||||
toasts.success(t("thread.copiedId"));
|
|
||||||
}
|
}
|
||||||
function openSenderProfile() {
|
function openSenderProfile() {
|
||||||
if (senderChat === message.chat_id) {
|
if (senderChat === message.chat_id) {
|
||||||
@@ -166,7 +171,7 @@
|
|||||||
data-message-id={message.message_id}
|
data-message-id={message.message_id}
|
||||||
class:deleted
|
class:deleted
|
||||||
class:first-in-group={firstInGroup}
|
class:first-in-group={firstInGroup}
|
||||||
class:highlighted
|
class:highlighted={highlight !== null && !markedInside}
|
||||||
class:last-in-group={lastInGroup}
|
class:last-in-group={lastInGroup}
|
||||||
class:own
|
class:own
|
||||||
class:with-avatar={isGroupChat}
|
class:with-avatar={isGroupChat}
|
||||||
@@ -212,12 +217,6 @@
|
|||||||
{#if message.reply}
|
{#if message.reply}
|
||||||
<ReplyHeader {onjump} reply={message.reply} />
|
<ReplyHeader {onjump} reply={message.reply} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if deleted}
|
|
||||||
<span class="deleted-tag">
|
|
||||||
<Icon name="delete" size="0.875rem" />
|
|
||||||
{t("thread.deleted")}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if message.poll}
|
{#if message.poll}
|
||||||
<Poll poll={message.poll} />
|
<Poll poll={message.poll} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -236,13 +235,20 @@
|
|||||||
{own}
|
{own}
|
||||||
/>
|
/>
|
||||||
{:else if message.has_media}
|
{:else if message.has_media}
|
||||||
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
|
<MessageMedia
|
||||||
|
items={menu}
|
||||||
|
{message}
|
||||||
|
onopen={() => onmedia(0)}
|
||||||
|
{onversions}
|
||||||
|
{own}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<EntityText
|
<EntityText
|
||||||
entities={message.entities}
|
entities={message.entities}
|
||||||
|
{marks}
|
||||||
{own}
|
{own}
|
||||||
text={message.text ?? ""}
|
text={message.text ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -266,9 +272,15 @@
|
|||||||
{t("thread.links", { count: linkCount })}
|
{t("thread.links", { count: linkCount })}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<MessageMeta {message} {onversions} {own} />
|
<MessageMeta
|
||||||
|
{message}
|
||||||
|
noted={notes.has(message)}
|
||||||
|
onnote={openAnnotations}
|
||||||
|
{onversions}
|
||||||
|
{own}
|
||||||
|
/>
|
||||||
{#if message.inline_buttons.length}
|
{#if message.inline_buttons.length}
|
||||||
<InlineButtons rows={message.inline_buttons} />
|
<InlineButtons {marks} rows={message.inline_buttons} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasCallbacks}
|
{#if hasCallbacks}
|
||||||
<button class="detail-chip" onclick={openCallbacks} type="button">
|
<button class="detail-chip" onclick={openCallbacks} type="button">
|
||||||
@@ -319,14 +331,26 @@
|
|||||||
>{t("thread.toReply")}</ContextMenuItem
|
>{t("thread.toReply")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
<CopyItem
|
||||||
|
done={t("chat.menu.linkCopied")}
|
||||||
|
icon="link"
|
||||||
|
label={t("chat.menu.copyLink")}
|
||||||
|
text={messageLink}
|
||||||
|
/>
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
<ContextMenuItem icon="copy" onselect={copyText}
|
<CopyItem
|
||||||
>{t("thread.copyText")}</ContextMenuItem
|
done={t("chat.menu.copied")}
|
||||||
>
|
icon="copy"
|
||||||
|
label={t("thread.copyText")}
|
||||||
|
text={() => message.text ?? ""}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuItem icon="hashtag" onselect={copyId}
|
<CopyItem
|
||||||
>{t("thread.copyId")}</ContextMenuItem
|
done={t("chat.menu.copied")}
|
||||||
>
|
icon="hashtag"
|
||||||
|
label={t("thread.copyId")}
|
||||||
|
text={() => String(message.message_id)}
|
||||||
|
/>
|
||||||
{#if isGroupChat && !own && message.sender_id !== null}
|
{#if isGroupChat && !own && message.sender_id !== null}
|
||||||
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
||||||
>{t("thread.senderProfile")}</ContextMenuItem
|
>{t("thread.senderProfile")}</ContextMenuItem
|
||||||
@@ -454,17 +478,6 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleted-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-chip {
|
.detail-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, untrack } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
|
import type { CaptureGap } from "#lib/api/capture.js";
|
||||||
import { listMessages } from "#lib/api/endpoints.js";
|
import { listMessages } from "#lib/api/endpoints.js";
|
||||||
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
|
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
|
||||||
import type { LiveEvent, MessageView } from "#lib/api/types.js";
|
import type { LiveEvent, MessageView } from "#lib/api/types.js";
|
||||||
import ActionMessage from "#lib/components/ActionMessage.svelte";
|
import ActionMessage from "#lib/components/ActionMessage.svelte";
|
||||||
|
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
||||||
import MessageBubble from "#lib/components/MessageBubble.svelte";
|
import MessageBubble from "#lib/components/MessageBubble.svelte";
|
||||||
import MessageVersions from "#lib/components/MessageVersions.svelte";
|
import MessageVersions from "#lib/components/MessageVersions.svelte";
|
||||||
@@ -11,9 +13,10 @@
|
|||||||
import TrackChat from "#lib/components/TrackChat.svelte";
|
import TrackChat from "#lib/components/TrackChat.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatDay } from "#lib/format/datetime.js";
|
import { formatDay, formatSpanRange } from "#lib/format/datetime.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { capture } from "#lib/stores/capture.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { discover } from "#lib/stores/discover.svelte.js";
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
const STICK_OFFSET = 9;
|
const STICK_OFFSET = 9;
|
||||||
const IDLE_DELAY = 1500;
|
const IDLE_DELAY = 1500;
|
||||||
const MAX_MESSAGES = 360;
|
const MAX_MESSAGES = 360;
|
||||||
|
const SETTLE_DELAY = 400;
|
||||||
|
const HIGHLIGHT_MS = 3000;
|
||||||
|
|
||||||
let messages = $state<MessageView[]>([]);
|
let messages = $state<MessageView[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -45,6 +50,7 @@
|
|||||||
let suppressAppear = $state(false);
|
let suppressAppear = $state(false);
|
||||||
let scrolling = $state(false);
|
let scrolling = $state(false);
|
||||||
let stuckDay = $state<string | null>(null);
|
let stuckDay = $state<string | null>(null);
|
||||||
|
let passedDays = $state.raw(new Set<string>());
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let mediaOpen = $state(false);
|
let mediaOpen = $state(false);
|
||||||
@@ -52,7 +58,10 @@
|
|||||||
let mediaIndex = $state(0);
|
let mediaIndex = $state(0);
|
||||||
let versionsOpen = $state(false);
|
let versionsOpen = $state(false);
|
||||||
let versionsMessageId = $state<number | null>(null);
|
let versionsMessageId = $state<number | null>(null);
|
||||||
let highlightId = $state<number | null>(null);
|
let highlight = $state<{ id: number; marks: string[] } | null>(null);
|
||||||
|
let calendarOpen = $state(false);
|
||||||
|
let calendarAnchor = $state<HTMLElement | null>(null);
|
||||||
|
let calendarMonth = $state<{ month: number; year: number } | null>(null);
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
@@ -66,6 +75,8 @@
|
|||||||
dayKey: string;
|
dayKey: string;
|
||||||
daySeparator: string | null;
|
daySeparator: string | null;
|
||||||
firstInGroup: boolean;
|
firstInGroup: boolean;
|
||||||
|
gapsAfter: CaptureGap[];
|
||||||
|
gapsBefore: CaptureGap[];
|
||||||
lastInGroup: boolean;
|
lastInGroup: boolean;
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
own: boolean;
|
own: boolean;
|
||||||
@@ -75,6 +86,27 @@
|
|||||||
return new Date(iso).toDateString();
|
return new Date(iso).toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gapSpans = $derived(
|
||||||
|
capture.gaps.map((gap) => ({
|
||||||
|
day: dayKey(gap.start),
|
||||||
|
end: Date.parse(gap.end),
|
||||||
|
gap,
|
||||||
|
start: Date.parse(gap.start),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function gapsBetween(
|
||||||
|
prev: MessageView | undefined,
|
||||||
|
message: MessageView
|
||||||
|
): typeof gapSpans {
|
||||||
|
if (!prev || gapSpans.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const from = Date.parse(prev.date);
|
||||||
|
const to = Date.parse(message.date);
|
||||||
|
return gapSpans.filter((span) => span.start >= from && span.end <= to);
|
||||||
|
}
|
||||||
|
|
||||||
const rows = $derived.by<Row[]>(() => {
|
const rows = $derived.by<Row[]>(() => {
|
||||||
const out: Row[] = [];
|
const out: Row[] = [];
|
||||||
for (let i = 0; i < messages.length; i += 1) {
|
for (let i = 0; i < messages.length; i += 1) {
|
||||||
@@ -82,15 +114,28 @@
|
|||||||
const prev = messages[i - 1];
|
const prev = messages[i - 1];
|
||||||
const next = messages[i + 1];
|
const next = messages[i + 1];
|
||||||
const day = dayKey(message.date);
|
const day = dayKey(message.date);
|
||||||
|
const gaps = gapsBetween(prev, message);
|
||||||
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
||||||
const sameNextDay = next ? dayKey(next.date) === day : false;
|
const sameNextDay = next ? dayKey(next.date) === day : false;
|
||||||
out.push({
|
out.push({
|
||||||
dayKey: day,
|
dayKey: day,
|
||||||
daySeparator: samePrevDay ? null : formatDay(message.date),
|
daySeparator: samePrevDay ? null : formatDay(message.date),
|
||||||
firstInGroup:
|
firstInGroup:
|
||||||
!prev || prev.sender_id !== message.sender_id || !samePrevDay,
|
!prev ||
|
||||||
|
prev.sender_id !== message.sender_id ||
|
||||||
|
!samePrevDay ||
|
||||||
|
gaps.length > 0,
|
||||||
|
gapsAfter: gaps
|
||||||
|
.filter((span) => span.day === day)
|
||||||
|
.map((span) => span.gap),
|
||||||
|
gapsBefore: gaps
|
||||||
|
.filter((span) => span.day !== day)
|
||||||
|
.map((span) => span.gap),
|
||||||
lastInGroup:
|
lastInGroup:
|
||||||
!next || next.sender_id !== message.sender_id || !sameNextDay,
|
!next ||
|
||||||
|
next.sender_id !== message.sender_id ||
|
||||||
|
!sameNextDay ||
|
||||||
|
gapsBetween(message, next).length > 0,
|
||||||
message,
|
message,
|
||||||
own: ownId !== null && message.sender_id === ownId,
|
own: ownId !== null && message.sender_id === ownId,
|
||||||
});
|
});
|
||||||
@@ -185,7 +230,11 @@
|
|||||||
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
return messages.find((m) => holds(m, messageId))?.message_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jumpToMessage(messageId: number, smooth = true) {
|
function jumpToMessage(
|
||||||
|
messageId: number,
|
||||||
|
smooth = true,
|
||||||
|
marks: string[] = []
|
||||||
|
) {
|
||||||
const owner = ownerOf(messageId) ?? messageId;
|
const owner = ownerOf(messageId) ?? messageId;
|
||||||
const target = container?.querySelector<HTMLElement>(
|
const target = container?.querySelector<HTMLElement>(
|
||||||
`[data-message-id="${owner}"]`
|
`[data-message-id="${owner}"]`
|
||||||
@@ -198,13 +247,13 @@
|
|||||||
behavior: smooth ? "smooth" : "auto",
|
behavior: smooth ? "smooth" : "auto",
|
||||||
block: "center",
|
block: "center",
|
||||||
});
|
});
|
||||||
highlightId = owner;
|
highlight = { id: owner, marks };
|
||||||
if (highlightTimer) {
|
if (highlightTimer) {
|
||||||
clearTimeout(highlightTimer);
|
clearTimeout(highlightTimer);
|
||||||
}
|
}
|
||||||
highlightTimer = setTimeout(() => {
|
highlightTimer = setTimeout(() => {
|
||||||
highlightId = null;
|
highlight = null;
|
||||||
}, 1600);
|
}, HIGHLIGHT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
@@ -219,13 +268,14 @@
|
|||||||
}
|
}
|
||||||
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
||||||
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
||||||
let day: string | null = null;
|
const stuck: string[] = [];
|
||||||
for (const sep of seps) {
|
for (const sep of seps) {
|
||||||
if (sep.getBoundingClientRect().top <= limit) {
|
if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
|
||||||
day = sep.dataset.day ?? null;
|
stuck.push(sep.dataset.day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stuckDay = day;
|
stuckDay = stuck.pop() ?? null;
|
||||||
|
passedDays = new Set(stuck);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitial() {
|
async function loadInitial() {
|
||||||
@@ -238,7 +288,7 @@
|
|||||||
ui.clearJump();
|
ui.clearJump();
|
||||||
await loadAround(pending.messageId);
|
await loadAround(pending.messageId);
|
||||||
await tick();
|
await tick();
|
||||||
jumpToMessage(pending.messageId, false);
|
jumpToMessage(pending.messageId, false, pending.marks);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const page = await listMessages(chatId, {
|
const page = await listMessages(chatId, {
|
||||||
@@ -252,6 +302,7 @@
|
|||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
updateStuck();
|
updateStuck();
|
||||||
|
setTimeout(updateStuck, SETTLE_DELAY);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error(t("thread.loadFailed"));
|
toasts.error(t("thread.loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -394,6 +445,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCalendar(event: MouseEvent, iso: string) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
calendarAnchor = event.currentTarget as HTMLElement;
|
||||||
|
calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() };
|
||||||
|
calendarOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function openMedia(message: MessageView, index: number) {
|
function openMedia(message: MessageView, index: number) {
|
||||||
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
||||||
mediaIndex = index;
|
mediaIndex = index;
|
||||||
@@ -521,14 +579,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goTo(messageId: number) {
|
async function goTo(messageId: number, marks: string[] = []) {
|
||||||
try {
|
try {
|
||||||
const loaded = ownerOf(messageId) !== null;
|
const loaded = ownerOf(messageId) !== null;
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
await loadAround(messageId);
|
await loadAround(messageId);
|
||||||
}
|
}
|
||||||
await tick();
|
await tick();
|
||||||
jumpToMessage(messageId, loaded);
|
jumpToMessage(messageId, loaded, marks);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error(t("thread.loadFailed"));
|
toasts.error(t("thread.loadFailed"));
|
||||||
}
|
}
|
||||||
@@ -540,7 +598,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.clearJump();
|
ui.clearJump();
|
||||||
goTo(target.messageId);
|
goTo(target.messageId, target.marks);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -581,22 +639,38 @@
|
|||||||
<div class="loading-older"><Spinner /></div>
|
<div class="loading-older"><Spinner /></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each rows as row (row.message.message_id)}
|
{#each rows as row (row.message.message_id)}
|
||||||
|
{#each row.gapsBefore as gap (gap.start)}
|
||||||
|
{@render gapLine(gap)}
|
||||||
|
{/each}
|
||||||
{#if row.daySeparator}
|
{#if row.daySeparator}
|
||||||
<div
|
<div
|
||||||
class="day-separator"
|
class="day-separator"
|
||||||
data-day={row.dayKey}
|
data-day={row.dayKey}
|
||||||
class:idle={!scrolling && row.dayKey === stuckDay}
|
class:idle={!scrolling && row.dayKey === stuckDay}
|
||||||
|
class:passed={passedDays.has(row.dayKey)}
|
||||||
>
|
>
|
||||||
<span>{row.daySeparator}</span>
|
<button
|
||||||
|
aria-label={t("chat.calendar")}
|
||||||
|
onclick={(event) => openCalendar(event, row.message.date)}
|
||||||
|
title={t("chat.calendar")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{row.daySeparator}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each row.gapsAfter as gap (gap.start)}
|
||||||
|
{@render gapLine(gap)}
|
||||||
|
{/each}
|
||||||
{#if row.message.service}
|
{#if row.message.service}
|
||||||
<ActionMessage message={row.message} onjump={goTo} />
|
<ActionMessage message={row.message} onjump={goTo} />
|
||||||
{:else}
|
{:else}
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
animate={!suppressAppear}
|
animate={!suppressAppear}
|
||||||
firstInGroup={row.firstInGroup}
|
firstInGroup={row.firstInGroup}
|
||||||
highlighted={highlightId === row.message.message_id}
|
highlight={highlight?.id === row.message.message_id
|
||||||
|
? highlight.marks
|
||||||
|
: null}
|
||||||
{isGroupChat}
|
{isGroupChat}
|
||||||
lastInGroup={row.lastInGroup}
|
lastInGroup={row.lastInGroup}
|
||||||
message={row.message}
|
message={row.message}
|
||||||
@@ -625,6 +699,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#snippet gapLine(
|
||||||
|
gap: CaptureGap
|
||||||
|
)}
|
||||||
|
<div class="capture-gap">
|
||||||
|
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPopover
|
||||||
|
anchor={calendarAnchor}
|
||||||
|
{chatId}
|
||||||
|
month={calendarMonth}
|
||||||
|
bind:open={calendarOpen}
|
||||||
|
/>
|
||||||
<MediaViewer
|
<MediaViewer
|
||||||
{chatId}
|
{chatId}
|
||||||
items={mediaItems}
|
items={mediaItems}
|
||||||
@@ -714,10 +802,15 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
|
|
||||||
span {
|
button {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
@@ -725,10 +818,39 @@
|
|||||||
background-color: var(--color-default-shadow);
|
background-color: var(--color-default-shadow);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.idle span {
|
&.idle button,
|
||||||
|
&.passed button {
|
||||||
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.capture-gap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
border-top: 1px dashed var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ContextMenu as Menu } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
import { visible } from "#lib/actions/visible.js";
|
import { visible } from "#lib/actions/visible.js";
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
import { fetchMedia } from "#lib/api/endpoints.js";
|
import { fetchMedia } from "#lib/api/endpoints.js";
|
||||||
@@ -14,24 +16,25 @@
|
|||||||
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
||||||
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
||||||
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
|
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
|
||||||
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
|
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
items?: Snippet;
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
onopen: () => void;
|
onopen: () => void;
|
||||||
|
onversions?: () => void;
|
||||||
own: boolean;
|
own: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { message, onopen, own }: Props = $props();
|
let { items, message, onopen, onversions, own }: Props = $props();
|
||||||
|
|
||||||
const POLL_TRIES = 5;
|
const POLL_TRIES = 5;
|
||||||
const POLL_DELAY = 3000;
|
const POLL_DELAY = 3000;
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let media = $state<InlineMedia | null>(null);
|
let media = $state<InlineMedia | null>(null);
|
||||||
let queuing = $state(false);
|
let queuing = $state(false);
|
||||||
|
let queued = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
const ref = $derived(message.media[0] ?? null);
|
const ref = $derived(message.media[0] ?? null);
|
||||||
@@ -71,7 +75,10 @@
|
|||||||
const storedId = $derived(
|
const storedId = $derived(
|
||||||
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
|
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
|
||||||
);
|
);
|
||||||
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
|
const fileName = $derived(
|
||||||
|
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
|
||||||
|
);
|
||||||
|
const waiting = $derived(queuing || queued);
|
||||||
|
|
||||||
function delay(ms: number): Promise<void> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -101,16 +108,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queue() {
|
async function queue() {
|
||||||
if (queuing) {
|
if (waiting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queuing = true;
|
queuing = true;
|
||||||
try {
|
try {
|
||||||
await fetchMedia(message.chat_id, message.message_id);
|
await fetchMedia(message.chat_id, message.message_id);
|
||||||
toasts.success("Скачивание поставлено в очередь");
|
queued = true;
|
||||||
poll();
|
poll();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось поставить в очередь");
|
toasts.error(t("chat.media.queueFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
queuing = false;
|
queuing = false;
|
||||||
}
|
}
|
||||||
@@ -124,7 +131,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadMedia(storedId);
|
await downloadMedia(storedId);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать файл");
|
toasts.error(t("chat.media.downloadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -145,7 +152,7 @@
|
|||||||
{#if message.is_self_destruct}
|
{#if message.is_self_destruct}
|
||||||
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
||||||
<Icon name="timer" size="1.25rem" />
|
<Icon name="timer" size="1.25rem" />
|
||||||
<span>Самоуничтожающееся медиа</span>
|
<span>{t("chat.media.selfDestruct")}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else if asFile && ref}
|
{:else if asFile && ref}
|
||||||
<FileChip media={ref} {own} />
|
<FileChip media={ref} {own} />
|
||||||
@@ -165,11 +172,11 @@
|
|||||||
<AudioFile {own} title={fileName} url={ready.url} />
|
<AudioFile {own} title={fileName} url={ready.url} />
|
||||||
{:else if ready && isImage}
|
{:else if ready && isImage}
|
||||||
<button class="media-thumb" onclick={onopen} type="button">
|
<button class="media-thumb" onclick={onopen} type="button">
|
||||||
<img alt="attachment" src={ready.url}>
|
<img alt={t("chat.media.photo")} src={ready.url}>
|
||||||
</button>
|
</button>
|
||||||
{:else if ready && isStaticSticker}
|
{:else if ready && isStaticSticker}
|
||||||
<button class="media-sticker" onclick={onopen} type="button">
|
<button class="media-sticker" onclick={onopen} type="button">
|
||||||
<img alt="sticker" src={ready.url}>
|
<img alt={fileName} src={ready.url}>
|
||||||
</button>
|
</button>
|
||||||
{:else if ready && isVideoSticker}
|
{:else if ready && isVideoSticker}
|
||||||
<video
|
<video
|
||||||
@@ -205,46 +212,59 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
||||||
<button class="media-placeholder" onclick={queue} type="button">
|
<button class="media-placeholder" onclick={queue} type="button">
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
|
||||||
<span>{vk === "video" ? "Видео" : "Фото"}</span>
|
<span
|
||||||
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
|
>{vk === "video"
|
||||||
|
? t("chat.media.video")
|
||||||
|
: t("chat.media.photo")}</span
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
>{waiting
|
||||||
|
? t("chat.media.queued")
|
||||||
|
: t("chat.media.tapToDownload")}</small
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{:else if media?.state === "not-downloaded"}
|
{:else if media?.state === "not-downloaded"}
|
||||||
<button class="media-chip" onclick={queue} type="button">
|
<button class="media-chip" onclick={queue} type="button">
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
|
||||||
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
|
<span
|
||||||
|
>{waiting
|
||||||
|
? t("chat.media.queued")
|
||||||
|
: t("chat.media.download", { kind: label })}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="media-chip" onclick={onopen} type="button">
|
<button class="media-chip" onclick={onopen} type="button">
|
||||||
<Icon name="photo" size="1.25rem" />
|
<Icon name="photo" size="1.25rem" />
|
||||||
<span>Медиа</span>
|
<span>{t("chat.media")}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
{#if own}
|
|
||||||
<MessageReadItem {message} />
|
|
||||||
{/if}
|
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||||
>Открыть на весь экран</ContextMenuItem
|
>{t("chat.media.fullscreen")}</ContextMenuItem
|
||||||
>
|
|
||||||
<ContextMenuItem
|
|
||||||
icon="recent"
|
|
||||||
onselect={() => ui.openMessagePanel("versions", message.message_id)}
|
|
||||||
>Версии медиа</ContextMenuItem
|
|
||||||
>
|
>
|
||||||
|
{#if onversions}
|
||||||
|
<ContextMenuItem icon="recent" onselect={onversions}
|
||||||
|
>{t("chat.media.versions")}</ContextMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
{#if storedId === null}
|
{#if storedId === null}
|
||||||
<ContextMenuItem icon="cloud-download" onselect={queue}>
|
<ContextMenuItem icon="cloud-download" onselect={queue}
|
||||||
Скачать в архив
|
>{t("chat.media.queue")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenuItem icon="download" onselect={save}>
|
<ContextMenuItem icon="download" onselect={save}
|
||||||
Скачать файл
|
>{t("chat.media.save")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
<ContextMenuItem icon="allow-share" onselect={share}
|
||||||
Доступ по ссылке
|
>{t("chat.media.share")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
|
{/if}
|
||||||
|
{#if items}
|
||||||
|
<Menu.Separator class="bg-menu-separator" />
|
||||||
|
{@render items()}
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -1,30 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MessageView } from "#lib/api/types.js";
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatTime } from "#lib/format/datetime.js";
|
import {
|
||||||
|
formatExact,
|
||||||
|
formatStamp,
|
||||||
|
formatTime,
|
||||||
|
} from "#lib/format/datetime.js";
|
||||||
import { readTitle } from "#lib/format/read.js";
|
import { readTitle } from "#lib/format/read.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
|
noted?: boolean;
|
||||||
|
onnote?: () => void;
|
||||||
onversions?: () => void;
|
onversions?: () => void;
|
||||||
own?: boolean;
|
own?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { message, onversions, own = false }: Props = $props();
|
let {
|
||||||
|
message,
|
||||||
|
noted = false,
|
||||||
|
onnote,
|
||||||
|
onversions,
|
||||||
|
own = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const TIP_DURATION = 3000;
|
||||||
|
|
||||||
|
let tip = $state(false);
|
||||||
|
let tipTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const exact = $derived(formatExact(message.date));
|
||||||
|
|
||||||
|
function toggleTip() {
|
||||||
|
tip = !tip;
|
||||||
|
clearTimeout(tipTimer);
|
||||||
|
if (tip) {
|
||||||
|
tipTimer = setTimeout(() => {
|
||||||
|
tip = false;
|
||||||
|
}, TIP_DURATION);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="MessageMeta">
|
<span class="MessageMeta">
|
||||||
{#if message.edited_at}
|
{#if noted}
|
||||||
<button
|
<button
|
||||||
class="edited"
|
aria-label={t("chat.note")}
|
||||||
onclick={onversions}
|
class="meta-button note"
|
||||||
title="View edit history"
|
onclick={onnote}
|
||||||
|
title={t("chat.note")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
edited
|
<Icon name="note" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="time">{formatTime(message.date)}</span>
|
{#if message.deleted_at}
|
||||||
|
<span class="deleted" title={formatExact(message.deleted_at)}>
|
||||||
|
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if message.edited_at}
|
||||||
|
<button
|
||||||
|
class="meta-button edited"
|
||||||
|
onclick={onversions}
|
||||||
|
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("chat.edited")}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="meta-button time"
|
||||||
|
onclick={toggleTip}
|
||||||
|
title={exact}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{formatTime(message.date)}
|
||||||
|
</button>
|
||||||
|
{#if tip}
|
||||||
|
<span class="tip" role="status">{exact}</span>
|
||||||
|
{/if}
|
||||||
{#if own}
|
{#if own}
|
||||||
<span class="ticks" title={readTitle(message)}>
|
<span class="ticks" title={readTitle(message)}>
|
||||||
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||||
@@ -34,6 +90,8 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.MessageMeta {
|
.MessageMeta {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -47,21 +105,70 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.meta-button {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edited,
|
||||||
|
.deleted {
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted::after {
|
||||||
|
content: "·";
|
||||||
|
margin-inline-start: 0.25rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 0.375rem);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--color-white);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
background-color: var(--color-toast-background);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
.ticks {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edited {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -6,6 +6,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 { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
versions = text;
|
versions = text;
|
||||||
mediaVersions = media;
|
mediaVersions = media;
|
||||||
})
|
})
|
||||||
.catch(() => toasts.error("Failed to load edit history"))
|
.catch(() => toasts.error(t("versions.failed")))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
@@ -46,8 +47,8 @@
|
|||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">Edit history</Dialog.Title>
|
<Dialog.Title class="dialog-title">{t("versions.title")}</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Close" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
@@ -55,11 +56,11 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="centered"><Spinner /></div>
|
<div class="centered"><Spinner /></div>
|
||||||
{:else if versions.length === 0 && mediaVersions.length === 0}
|
{:else if versions.length === 0 && mediaVersions.length === 0}
|
||||||
<p class="empty">No versions recorded.</p>
|
<p class="empty">{t("versions.none")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#if mediaVersions.length > 0}
|
{#if mediaVersions.length > 0}
|
||||||
<div class="media-versions">
|
<div class="media-versions">
|
||||||
<span class="media-label">Media versions</span>
|
<span class="media-label">{t("versions.media")}</span>
|
||||||
<div class="media-strip">
|
<div class="media-strip">
|
||||||
{#each mediaVersions as media (media.id)}
|
{#each mediaVersions as media (media.id)}
|
||||||
<MediaVersionThumb version={media} />
|
<MediaVersionThumb version={media} />
|
||||||
@@ -70,12 +71,14 @@
|
|||||||
{#each versions as version, index (version.observed_at)}
|
{#each versions as version, index (version.observed_at)}
|
||||||
<div class="version">
|
<div class="version">
|
||||||
<div class="version-meta">
|
<div class="version-meta">
|
||||||
<span class="version-label">Version {index + 1}</span>
|
<span class="version-label"
|
||||||
|
>{t("versions.version", { number: index + 1 })}</span
|
||||||
|
>
|
||||||
<span class="version-date"
|
<span class="version-date"
|
||||||
>{formatFull(version.observed_at)}</span
|
>{formatFull(version.observed_at)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-text">{version.text ?? "(no text)"}</div>
|
<div class="version-text">{version.text ?? t("feed.noText")}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { PinnedView } from "#lib/api/types.js";
|
import type { PinnedView } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { mediaKindLabel } from "#lib/format/media.js";
|
import { mediaKindLabel } from "#lib/format/media.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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
? (pinned.text ??
|
? (pinned.text ??
|
||||||
(pinned.media_kind
|
(pinned.media_kind
|
||||||
? mediaKindLabel(pinned.media_kind)
|
? mediaKindLabel(pinned.media_kind)
|
||||||
: "Pinned message"))
|
: t("pinned.message")))
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
>
|
>
|
||||||
<Icon name="pin" size="1.125rem" />
|
<Icon name="pin" size="1.125rem" />
|
||||||
<span class="body">
|
<span class="body">
|
||||||
<span class="label">Pinned message</span>
|
<span class="label">{t("pinned.message")}</span>
|
||||||
<span class="preview">{preview}</span>
|
<span class="preview">{preview}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
|
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
|
||||||
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
|
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
|
||||||
|
import NotesPanel from "#lib/components/annotations/NotesPanel.svelte";
|
||||||
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
||||||
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
|
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
|
||||||
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
|
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { notes } from "#lib/stores/notes.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
changes: "feed.changes",
|
changes: "feed.changes",
|
||||||
jobs: "settings.jobs",
|
jobs: "settings.jobs",
|
||||||
links: "panel.links",
|
links: "panel.links",
|
||||||
|
notes: "notes.title",
|
||||||
policy: "settings.policy",
|
policy: "settings.policy",
|
||||||
presence: "chat.analytics",
|
presence: "chat.analytics",
|
||||||
profile: "chat.profile",
|
profile: "chat.profile",
|
||||||
@@ -49,7 +52,6 @@
|
|||||||
"storage-trash": "panel.trash",
|
"storage-trash": "panel.trash",
|
||||||
stories: "settings.stories",
|
stories: "settings.stories",
|
||||||
"stories-all": "panel.allStories",
|
"stories-all": "panel.allStories",
|
||||||
versions: "panel.versions",
|
|
||||||
watches: "settings.watches",
|
watches: "settings.watches",
|
||||||
webhooks: "watches.webhooks",
|
webhooks: "watches.webhooks",
|
||||||
};
|
};
|
||||||
@@ -65,6 +67,9 @@
|
|||||||
if (ui.rightPanel === null) {
|
if (ui.rightPanel === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (ui.rightPanel === "notes" && notes.tag !== null) {
|
||||||
|
return t("notes.tag", { tag: notes.tag });
|
||||||
|
}
|
||||||
const chat =
|
const chat =
|
||||||
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
||||||
? chats.byId(storageUi.chatId)
|
? chats.byId(storageUi.chatId)
|
||||||
@@ -139,6 +144,10 @@
|
|||||||
<AlertsPanel />
|
<AlertsPanel />
|
||||||
{:else if ui.rightPanel === "annotations"}
|
{:else if ui.rightPanel === "annotations"}
|
||||||
<AnnotationsPanel />
|
<AnnotationsPanel />
|
||||||
|
{:else if ui.rightPanel === "notes"}
|
||||||
|
{#key accounts.selectedId}
|
||||||
|
<NotesPanel />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "shares"}
|
{:else if ui.rightPanel === "shares"}
|
||||||
<SharesPanel />
|
<SharesPanel />
|
||||||
{:else if ui.rightPanel === "bots"}
|
{:else if ui.rightPanel === "bots"}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
|
import { enqueueBackfill, trackChat } from "#lib/api/endpoints.js";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { 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 { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +16,11 @@
|
|||||||
let { chatId }: Props = $props();
|
let { chatId }: Props = $props();
|
||||||
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
const started = createFlash<true>();
|
||||||
|
|
||||||
const item = $derived(discover.get(chatId));
|
const item = $derived(discover.get(chatId));
|
||||||
const tracked = $derived(item?.tracked ?? false);
|
const tracked = $derived(item?.tracked ?? false);
|
||||||
const title = $derived(item?.title ?? "Этот чат");
|
const title = $derived(item?.title ?? t("track.thisChat"));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (accounts.selectedId === null) {
|
if (accounts.selectedId === null) {
|
||||||
@@ -33,10 +36,10 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
discover.set(await trackChat(chatId, true));
|
discover.set(await trackChat(chatId, true));
|
||||||
toasts.success("Отслеживание включено, история загружается");
|
started.show(true);
|
||||||
chats.refresh();
|
chats.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось включить отслеживание");
|
toasts.error(t("track.failed"));
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -49,9 +52,9 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await enqueueBackfill(chatId, true, true);
|
await enqueueBackfill(chatId, true, true);
|
||||||
toasts.success("Полный бэкфилл запущен");
|
started.show(true);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось запустить бэкфилл");
|
toasts.error(t("jobs.backfillFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -59,26 +62,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="track">
|
<div class="track">
|
||||||
<div class="title">Здесь пока нет сообщений</div>
|
<div class="title">{t("track.empty")}</div>
|
||||||
<p class="description">
|
<p class="description">
|
||||||
{#if tracked}
|
{tracked ? t("track.tracked", { title }) : t("track.hint")}
|
||||||
{title}
|
|
||||||
отслеживается — новые сообщения и статистика собираются автоматически.
|
|
||||||
{:else}
|
|
||||||
Включите отслеживание, чтобы собирать сообщения, медиа и статистику по
|
|
||||||
этому чату.
|
|
||||||
{/if}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if tracked}
|
{#if tracked}
|
||||||
<Button loading={busy} onclick={backfill} pill variant="secondary">
|
<Button loading={busy} onclick={backfill} pill variant="secondary">
|
||||||
<Icon name="cloud-download" />
|
<Icon name={started.current ? "check" : "cloud-download"} />
|
||||||
<span>Загрузить историю</span>
|
<span
|
||||||
|
>{started.current ? t("track.loading") : t("track.loadHistory")}</span
|
||||||
|
>
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button loading={busy} onclick={track} pill variant="primary">
|
<Button loading={busy} onclick={track} pill variant="primary">
|
||||||
<Icon name="stats" />
|
<Icon name="stats" />
|
||||||
<span>Начать отслеживать</span>
|
<span>{t("track.start")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,12 +16,14 @@
|
|||||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||||
import {
|
import {
|
||||||
alertAvatar,
|
alertAvatar,
|
||||||
|
alertHref,
|
||||||
alertLabel,
|
alertLabel,
|
||||||
alertPreview,
|
alertPreview,
|
||||||
alertTitle,
|
alertTitle,
|
||||||
EVENT_ICONS,
|
EVENT_ICONS,
|
||||||
} from "#lib/format/watches.js";
|
} from "#lib/format/watches.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { replaceParam } from "#lib/params.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
import { ALERTS_PARAM, alerts } from "#lib/stores/alerts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -48,15 +50,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function writeParam(value: Filter | null) {
|
function writeParam(value: Filter | null) {
|
||||||
const url = new URL(page.url.href);
|
replaceParam(ALERTS_PARAM, value);
|
||||||
if (value === null) {
|
|
||||||
url.searchParams.delete(ALERTS_PARAM);
|
|
||||||
} else {
|
|
||||||
url.searchParams.set(ALERTS_PARAM, value);
|
|
||||||
}
|
|
||||||
if (url.href !== page.url.href) {
|
|
||||||
goto(url, { replace: true, reset: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(next: Filter) {
|
function setFilter(next: Filter) {
|
||||||
@@ -135,10 +129,10 @@
|
|||||||
|
|
||||||
async function openAlert(alert: AlertItem) {
|
async function openAlert(alert: AlertItem) {
|
||||||
seen(alert);
|
seen(alert);
|
||||||
await goto(alert.path);
|
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
ui.closePanel();
|
ui.resetPanel();
|
||||||
}
|
}
|
||||||
|
await goto(alertHref(alert));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seeAll() {
|
async function seeAll() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import Button from "../ui/Button.svelte";
|
import Button from "../ui/Button.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
const {
|
const {
|
||||||
initial = "",
|
initial = "",
|
||||||
saving = false,
|
saving = false,
|
||||||
submitLabel = "Добавить заметку",
|
submitLabel,
|
||||||
oncancel,
|
oncancel,
|
||||||
onsubmit,
|
onsubmit,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -41,15 +42,19 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Заметка к сообщению…"
|
placeholder={t("notes.placeholder")}
|
||||||
rows={oncancel ? 2 : 3}
|
rows={oncancel ? 2 : 3}
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if oncancel}
|
{#if oncancel}
|
||||||
<Button onclick={oncancel} variant="translucent">Отмена</Button>
|
<Button onclick={oncancel} variant="translucent">
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button disabled={saving || !valid} type="submit">{submitLabel}</Button>
|
<Button disabled={saving || !valid} type="submit">
|
||||||
|
{submitLabel ?? t("notes.add")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Annotation } from "#lib/api/types.js";
|
import type { Annotation } from "#lib/api/types.js";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import Icon from "../ui/Icon.svelte";
|
import Icon from "../ui/Icon.svelte";
|
||||||
import AnnotationEditor from "./AnnotationEditor.svelte";
|
import AnnotationEditor from "./AnnotationEditor.svelte";
|
||||||
|
import NoteText from "./NoteText.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
@@ -28,14 +30,14 @@
|
|||||||
onupdate(annotation, text);
|
onupdate(annotation, text);
|
||||||
editingId = null;
|
editingId = null;
|
||||||
}}
|
}}
|
||||||
submitLabel="Сохранить"
|
submitLabel={t("common.save")}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text">{annotation.text}</p>
|
<NoteText text={annotation.text} />
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="time">{formatFull(annotation.updated_at)}</span>
|
<span class="time">{formatFull(annotation.updated_at)}</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Изменить"
|
aria-label={t("notes.edit")}
|
||||||
class="action"
|
class="action"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
editingId = annotation.id;
|
editingId = annotation.id;
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
<Icon name="edit" size="1rem" />
|
<Icon name="edit" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Удалить"
|
aria-label={t("common.delete")}
|
||||||
class="action"
|
class="action"
|
||||||
onclick={() => ondelete(annotation)}
|
onclick={() => ondelete(annotation)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -74,12 +76,6 @@
|
|||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
import type { Annotation } from "#lib/api/types.js";
|
import type { Annotation } from "#lib/api/types.js";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { notes } from "#lib/stores/notes.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import AnnotationEditor from "./AnnotationEditor.svelte";
|
import AnnotationEditor from "./AnnotationEditor.svelte";
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
items = rows;
|
items = rows;
|
||||||
|
notes.mark(cid, mid, rows.length > 0);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
try {
|
try {
|
||||||
const created = await createAnnotation(chatId, messageId, text);
|
const created = await createAnnotation(chatId, messageId, text);
|
||||||
items = [created, ...items];
|
items = [created, ...items];
|
||||||
|
notes.mark(chatId, messageId, true);
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -71,11 +75,12 @@
|
|||||||
async function remove(annotation: Annotation) {
|
async function remove(annotation: Annotation) {
|
||||||
await deleteAnnotation(annotation.id);
|
await deleteAnnotation(annotation.id);
|
||||||
items = items.filter((item) => item.id !== annotation.id);
|
items = items.filter((item) => item.id !== annotation.id);
|
||||||
|
notes.mark(annotation.chat_id, annotation.message_id, items.length > 0);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null || messageId === null}
|
{#if chatId === null || messageId === null}
|
||||||
<EmptyState description="Сообщение не выбрано" title="Заметки" />
|
<EmptyState description={t("ui.noMessage")} title={t("notes.title")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<AnnotationEditor onsubmit={add} {saving} />
|
<AnnotationEditor onsubmit={add} {saving} />
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { noteParts, notes } from "#lib/stores/notes.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
interactive?: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { interactive = true, text }: Props = $props();
|
||||||
|
|
||||||
|
const parts = $derived(noteParts(text));
|
||||||
|
|
||||||
|
function openTag(event: MouseEvent, tag: string) {
|
||||||
|
event.stopPropagation();
|
||||||
|
notes.openTag(tag);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="note-text">
|
||||||
|
{#each parts as part, index (index)}
|
||||||
|
{#if "tag" in part && interactive}
|
||||||
|
<button
|
||||||
|
class="tag"
|
||||||
|
onclick={(event) => openTag(event, part.tag)}
|
||||||
|
type="button"
|
||||||
|
class:active={notes.tag === part.tag}
|
||||||
|
>
|
||||||
|
{part.label}
|
||||||
|
</button>
|
||||||
|
{:else if "tag" in part}
|
||||||
|
<span class="tag" class:active={notes.tag === part.tag}
|
||||||
|
>{part.label}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{part.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.note-text {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline;
|
||||||
|
|
||||||
|
padding: 0 0.3125rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:is(button):hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 20%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(button) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from "svelte";
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
import {
|
||||||
|
listNotes,
|
||||||
|
listNoteTags,
|
||||||
|
type Note,
|
||||||
|
type NoteTag,
|
||||||
|
} from "#lib/api/notes.js";
|
||||||
|
import NoteText from "#lib/components/annotations/NoteText.svelte";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { replaceParam } from "#lib/params.js";
|
||||||
|
import { NOTES_PARAM, notes } from "#lib/stores/notes.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { isMobile } from "#lib/viewport.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
let items = $state<Note[]>([]);
|
||||||
|
let tags = $state<NoteTag[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let hasMore = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
let token = 0;
|
||||||
|
|
||||||
|
const chips = $derived.by(() => {
|
||||||
|
const current = notes.tag;
|
||||||
|
if (current === null || tags.some((item) => item.tag === current)) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
return [{ count: 0, tag: current }, ...tags];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(tag: string | null, offset = 0) {
|
||||||
|
token += 1;
|
||||||
|
const mine = token;
|
||||||
|
if (offset === 0) {
|
||||||
|
loading = true;
|
||||||
|
} else {
|
||||||
|
loadingMore = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const page = await listNotes(tag, offset, PAGE);
|
||||||
|
if (mine !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items = offset === 0 ? page : [...items, ...page];
|
||||||
|
hasMore = page.length === PAGE;
|
||||||
|
failed = false;
|
||||||
|
} catch {
|
||||||
|
if (mine === token) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mine === token) {
|
||||||
|
loading = false;
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { tag } = notes;
|
||||||
|
untrack(() => {
|
||||||
|
replaceParam(NOTES_PARAM, tag ?? "");
|
||||||
|
load(tag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
listNoteTags()
|
||||||
|
.then((result) => {
|
||||||
|
tags = result;
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
notes.tag = null;
|
||||||
|
replaceParam(NOTES_PARAM, null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(tag: string | null) {
|
||||||
|
notes.tag = notes.tag === tag ? null : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function href(note: Note): string {
|
||||||
|
return `/app/${note.chat_id}?message=${note.message_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(event: MouseEvent, note: Note) {
|
||||||
|
if (
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.button !== 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
ui.requestJump(note.chat_id, note.message_id);
|
||||||
|
if (isMobile()) {
|
||||||
|
ui.resetPanel();
|
||||||
|
goto(href(note));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tag = notes.tag === null ? "" : encodeURIComponent(notes.tag);
|
||||||
|
goto(`${href(note)}&${NOTES_PARAM}=${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatTitle(note: Note): string {
|
||||||
|
return note.chat_title ?? t("chat.fallbackTitle", { id: note.chat_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function quote(note: Note): string {
|
||||||
|
if (note.message_date === null) {
|
||||||
|
return t("notes.messageGone");
|
||||||
|
}
|
||||||
|
return note.message_text ?? t("notes.mediaMessage");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
{#if chips.length > 0}
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
aria-pressed={notes.tag === null}
|
||||||
|
class="chip"
|
||||||
|
onclick={() => pick(null)}
|
||||||
|
type="button"
|
||||||
|
class:on={notes.tag === null}
|
||||||
|
>
|
||||||
|
{t("notes.all")}
|
||||||
|
</button>
|
||||||
|
{#each chips as item (item.tag)}
|
||||||
|
<button
|
||||||
|
aria-pressed={notes.tag === item.tag}
|
||||||
|
class="chip"
|
||||||
|
onclick={() => pick(item.tag)}
|
||||||
|
type="button"
|
||||||
|
class:on={notes.tag === item.tag}
|
||||||
|
>
|
||||||
|
#{item.tag}
|
||||||
|
{#if item.count > 0}
|
||||||
|
<span class="value">{item.count}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading && items.length === 0}
|
||||||
|
<div class="center"><Spinner /></div>
|
||||||
|
{:else if failed && items.length === 0}
|
||||||
|
<div class="state">
|
||||||
|
<p>{t("notes.failed")}</p>
|
||||||
|
<Button onclick={() => load(notes.tag)} variant="text">
|
||||||
|
{t("notes.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
description={notes.tag === null ? t("notes.empty") : undefined}
|
||||||
|
title={notes.tag === null
|
||||||
|
? t("notes.emptyTitle")
|
||||||
|
: t("notes.emptyTag", { tag: notes.tag })}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ul class="list">
|
||||||
|
{#each items as note (note.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="row"
|
||||||
|
href={href(note)}
|
||||||
|
onclick={(event) => open(event, note)}
|
||||||
|
use:ripple
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
avatar={{
|
||||||
|
id: note.chat_id,
|
||||||
|
kind: note.chat_id > 0 ? "peer" : "chat",
|
||||||
|
}}
|
||||||
|
colorKey={note.chat_id}
|
||||||
|
hasAvatar={note.chat_has_avatar}
|
||||||
|
name={chatTitle(note)}
|
||||||
|
size={2.75}
|
||||||
|
/>
|
||||||
|
<div class="body">
|
||||||
|
<div class="top">
|
||||||
|
<span class="title">{chatTitle(note)}</span>
|
||||||
|
<span class="date">{formatListDate(note.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text">
|
||||||
|
<NoteText interactive={false} text={note.text} />
|
||||||
|
</div>
|
||||||
|
<div class="quote" class:deleted={note.message_deleted}>
|
||||||
|
{quote(note)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hasMore}
|
||||||
|
<div class="more">
|
||||||
|
<Button
|
||||||
|
loading={loadingMore}
|
||||||
|
onclick={() => load(notes.tag, items.length)}
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
{t("notes.more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.notes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0 0.5625rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease-out,
|
||||||
|
box-shadow 0.15s ease-out;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 12%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
--ripple-color: var(--color-interactive-element-hover);
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
padding-inline-start: 0.5rem;
|
||||||
|
border-inline-start: 2px solid var(--color-primary);
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.deleted {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
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 { 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";
|
||||||
@@ -17,14 +19,19 @@
|
|||||||
let token = $state("");
|
let token = $state("");
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const STATUS_KEYS: Record<string, MessageKey> = {
|
||||||
error: "ошибка",
|
error: "bots.status.error",
|
||||||
ok: "слушает",
|
ok: "bots.status.ok",
|
||||||
pending: "подключается",
|
pending: "bots.status.pending",
|
||||||
removed: "отключён",
|
removed: "bots.status.removed",
|
||||||
unauthorized: "токен отклонён",
|
unauthorized: "bots.status.unauthorized",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const key = STATUS_KEYS[status];
|
||||||
|
return key ? t(key) : status;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +56,7 @@
|
|||||||
await load();
|
await load();
|
||||||
await accounts.load();
|
await accounts.load();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не похоже на токен бота");
|
toasts.error(t("bots.badToken"));
|
||||||
} finally {
|
} finally {
|
||||||
adding = false;
|
adding = false;
|
||||||
}
|
}
|
||||||
@@ -81,7 +88,7 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<Spinner />
|
<Spinner />
|
||||||
{:else if bots.length === 0}
|
{:else if bots.length === 0}
|
||||||
<p class="empty">Ботов пока нет.</p>
|
<p class="empty">{t("bots.empty")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="bots">
|
<ul class="bots">
|
||||||
{#each bots as bot (bot.bot_id)}
|
{#each bots as bot (bot.bot_id)}
|
||||||
@@ -94,9 +101,9 @@
|
|||||||
>
|
>
|
||||||
<span class="name">{botName(bot)}</span>
|
<span class="name">{botName(bot)}</span>
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
{statusLabels[bot.status] ?? bot.status}
|
{statusLabel(bot.status)}
|
||||||
{#if bot.account_id !== null}
|
{#if bot.account_id !== null}
|
||||||
· аккаунт {bot.account_id}
|
· {t("bots.account", { id: bot.account_id })}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if bot.status_detail}
|
{#if bot.status_detail}
|
||||||
@@ -105,7 +112,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if bot.enabled}
|
{#if bot.enabled}
|
||||||
<Button
|
<Button
|
||||||
aria-label="Отключить"
|
aria-label={t("bots.disconnect")}
|
||||||
onclick={() => remove(bot)}
|
onclick={() => remove(bot)}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -121,25 +128,14 @@
|
|||||||
|
|
||||||
<div class="add">
|
<div class="add">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Токен бота от @BotFather</span>
|
<span>{t("bots.token")}</span>
|
||||||
<input placeholder="123456:AA…" type="password" bind:value={token}>
|
<input placeholder="123456:AA…" type="password" bind:value={token}>
|
||||||
</label>
|
</label>
|
||||||
<Button fluid loading={adding} onclick={add}>Добавить</Button>
|
<Button fluid loading={adding} onclick={add}>{t("bots.add")}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">{t("bots.login")}</p>
|
||||||
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у
|
<p class="note">{t("bots.history")}</p>
|
||||||
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
|
|
||||||
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
|
|
||||||
групп видны там же, где переписка остальных аккаунтов.
|
|
||||||
</p>
|
|
||||||
<p class="note">
|
|
||||||
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
|
|
||||||
держит для бота; история диалогов боту запрещена, глубже этой очереди
|
|
||||||
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
|
|
||||||
— только при выключенном privacy mode. Отключение убирает токен и сессию,
|
|
||||||
архив остаётся.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
import { filesLabel } from "#lib/format/storage.js";
|
import { filesLabel } from "#lib/format/storage.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -28,12 +29,12 @@
|
|||||||
let confirming = $state(false);
|
let confirming = $state(false);
|
||||||
let moving = $state(false);
|
let moving = $state(false);
|
||||||
let moved = $state<TrashOutcome | null>(null);
|
let moved = $state<TrashOutcome | null>(null);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<MessageKey | null>(null);
|
||||||
|
|
||||||
function where(scope: StorageScope): string {
|
function where(scope: StorageScope): string {
|
||||||
return "folderId" in target
|
return "folderId" in target
|
||||||
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
? t("storage.inFolder", { count: scope.chats })
|
||||||
: "в этом чате";
|
: t("storage.inChat");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
await load();
|
await load();
|
||||||
onchange?.();
|
onchange?.();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось переместить файлы в корзину";
|
problem = "storage.scope.trashFailed";
|
||||||
} finally {
|
} finally {
|
||||||
moving = false;
|
moving = false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
await load();
|
await load();
|
||||||
onchange?.();
|
onchange?.();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "storage.restoreFailed";
|
||||||
} finally {
|
} finally {
|
||||||
moving = false;
|
moving = false;
|
||||||
}
|
}
|
||||||
@@ -90,15 +91,15 @@
|
|||||||
{#if moved && moved.files > 0}
|
{#if moved && moved.files > 0}
|
||||||
<div class="files">
|
<div class="files">
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span>{filesLabel(moved.files)} в корзине</span>
|
<span>{t("storage.inTrash", { files: filesLabel(moved.files) })}</span>
|
||||||
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
<small>{t("storage.freed", { size: formatBytes(moved.bytes) })}</small>
|
||||||
{#if problem}
|
{#if problem}
|
||||||
<small class="failed">{problem}</small>
|
<small class="failed">{t(problem)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={moving} onclick={undo} smaller variant="text">
|
<Button disabled={moving} onclick={undo} smaller variant="text">
|
||||||
<Icon name="undo" size="1.25rem" />
|
<Icon name="undo" size="1.25rem" />
|
||||||
<span>Вернуть</span>
|
<span>{t("storage.restore")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if files && files.files > 0}
|
{:else if files && files.files > 0}
|
||||||
@@ -106,22 +107,19 @@
|
|||||||
{#if confirming}
|
{#if confirming}
|
||||||
<div class="text">
|
<div class="text">
|
||||||
<span>
|
<span>
|
||||||
{filesLabel(files.files)}, {formatBytes(files.bytes)},
|
{t("storage.scope.confirm", {
|
||||||
{plural(
|
count: files.files,
|
||||||
files.files,
|
files: filesLabel(files.files),
|
||||||
"уйдёт в корзину и перестанет скачиваться сам",
|
size: formatBytes(files.bytes),
|
||||||
"уйдут в корзину и перестанут скачиваться сами",
|
})}
|
||||||
"уйдут в корзину и перестанут скачиваться сами"
|
|
||||||
)}.
|
|
||||||
</span>
|
</span>
|
||||||
{#if files.gone > 0}
|
{#if files.gone > 0}
|
||||||
<small class="warn">
|
<small class="warn">
|
||||||
{files.gone}
|
{t("storage.scope.gone", { count: files.gone })}
|
||||||
из них потом не скачать заново
|
|
||||||
</small>
|
</small>
|
||||||
{/if}
|
{/if}
|
||||||
{#if problem}
|
{#if problem}
|
||||||
<small class="failed">{problem}</small>
|
<small class="failed">{t(problem)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button
|
<Button
|
||||||
@@ -131,7 +129,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Отмена
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={moving}
|
disabled={moving}
|
||||||
@@ -141,7 +139,7 @@
|
|||||||
smaller
|
smaller
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.25rem" />
|
<Icon name="delete" size="1.25rem" />
|
||||||
<span>В корзину</span>
|
<span>{t("storage.toTrash")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,18 +154,18 @@
|
|||||||
</small>
|
</small>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span>{formatBytes(files.bytes)} уже скачано</span>
|
<span>
|
||||||
|
{t("storage.scope.downloaded", { size: formatBytes(files.bytes) })}
|
||||||
|
</span>
|
||||||
<small>{filesLabel(files.files)} {where(files)}</small>
|
<small>{filesLabel(files.files)} {where(files)}</small>
|
||||||
{/if}
|
{/if}
|
||||||
{#if files.kept > 0}
|
{#if files.kept > 0}
|
||||||
<small>
|
<small>
|
||||||
Ещё {filesLabel(files.kept)} есть и в других чатах,
|
{t("storage.scope.kept", {
|
||||||
{plural(
|
count: files.kept,
|
||||||
files.kept,
|
files: filesLabel(files.kept),
|
||||||
"он останется",
|
n: files.kept.toLocaleString(locale.tag),
|
||||||
"они останутся",
|
})}
|
||||||
"они останутся"
|
|
||||||
)}
|
|
||||||
</small>
|
</small>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +178,7 @@
|
|||||||
variant="gray"
|
variant="gray"
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.25rem" />
|
<Icon name="delete" size="1.25rem" />
|
||||||
<span>В корзину</span>
|
<span>{t("storage.toTrash")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,10 +187,10 @@
|
|||||||
<div class="text">
|
<div class="text">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<small>
|
<small>
|
||||||
{filesLabel(files.kept)}
|
{t("storage.scope.keptOnly", {
|
||||||
есть и в других чатах, убрать
|
count: files.kept,
|
||||||
{plural(files.kept, "его", "их", "их")}
|
files: filesLabel(files.kept),
|
||||||
можно из списка ниже
|
})}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js";
|
import { filesLabel, groupLabel } from "#lib/format/storage.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chat: StorageChat;
|
chat: StorageChat;
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
|
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
|
||||||
const makeup = $derived(
|
const makeup = $derived(
|
||||||
parts
|
parts
|
||||||
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
|
.map((item) => `${groupLabel(item.group)}: ${formatBytes(item.bytes)}`)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,9 +31,11 @@
|
|||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import {
|
import {
|
||||||
filesLabel,
|
filesLabel,
|
||||||
GROUP_LABELS,
|
groupLabel,
|
||||||
messagesLabel,
|
messagesLabel,
|
||||||
} from "#lib/format/storage.js";
|
} from "#lib/format/storage.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.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 {
|
import {
|
||||||
@@ -53,9 +55,9 @@
|
|||||||
const PAGE = 30;
|
const PAGE = 30;
|
||||||
const UNDO_MS = 12_000;
|
const UNDO_MS = 12_000;
|
||||||
const UNDO_GUARD_MS = 600;
|
const UNDO_GUARD_MS = 600;
|
||||||
const LISTINGS: { label: string; value: StorageListing }[] = [
|
const LISTINGS: { label: MessageKey; value: StorageListing }[] = [
|
||||||
{ label: "Файлы", value: "files" },
|
{ label: "storage.listing.files", value: "files" },
|
||||||
{ label: "Чаты", value: "chats" },
|
{ label: "storage.listing.chats", value: "chats" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let summary = $state<StorageSummary | null>(null);
|
let summary = $state<StorageSummary | null>(null);
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
|
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<MessageKey | null>(null);
|
||||||
let viewerOpen = $state(false);
|
let viewerOpen = $state(false);
|
||||||
let viewerIndex = $state(0);
|
let viewerIndex = $state(0);
|
||||||
const selected = new SvelteSet<number>();
|
const selected = new SvelteSet<number>();
|
||||||
@@ -89,12 +91,11 @@
|
|||||||
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
|
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
|
||||||
const emptiness = $derived.by(() => {
|
const emptiness = $derived.by(() => {
|
||||||
if (group !== null) {
|
if (group !== null) {
|
||||||
return "В этой группе нет файлов.";
|
return t("storage.empty.group");
|
||||||
}
|
}
|
||||||
return chatId === null
|
return chatId === null ? t("storage.empty.all") : t("storage.empty.chat");
|
||||||
? "На диске нет скачанных файлов."
|
|
||||||
: "В этом чате нет скачанных файлов.";
|
|
||||||
});
|
});
|
||||||
|
const failure = $derived(problem && t(problem));
|
||||||
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
|
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
|
||||||
const chosenBytes = $derived(
|
const chosenBytes = $derived(
|
||||||
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function chatTitle(id: number): string {
|
function chatTitle(id: number): string {
|
||||||
return chats.byId(id)?.title ?? `Чат ${id}`;
|
return chats.byId(id)?.title ?? t("ui.chat", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
@@ -249,11 +250,11 @@
|
|||||||
scope?.refresh();
|
scope?.refresh();
|
||||||
showUndo(outcome, trashed);
|
showUndo(outcome, trashed);
|
||||||
if (outcome.blocked.length > 0) {
|
if (outcome.blocked.length > 0) {
|
||||||
problem = "Файл занят в сторис или аватаре и остался на диске";
|
problem = "storage.blocked";
|
||||||
}
|
}
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось переместить в корзину";
|
problem = "storage.trashFailed";
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -273,7 +274,7 @@
|
|||||||
scope?.refresh();
|
scope?.refresh();
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "storage.restoreFailed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadMedia(file.media_id);
|
await downloadMedia(file.media_id);
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось скачать файл";
|
problem = "ui.downloadFailed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,22 +331,26 @@
|
|||||||
{#if summary.files > 0 || chatId !== null}
|
{#if summary.files > 0 || chatId !== null}
|
||||||
<section class="summary">
|
<section class="summary">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
|
<span class="total">
|
||||||
|
{formatBytes(summary.bytes) || t("storage.zero")}
|
||||||
|
</span>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
{filesLabel(summary.files)}
|
|
||||||
{#if chatId !== null}
|
{#if chatId !== null}
|
||||||
в этом чате
|
{t("storage.hero.chat", { files: filesLabel(summary.files) })}
|
||||||
{:else if summary.trash_bytes > 0}
|
{:else if summary.trash_bytes > 0}
|
||||||
· ещё {formatBytes(summary.trash_bytes)} в корзине
|
{t("storage.hero.trash", {
|
||||||
|
files: filesLabel(summary.files),
|
||||||
|
size: formatBytes(summary.trash_bytes),
|
||||||
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
на диске
|
{t("storage.hero.disk", { files: filesLabel(summary.files) })}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack" class:bare={groups.length === 0}>
|
<div class="stack" class:bare={groups.length === 0}>
|
||||||
{#each groups as item (item.group)}
|
{#each groups as item (item.group)}
|
||||||
<span
|
<span
|
||||||
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
|
title="{groupLabel(item.group)}: {formatBytes(item.bytes)}"
|
||||||
style:background-color="var(--color-storage-{item.group})"
|
style:background-color="var(--color-storage-{item.group})"
|
||||||
style:flex-grow={item.bytes}
|
style:flex-grow={item.bytes}
|
||||||
></span>
|
></span>
|
||||||
@@ -353,7 +358,9 @@
|
|||||||
{#if chatId === null && summary.trash_bytes > 0}
|
{#if chatId === null && summary.trash_bytes > 0}
|
||||||
<span
|
<span
|
||||||
class="trashed"
|
class="trashed"
|
||||||
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
title={t("storage.trash.title", {
|
||||||
|
size: formatBytes(summary.trash_bytes),
|
||||||
|
})}
|
||||||
style:flex-grow={summary.trash_bytes}
|
style:flex-grow={summary.trash_bytes}
|
||||||
></span>
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -371,7 +378,7 @@
|
|||||||
class="dot"
|
class="dot"
|
||||||
style:background-color="var(--color-storage-{item.group})"
|
style:background-color="var(--color-storage-{item.group})"
|
||||||
></span>
|
></span>
|
||||||
{GROUP_LABELS[item.group]}
|
{groupLabel(item.group)}
|
||||||
<span class="value">{formatBytes(item.bytes)}</span>
|
<span class="value">{formatBytes(item.bytes)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -386,14 +393,14 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.375rem" />
|
<Icon name="delete" size="1.375rem" />
|
||||||
<span class="label">Корзина</span>
|
<span class="label">{t("storage.trash")}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
{#if summary.trash_files > 0}
|
{#if summary.trash_files > 0}
|
||||||
<span class="swatch"></span>
|
<span class="swatch"></span>
|
||||||
{filesLabel(summary.trash_files)}
|
{filesLabel(summary.trash_files)}
|
||||||
· {formatBytes(summary.trash_bytes)}
|
· {formatBytes(summary.trash_bytes)}
|
||||||
{:else}
|
{:else}
|
||||||
пусто
|
{t("storage.trash.nothing")}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="next" size="1.125rem" />
|
<Icon name="next" size="1.125rem" />
|
||||||
@@ -407,13 +414,13 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:on={listing === item.value}
|
class:on={listing === item.value}
|
||||||
>
|
>
|
||||||
{item.label}
|
{t(item.label)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ScopeFiles
|
<ScopeFiles
|
||||||
label="Все файлы чата"
|
label={t("storage.chatFiles")}
|
||||||
onchange={() => {
|
onchange={() => {
|
||||||
hideUndo();
|
hideUndo();
|
||||||
reload();
|
reload();
|
||||||
@@ -426,12 +433,14 @@
|
|||||||
|
|
||||||
{#if listing === "files" && files.length > 0}
|
{#if listing === "files" && files.length > 0}
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>Самые тяжёлые</h3>
|
<h3>{t("storage.heaviest")}</h3>
|
||||||
{#if summary && group === null && summary.heavy_files > 0}
|
{#if summary && group === null && summary.heavy_files > 0}
|
||||||
<span>
|
<span>
|
||||||
{filesLabel(summary.heavy_files)}
|
{t("storage.heavy", {
|
||||||
крупнее {formatBytes(summary.heavy_threshold)}
|
files: filesLabel(summary.heavy_files),
|
||||||
= {formatBytes(summary.heavy_bytes)}
|
size: formatBytes(summary.heavy_bytes),
|
||||||
|
threshold: formatBytes(summary.heavy_threshold),
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -448,17 +457,17 @@
|
|||||||
>
|
>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
|
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
|
||||||
Файлы чата
|
{t("storage.menu.chatFiles")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
|
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
|
||||||
Перейти в чат
|
{t("storage.menu.openChat")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="settings"
|
icon="settings"
|
||||||
onselect={() =>
|
onselect={() =>
|
||||||
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
|
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
|
||||||
>
|
>
|
||||||
Политика захвата
|
{t("storage.menu.policy")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</StorageChatRow>
|
</StorageChatRow>
|
||||||
@@ -484,19 +493,19 @@
|
|||||||
>
|
>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
||||||
Открыть
|
{t("ui.open")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
|
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
|
||||||
Перейти к сообщению
|
{t("ui.jumpToMessage")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="download" onselect={() => save(file)}>
|
<ContextMenuItem icon="download" onselect={() => save(file)}>
|
||||||
Скачать файл
|
{t("ui.downloadFile")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="delete"
|
icon="delete"
|
||||||
onselect={() => trash([file.media_id])}
|
onselect={() => trash([file.media_id])}
|
||||||
>
|
>
|
||||||
В корзину
|
{t("storage.toTrash")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</StorageRow>
|
</StorageRow>
|
||||||
@@ -506,29 +515,31 @@
|
|||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>Не удалось загрузить хранилище.</p>
|
<p>{t("storage.loadFailed")}</p>
|
||||||
<Button onclick={() => reload()} smaller variant="text">
|
<Button onclick={() => reload()} smaller variant="text">
|
||||||
Повторить
|
{t("ui.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if shown === 0}
|
{:else if shown === 0}
|
||||||
<div class="state"><p>{emptiness}</p></div>
|
<div class="state"><p>{emptiness}</p></div>
|
||||||
{:else if !done}
|
{:else if !done}
|
||||||
<button class="more" onclick={() => loadMore()} type="button">
|
<button class="more" onclick={() => loadMore()} type="button">
|
||||||
Показать ещё
|
{t("ui.showMore")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chosen.length > 0}
|
{#if chosen.length > 0}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
failure={problem}
|
{failure}
|
||||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
note={chosenGone > 0
|
||||||
|
? t("storage.cantRefetch", { count: chosenGone })
|
||||||
|
: undefined}
|
||||||
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
|
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
|
||||||
warn
|
warn
|
||||||
>
|
>
|
||||||
<Button onclick={() => selected.clear()} smaller variant="text">
|
<Button onclick={() => selected.clear()} smaller variant="text">
|
||||||
Снять
|
{t("storage.deselect")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -538,22 +549,22 @@
|
|||||||
smaller
|
smaller
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.25rem" />
|
<Icon name="delete" size="1.25rem" />
|
||||||
<span>В корзину</span>
|
<span>{t("storage.toTrash")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{:else if undo}
|
{:else if undo}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
failure={problem}
|
{failure}
|
||||||
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
|
note={t("storage.freed", { size: formatBytes(undo.outcome.bytes) })}
|
||||||
title="{filesLabel(undo.outcome.files)} в корзине"
|
title={t("storage.inTrash", { files: filesLabel(undo.outcome.files) })}
|
||||||
>
|
>
|
||||||
<Button onclick={restoreLast} smaller variant="text">
|
<Button onclick={restoreLast} smaller variant="text">
|
||||||
<Icon name="undo" size="1.25rem" />
|
<Icon name="undo" size="1.25rem" />
|
||||||
<span>Вернуть</span>
|
<span>{t("storage.restore")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{:else if problem}
|
{:else if failure}
|
||||||
<SelectionBar failure={problem} title="Не получилось">
|
<SelectionBar {failure} title={t("storage.failed")}>
|
||||||
<Button
|
<Button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
problem = null;
|
problem = null;
|
||||||
@@ -561,7 +572,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Понятно
|
{t("ui.gotIt")}
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import { groupOf } from "#lib/format/storage.js";
|
import { groupOf } from "#lib/format/storage.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
>
|
>
|
||||||
<span class="check">
|
<span class="check">
|
||||||
<input
|
<input
|
||||||
aria-label="Выбрать {fileName}"
|
aria-label={t("storage.select", { name: fileName })}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onchange={ontoggle}
|
onchange={ontoggle}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
<span class="note">{note}</span>
|
<span class="note">{note}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !refetchable}
|
{#if !refetchable}
|
||||||
<span class="gone">Не скачать заново</span>
|
<span class="gone">{t("storage.gone")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="size">{formatBytes(size)}</span>
|
<span class="size">{formatBytes(size)}</span>
|
||||||
|
|||||||
@@ -19,12 +19,13 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
import {
|
import {
|
||||||
filesLabel,
|
filesLabel,
|
||||||
messagesLabel,
|
messagesLabel,
|
||||||
purgeLabel,
|
purgeLabel,
|
||||||
} from "#lib/format/storage.js";
|
} from "#lib/format/storage.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { isMobile } from "#lib/viewport.js";
|
import { isMobile } from "#lib/viewport.js";
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let doomed = $state<TrashItem[] | null>(null);
|
let doomed = $state<TrashItem[] | null>(null);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<MessageKey | null>(null);
|
||||||
let viewerOpen = $state(false);
|
let viewerOpen = $state(false);
|
||||||
let viewerIndex = $state(0);
|
let viewerIndex = $state(0);
|
||||||
const selected = new SvelteSet<string>();
|
const selected = new SvelteSet<string>();
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
const doomedGone = $derived(
|
const doomedGone = $derived(
|
||||||
doomed?.filter((item) => !item.refetchable).length ?? 0
|
doomed?.filter((item) => !item.refetchable).length ?? 0
|
||||||
);
|
);
|
||||||
|
const failure = $derived(problem && t(problem));
|
||||||
const viewerItems = $derived<ViewerItem[]>(
|
const viewerItems = $derived<ViewerItem[]>(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
|
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chatTitle(chatId: number): string {
|
function chatTitle(chatId: number): string {
|
||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
return chats.byId(chatId)?.title ?? t("ui.chat", { id: chatId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTotal() {
|
async function loadTotal() {
|
||||||
@@ -132,7 +134,7 @@
|
|||||||
await restoreFromTrash(keys);
|
await restoreFromTrash(keys);
|
||||||
drop(keys);
|
drop(keys);
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "storage.restoreFailed";
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@
|
|||||||
await restoreFromTrash(null);
|
await restoreFromTrash(null);
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "storage.restoreFailed";
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,7 @@
|
|||||||
everything = false;
|
everything = false;
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось удалить файлы";
|
problem = "storage.trash.deleteFailed";
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -190,15 +192,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="trash">
|
<div class="trash">
|
||||||
<p class="note">
|
<p class="note">{t("storage.trash.note")}</p>
|
||||||
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
|
|
||||||
вернуть.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if total.files > 0}
|
{#if total.files > 0}
|
||||||
<button class="act" disabled={busy} onclick={restoreAll} type="button">
|
<button class="act" disabled={busy} onclick={restoreAll} type="button">
|
||||||
<Icon name="undo" size="1.375rem" />
|
<Icon name="undo" size="1.375rem" />
|
||||||
<span class="label">Вернуть всё</span>
|
<span class="label">{t("storage.trash.restoreAll")}</span>
|
||||||
<span class="value">{filesLabel(total.files)}</span>
|
<span class="value">{filesLabel(total.files)}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -209,7 +208,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.375rem" />
|
<Icon name="delete" size="1.375rem" />
|
||||||
<span class="label">Очистить корзину</span>
|
<span class="label">{t("storage.trash.clear")}</span>
|
||||||
<span class="value">{formatBytes(total.bytes)}</span>
|
<span class="value">{formatBytes(total.bytes)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -233,13 +232,13 @@
|
|||||||
>
|
>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
||||||
Открыть
|
{t("ui.open")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
|
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
|
||||||
Вернуть
|
{t("storage.restore")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
|
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
|
||||||
Перейти к сообщению
|
{t("ui.jumpToMessage")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon="delete"
|
icon="delete"
|
||||||
@@ -247,7 +246,7 @@
|
|||||||
doomed = [item];
|
doomed = [item];
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Удалить насовсем
|
{t("storage.trash.deleteForever")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</StorageRow>
|
</StorageRow>
|
||||||
@@ -257,22 +256,24 @@
|
|||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>Не удалось загрузить корзину.</p>
|
<p>{t("storage.trash.loadFailed")}</p>
|
||||||
<Button onclick={load} smaller variant="text">Повторить</Button>
|
<Button onclick={load} smaller variant="text">{t("ui.retry")}</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<div class="state"><p>Корзина пуста.</p></div>
|
<div class="state"><p>{t("storage.trash.empty")}</p></div>
|
||||||
{:else if !done}
|
{:else if !done}
|
||||||
<button class="more" onclick={loadMore} type="button">
|
<button class="more" onclick={loadMore} type="button">
|
||||||
Показать ещё
|
{t("ui.showMore")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chosen.length > 0}
|
{#if chosen.length > 0}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
failure={dialogOpen ? null : problem}
|
failure={dialogOpen ? null : failure}
|
||||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
note={chosenGone > 0
|
||||||
|
? t("storage.cantRefetch", { count: chosenGone })
|
||||||
|
: undefined}
|
||||||
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
||||||
warn
|
warn
|
||||||
>
|
>
|
||||||
@@ -284,7 +285,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Удалить
|
{t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -294,11 +295,11 @@
|
|||||||
smaller
|
smaller
|
||||||
>
|
>
|
||||||
<Icon name="undo" size="1.25rem" />
|
<Icon name="undo" size="1.25rem" />
|
||||||
<span>Вернуть</span>
|
<span>{t("storage.restore")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{:else if problem && !dialogOpen}
|
{:else if failure && !dialogOpen}
|
||||||
<SelectionBar failure={problem} title="Не получилось">
|
<SelectionBar {failure} title={t("storage.failed")}>
|
||||||
<Button
|
<Button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
problem = null;
|
problem = null;
|
||||||
@@ -306,7 +307,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Понятно
|
{t("ui.gotIt")}
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -327,59 +328,53 @@
|
|||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">
|
<Dialog.Title class="dialog-title">
|
||||||
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
|
{everything
|
||||||
|
? t("storage.trash.clearTitle")
|
||||||
|
: t("storage.trash.deleteTitle")}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
{#if everything}
|
{#if everything}
|
||||||
<p class="confirm">
|
<p class="confirm">
|
||||||
{filesLabel(total.files)}, {formatBytes(total.bytes)},
|
{t("storage.trash.willDelete", {
|
||||||
{plural(
|
count: total.files,
|
||||||
total.files,
|
files: filesLabel(total.files),
|
||||||
"будет удалён",
|
size: formatBytes(total.bytes),
|
||||||
"будут удалены",
|
})}
|
||||||
"будут удалены"
|
|
||||||
)}
|
|
||||||
с диска.
|
|
||||||
</p>
|
|
||||||
<p class="confirm rest">
|
|
||||||
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
|
|
||||||
удалённых там сообщений пропадут насовсем.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="confirm rest">{t("storage.trash.clearRest")}</p>
|
||||||
{:else if doomed}
|
{:else if doomed}
|
||||||
<p class="confirm">
|
<p class="confirm">
|
||||||
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
|
{t("storage.trash.willDelete", {
|
||||||
{plural(
|
count: doomed.length,
|
||||||
doomed.length,
|
files: filesLabel(doomed.length),
|
||||||
"будет удалён",
|
size: formatBytes(bytesOf(doomed)),
|
||||||
"будут удалены",
|
})}
|
||||||
"будут удалены"
|
|
||||||
)}
|
|
||||||
с диска.
|
|
||||||
</p>
|
</p>
|
||||||
{#if doomedGone > 0}
|
{#if doomedGone > 0}
|
||||||
<p class="confirm warn">
|
<p class="confirm warn">
|
||||||
<Icon name="warning" size="1.25rem" />
|
<Icon name="warning" size="1.25rem" />
|
||||||
<span>
|
<span>
|
||||||
{filesLabel(doomedGone)}
|
{t("storage.trash.goneWarn", {
|
||||||
скачать заново нельзя: в Telegram
|
count: doomedGone,
|
||||||
{doomedGone === 1 ? "его" : "их"}
|
files: filesLabel(doomedGone),
|
||||||
уже нет.
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if doomedGone < doomed.length}
|
{#if doomedGone < doomed.length}
|
||||||
<p class="confirm rest">
|
<p class="confirm rest">
|
||||||
{doomedGone === 0 ? "Потом их" : "Остальные"}
|
{doomedGone === 0
|
||||||
можно скачать из Telegram заново.
|
? t("storage.trash.laterAll", { count: doomed.length })
|
||||||
|
: t("storage.trash.laterRest")}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if problem}
|
{#if failure}
|
||||||
<p class="confirm failed">{problem}.</p>
|
<p class="confirm failed">{failure}.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
@@ -387,7 +382,9 @@
|
|||||||
{#snippet child({
|
{#snippet child({
|
||||||
props,
|
props,
|
||||||
})}
|
})}
|
||||||
<Button {...props} pill variant="text">Отмена</Button>
|
<Button {...props} pill variant="text">
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
<Button
|
<Button
|
||||||
@@ -397,7 +394,7 @@
|
|||||||
pill
|
pill
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Удалить
|
{t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import type { JobStatus, JobView } from "#lib/api/types.js";
|
import type { JobStatus, JobView } from "#lib/api/types.js";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { locale, 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 { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,25 +17,22 @@
|
|||||||
|
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
const KIND_LABELS: Record<string, string> = {
|
const KIND_KEYS: Record<string, MessageKey> = {
|
||||||
backfill: "Бэкфилл",
|
backfill: "jobs.kind.backfill",
|
||||||
backfill_stories: "Бэкфилл сторис",
|
backfill_stories: "jobs.kind.backfill_stories",
|
||||||
enrich_chat: "Обогащение чата",
|
enrich_chat: "jobs.kind.enrich_chat",
|
||||||
fetch_avatar: "Аватар",
|
fetch_avatar: "jobs.kind.fetch_avatar",
|
||||||
fetch_custom_emoji: "Кастом-эмодзи",
|
fetch_custom_emoji: "jobs.kind.fetch_custom_emoji",
|
||||||
fetch_media: "Докачка медиа",
|
fetch_media: "jobs.kind.fetch_media",
|
||||||
sync_dialogs: "Синхронизация диалогов",
|
fetch_read_info: "jobs.kind.fetch_read_info",
|
||||||
transcribe: "Расшифровка",
|
sync_contacts: "jobs.kind.sync_contacts",
|
||||||
|
sync_dialogs: "jobs.kind.sync_dialogs",
|
||||||
|
transcribe: "jobs.kind.transcribe",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<JobStatus, string> = {
|
function statusLabel(status: JobStatus): string {
|
||||||
canceled: "Отменено",
|
return t(`jobs.status.${status}`);
|
||||||
done: "Готово",
|
}
|
||||||
failed: "Ошибка",
|
|
||||||
paused: "Пауза",
|
|
||||||
pending: "В очереди",
|
|
||||||
running: "Выполняется",
|
|
||||||
};
|
|
||||||
|
|
||||||
let jobs = $state<JobView[]>([]);
|
let jobs = $state<JobView[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
await cancelJob(job.id);
|
await cancelJob(job.id);
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось остановить задачу");
|
toasts.error(t("jobs.stopFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
canceling = null;
|
canceling = null;
|
||||||
}
|
}
|
||||||
@@ -76,16 +76,22 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
jobs = await listJobs();
|
jobs = await listJobs();
|
||||||
|
chats.ensureMany(
|
||||||
|
jobs.map(chatId).filter((id): id is number => id !== null)
|
||||||
|
);
|
||||||
loading = false;
|
loading = false;
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
function kindLabel(job: JobView): string {
|
function kindLabel(job: JobView): string {
|
||||||
const label = KIND_LABELS[job.kind] ?? job.kind;
|
const key = KIND_KEYS[job.kind];
|
||||||
|
const kind = key ? t(key) : job.kind;
|
||||||
if (job.kind !== "backfill") {
|
if (job.kind !== "backfill") {
|
||||||
return label;
|
return kind;
|
||||||
}
|
}
|
||||||
return job.params.full ? `${label} (полный)` : `${label} (новые)`;
|
return job.params.full
|
||||||
|
? t("jobs.kind.full", { kind })
|
||||||
|
: t("jobs.kind.newer", { kind });
|
||||||
}
|
}
|
||||||
|
|
||||||
function processed(job: JobView): number | null {
|
function processed(job: JobView): number | null {
|
||||||
@@ -98,6 +104,10 @@
|
|||||||
return typeof value === "number" ? value : null;
|
return typeof value === "number" ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatLabel(id: number): string {
|
||||||
|
return chats.byId(id)?.title ?? t("jobs.chat", { id });
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (version >= 0 && accounts.selectedId !== null) {
|
if (version >= 0 && accounts.selectedId !== null) {
|
||||||
load().catch(() => {
|
load().catch(() => {
|
||||||
@@ -116,10 +126,12 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if jobs.length === 0}
|
{:else if jobs.length === 0}
|
||||||
<div class="state empty">Задач нет</div>
|
<div class="state empty">{t("jobs.empty")}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="jobs">
|
<div class="jobs">
|
||||||
{#each jobs as job (job.id)}
|
{#each jobs as job (job.id)}
|
||||||
|
{@const chat = chatId(job)}
|
||||||
|
{@const count = processed(job)}
|
||||||
<div class="job">
|
<div class="job">
|
||||||
<div class="job-head">
|
<div class="job-head">
|
||||||
<span class="kind">{kindLabel(job)}</span>
|
<span class="kind">{kindLabel(job)}</span>
|
||||||
@@ -130,20 +142,22 @@
|
|||||||
onclick={() => cancel(job)}
|
onclick={() => cancel(job)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Стоп
|
{t("jobs.stop")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="badge {job.status}">{STATUS_LABELS[job.status]}</span>
|
<span class="badge {job.status}">{statusLabel(job.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if chatId(job) !== null}
|
{#if chat !== null}
|
||||||
<span>чат {chatId(job)}</span>
|
<span>{chatLabel(chat)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if processed(job) !== null}
|
{#if count !== null}
|
||||||
<span>обработано {processed(job)}</span>
|
<span>
|
||||||
|
{t("jobs.processed", { count: count.toLocaleString(locale.tag) })}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if job.flood_waits > 0}
|
{#if job.flood_waits > 0}
|
||||||
<span>flood-wait {job.flood_waits}</span>
|
<span>{t("jobs.floodWaits", { count: job.flood_waits })}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="time">{formatFull(job.created_at)}</span>
|
<span class="time">{formatFull(job.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
import JobList from "#lib/components/jobs/JobList.svelte";
|
import JobList from "#lib/components/jobs/JobList.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
let starting = $state(false);
|
let starting = $state(false);
|
||||||
let syncing = $state(false);
|
let syncing = $state(false);
|
||||||
let syncingContacts = $state(false);
|
let syncingContacts = $state(false);
|
||||||
|
const started = createFlash<"backfill" | "sync" | "contacts">();
|
||||||
|
|
||||||
const picker = createChatPicker();
|
const picker = createChatPicker();
|
||||||
const availableChats = $derived(picker.results);
|
const availableChats = $derived(picker.results);
|
||||||
@@ -39,9 +42,9 @@
|
|||||||
|
|
||||||
function chatTitle(id: number | null): string {
|
function chatTitle(id: number | null): string {
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
return "Чат не выбран";
|
return t("jobs.noChat");
|
||||||
}
|
}
|
||||||
return chats.byId(id)?.title ?? `Чат ${id}`;
|
return chats.byId(id)?.title ?? t("ui.chat", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChat(id: number) {
|
function selectChat(id: number) {
|
||||||
@@ -57,10 +60,10 @@
|
|||||||
starting = true;
|
starting = true;
|
||||||
try {
|
try {
|
||||||
await enqueueBackfill(selected, media, true);
|
await enqueueBackfill(selected, media, true);
|
||||||
toasts.success("Полный бэкфилл запущен");
|
started.show("backfill");
|
||||||
version += 1;
|
version += 1;
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось запустить бэкфилл");
|
toasts.error(t("jobs.backfillFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
starting = false;
|
starting = false;
|
||||||
}
|
}
|
||||||
@@ -73,10 +76,10 @@
|
|||||||
syncing = true;
|
syncing = true;
|
||||||
try {
|
try {
|
||||||
await syncDialogs();
|
await syncDialogs();
|
||||||
toasts.success("Синхронизация диалогов запущена");
|
started.show("sync");
|
||||||
version += 1;
|
version += 1;
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось синхронизировать диалоги");
|
toasts.error(t("jobs.syncFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
syncing = false;
|
syncing = false;
|
||||||
}
|
}
|
||||||
@@ -89,10 +92,10 @@
|
|||||||
syncingContacts = true;
|
syncingContacts = true;
|
||||||
try {
|
try {
|
||||||
await syncContacts();
|
await syncContacts();
|
||||||
toasts.success("Синхронизация контактов запущена");
|
started.show("contacts");
|
||||||
version += 1;
|
version += 1;
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось синхронизировать контакты");
|
toasts.error(t("jobs.contactsFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
syncingContacts = false;
|
syncingContacts = false;
|
||||||
}
|
}
|
||||||
@@ -105,21 +108,20 @@
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">Диалоги</div>
|
<div class="section-title">{t("jobs.dialogs")}</div>
|
||||||
<p class="hint">
|
<p class="hint">{t("jobs.dialogsHint")}</p>
|
||||||
Синхронизация подтягивает все диалоги аккаунта — старые чаты станут видны
|
|
||||||
и доступны для бэкфилла.
|
|
||||||
</p>
|
|
||||||
<div class="action">
|
<div class="action">
|
||||||
<Button fluid loading={syncing} onclick={sync} variant="secondary">
|
<Button fluid loading={syncing} onclick={sync} variant="secondary">
|
||||||
<Icon name="reload" />
|
{#if started.current === "sync"}
|
||||||
<span>Синхронизировать диалоги</span>
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
|
<Icon name="reload" />
|
||||||
|
<span>{t("jobs.syncDialogs")}</span>
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
<p class="hint">{t("jobs.contactsHint")}</p>
|
||||||
Контакты подтягиваются отдельно — тогда в поиске появятся люди, с которыми
|
|
||||||
ещё не было переписки.
|
|
||||||
</p>
|
|
||||||
<div class="action">
|
<div class="action">
|
||||||
<Button
|
<Button
|
||||||
fluid
|
fluid
|
||||||
@@ -127,18 +129,20 @@
|
|||||||
onclick={syncContactList}
|
onclick={syncContactList}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
<Icon name="user" />
|
{#if started.current === "contacts"}
|
||||||
<span>Синхронизировать контакты</span>
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
|
<Icon name="user" />
|
||||||
|
<span>{t("jobs.syncContacts")}</span>
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">Бэкфилл</div>
|
<div class="section-title">{t("jobs.backfill")}</div>
|
||||||
<p class="hint">
|
<p class="hint">{t("jobs.backfillHint")}</p>
|
||||||
Полный бэкфилл перечитывает всю историю чата с самого начала. Кнопка в
|
|
||||||
шапке чата догружает только сообщения новее последнего сохранённого.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="select"
|
class="select"
|
||||||
@@ -155,8 +159,9 @@
|
|||||||
{#if picking}
|
{#if picking}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
<input
|
<input
|
||||||
|
aria-label={t("ui.searchChat")}
|
||||||
class="filter"
|
class="filter"
|
||||||
placeholder="Поиск чата"
|
placeholder={t("ui.searchChat")}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={filter}
|
bind:value={filter}
|
||||||
>
|
>
|
||||||
@@ -166,7 +171,7 @@
|
|||||||
onclick={() => selectChat(chat.chat_id)}
|
onclick={() => selectChat(chat.chat_id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
{chat.title ?? t("ui.chat", { id: chat.chat_id })}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +186,7 @@
|
|||||||
role="switch"
|
role="switch"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span class="label">Скачивать медиа</span>
|
<span class="label">{t("jobs.downloadMedia")}</span>
|
||||||
<span class="switch" class:on={media}>
|
<span class="switch" class:on={media}>
|
||||||
<span class="knob"></span>
|
<span class="knob"></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -195,14 +200,19 @@
|
|||||||
onclick={start}
|
onclick={start}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
<Icon name="cloud-download" />
|
{#if started.current === "backfill"}
|
||||||
<span>Запустить полный бэкфилл</span>
|
<Icon name="check" />
|
||||||
|
<span>{t("jobs.status.pending")}</span>
|
||||||
|
{:else}
|
||||||
|
<Icon name="cloud-download" />
|
||||||
|
<span>{t("jobs.startBackfill")}</span>
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">Задачи</div>
|
<div class="section-title">{t("jobs.title")}</div>
|
||||||
<JobList {version} />
|
<JobList {version} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
import type { MediaRef } from "#lib/api/types.js";
|
import type { MediaRef } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
|
import { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,7 +25,17 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let inline = $state<InlineMedia | null>(null);
|
let inline = $state<InlineMedia | null>(null);
|
||||||
let queuing = $state(false);
|
let queuing = $state(false);
|
||||||
|
const queued = createFlash<true>();
|
||||||
|
|
||||||
|
const queueIcon = $derived.by(() => {
|
||||||
|
if (queuing) {
|
||||||
|
return "timer";
|
||||||
|
}
|
||||||
|
return queued.current ? "check" : "download";
|
||||||
|
});
|
||||||
|
const queueLabel = $derived(
|
||||||
|
queued.current ? t("chat.media.queued") : t("media.downloadToArchive")
|
||||||
|
);
|
||||||
const ready = $derived(inline?.state === "ready" ? inline : null);
|
const ready = $derived(inline?.state === "ready" ? inline : null);
|
||||||
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
|
const isVideo = $derived(ready ? visualKind(ready.kind) === "video" : false);
|
||||||
|
|
||||||
@@ -39,9 +51,9 @@
|
|||||||
queuing = true;
|
queuing = true;
|
||||||
try {
|
try {
|
||||||
await fetchMedia(chatId, media.message_id);
|
await fetchMedia(chatId, media.message_id);
|
||||||
toasts.success("Download queued");
|
queued.show(true);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Failed to queue download");
|
toasts.error(t("media.queueFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
queuing = false;
|
queuing = false;
|
||||||
}
|
}
|
||||||
@@ -62,11 +74,17 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else if ready}
|
{:else if ready}
|
||||||
<button class="tile" onclick={onopen} type="button">
|
<button class="tile" onclick={onopen} type="button">
|
||||||
<img alt="attachment" src={ready.url}>
|
<img alt={t("media.attachment")} src={ready.url}>
|
||||||
</button>
|
</button>
|
||||||
{:else if inline?.state === "not-downloaded"}
|
{:else if inline?.state === "not-downloaded"}
|
||||||
<button class="tile placeholder" onclick={queue} type="button">
|
<button
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
aria-label={queueLabel}
|
||||||
|
class="tile placeholder"
|
||||||
|
onclick={queue}
|
||||||
|
title={queueLabel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name={queueIcon} size="1.5rem" />
|
||||||
</button>
|
</button>
|
||||||
{:else if loaded}
|
{:else if loaded}
|
||||||
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
|
<div class="tile placeholder"><Icon name="photo" size="1.5rem" /></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatDuration } from "#lib/format/duration.js";
|
import { formatDuration } from "#lib/format/duration.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,7 +42,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="Audio" class:own>
|
<div class="Audio" class:own>
|
||||||
<button aria-label="Play audio" class="toggle" onclick={toggle} type="button">
|
<button
|
||||||
|
aria-label={paused ? t("ui.play") : t("ui.pause")}
|
||||||
|
class="toggle"
|
||||||
|
onclick={toggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
|
<Icon name={paused ? "play" : "pause"} size="1.625rem" />
|
||||||
</button>
|
</button>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { MediaRef } from "#lib/api/types.js";
|
import type { MediaRef } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { 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";
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
const name = $derived(
|
const name = $derived(
|
||||||
media.file_name ?? mediaKindLabel(media.kind) ?? "Файл"
|
media.file_name ?? mediaKindLabel(media.kind) ?? t("media.kind.document")
|
||||||
);
|
);
|
||||||
const size = $derived(formatBytes(media.file_size));
|
const size = $derived(formatBytes(media.file_size));
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadMedia(media.id);
|
await downloadMedia(media.id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать файл");
|
toasts.error(t("ui.downloadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Доступ по ссылке"
|
aria-label={t("ui.shareLink")}
|
||||||
class="file-share"
|
class="file-share"
|
||||||
onclick={share}
|
onclick={share}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatDuration } from "#lib/format/duration.js";
|
import { formatDuration } from "#lib/format/duration.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
||||||
import { POSTER_TIME, poster } from "#lib/media/poster.js";
|
import { POSTER_TIME, poster } from "#lib/media/poster.js";
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
|
|
||||||
<div class="RoundVideoWrap">
|
<div class="RoundVideoWrap">
|
||||||
<button
|
<button
|
||||||
aria-label="Play video message"
|
aria-label={paused ? t("media.playRound") : t("ui.pause")}
|
||||||
class="RoundVideo"
|
class="RoundVideo"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
class:active={showTranscript}
|
class:active={showTranscript}
|
||||||
>
|
>
|
||||||
<Icon name="transcribe" size="1rem" />
|
<Icon name="transcribe" size="1rem" />
|
||||||
Transcription
|
{t("media.transcription")}
|
||||||
</button>
|
</button>
|
||||||
{#if showTranscript}
|
{#if showTranscript}
|
||||||
<div class="transcript">{transcript}</div>
|
<div class="transcript">{transcript}</div>
|
||||||
|
|||||||
@@ -3,6 +3,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 { formatDuration } from "#lib/format/duration.js";
|
import { formatDuration } from "#lib/format/duration.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
import { claimPlayback, releasePlayback } from "#lib/media/playback.js";
|
||||||
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
|
import { computeWaveform, flatWaveform } from "#lib/media/waveform.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
@@ -38,10 +39,10 @@
|
|||||||
fetched = result;
|
fetched = result;
|
||||||
showTranscript = true;
|
showTranscript = true;
|
||||||
} else {
|
} else {
|
||||||
toasts.error("Не удалось расшифровать");
|
toasts.error(t("media.transcribeFailed"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось расшифровать");
|
toasts.error(t("media.transcribeFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
pending = false;
|
pending = false;
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
<div class="VoiceWrap" class:own>
|
<div class="VoiceWrap" class:own>
|
||||||
<div class="Voice">
|
<div class="Voice">
|
||||||
<button
|
<button
|
||||||
aria-label="Play voice"
|
aria-label={paused ? t("media.playVoice") : t("ui.pause")}
|
||||||
class="toggle"
|
class="toggle"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -121,7 +122,9 @@
|
|||||||
<div class="time">{formatDuration(elapsed)}</div>
|
<div class="time">{formatDuration(elapsed)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="Show transcription"
|
aria-label={showTranscript && text
|
||||||
|
? t("media.hideTranscription")
|
||||||
|
: t("media.showTranscription")}
|
||||||
class="transcribe"
|
class="transcribe"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onclick={onTranscribe}
|
onclick={onTranscribe}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
sameToggles,
|
sameToggles,
|
||||||
summary,
|
summary,
|
||||||
TOGGLE_KEYS,
|
TOGGLE_KEYS,
|
||||||
TOGGLE_LABELS,
|
|
||||||
type ToggleKey,
|
type ToggleKey,
|
||||||
|
toggleLabel,
|
||||||
} from "#lib/format/policy.js";
|
} from "#lib/format/policy.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onchange?: () => void;
|
onchange?: () => void;
|
||||||
@@ -44,17 +46,17 @@
|
|||||||
onchange,
|
onchange,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
|
const PRESETS: { label: MessageKey; toggles: CaptureToggles }[] = [
|
||||||
{ label: "Всё", toggles: preset("all") },
|
{ label: "policy.summary.all", toggles: preset("all") },
|
||||||
{ label: "Только сообщения", toggles: preset(["messages"]) },
|
{ label: "policy.summary.messagesOnly", toggles: preset(["messages"]) },
|
||||||
{ label: "Ничего", toggles: preset([]) },
|
{ label: "policy.summary.none", toggles: preset([]) },
|
||||||
];
|
];
|
||||||
|
|
||||||
let rule = $state<PolicyRecord | null>(null);
|
let rule = $state<PolicyRecord | null>(null);
|
||||||
let parent = $state<PolicyParent | null>(null);
|
let parent = $state<PolicyParent | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<MessageKey | null>(null);
|
||||||
let removing = $state(false);
|
let removing = $state(false);
|
||||||
|
|
||||||
const isChat = $derived(scopeType === "chat");
|
const isChat = $derived(scopeType === "chat");
|
||||||
@@ -79,13 +81,14 @@
|
|||||||
if (isChat && parent) {
|
if (isChat && parent) {
|
||||||
const from = parentLabel(parent.scope_type, parent.title);
|
const from = parentLabel(parent.scope_type, parent.title);
|
||||||
return rule
|
return rule
|
||||||
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
|
? t("policy.hint.chatRule", {
|
||||||
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
|
from,
|
||||||
|
summary: summary(parent.toggles).toLocaleLowerCase(locale.tag),
|
||||||
|
})
|
||||||
|
: t("policy.hint.chatInherit", { from });
|
||||||
}
|
}
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
return rule
|
return rule ? t("policy.hint.folderRule") : t("policy.hint.folderNone");
|
||||||
? "Действует на чаты папки, у которых нет своего правила."
|
|
||||||
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@
|
|||||||
});
|
});
|
||||||
onchange?.();
|
onchange?.();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось сохранить правило";
|
problem = "policy.rule.saveFailed";
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -136,7 +139,7 @@
|
|||||||
removing = false;
|
removing = false;
|
||||||
onchange?.();
|
onchange?.();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось удалить правило";
|
problem = "policy.rule.deleteFailed";
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
try {
|
try {
|
||||||
({ rule, parent } = await getPolicyScope(target));
|
({ rule, parent } = await getPolicyScope(target));
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось загрузить правило";
|
problem = "policy.rule.loadFailed";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -179,7 +182,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:on={shown !== null && sameToggles(shown, item.toggles)}
|
class:on={shown !== null && sameToggles(shown, item.toggles)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{t(item.label)}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +199,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:changed={differs(key)}
|
class:changed={differs(key)}
|
||||||
>
|
>
|
||||||
<span class="label">{TOGGLE_LABELS[key]}</span>
|
<span class="label">{toggleLabel(key)}</span>
|
||||||
<span class="switch" class:on={shown[key]}>
|
<span class="switch" class:on={shown[key]}>
|
||||||
<span class="knob"></span>
|
<span class="knob"></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -206,7 +209,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if problem}
|
{#if problem}
|
||||||
<p class="problem">{problem}</p>
|
<p class="problem">{t(problem)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if target}
|
{#if target}
|
||||||
@@ -216,7 +219,7 @@
|
|||||||
{#if rule && (isChat || isFolder)}
|
{#if rule && (isChat || isFolder)}
|
||||||
{#if removing}
|
{#if removing}
|
||||||
<div class="removal">
|
<div class="removal">
|
||||||
<span>Удалить правило?</span>
|
<span>{t("policy.rule.removeConfirm")}</span>
|
||||||
<Button
|
<Button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
removing = false;
|
removing = false;
|
||||||
@@ -224,7 +227,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Отмена
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -233,7 +236,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
Удалить
|
{t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -244,7 +247,7 @@
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Удалить правило
|
{t("policy.rule.remove")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Dialog } from "bits-ui";
|
import { Dialog } from "bits-ui";
|
||||||
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
|
|
||||||
const target = $derived(policyUi.target);
|
const target = $derived(policyUi.target);
|
||||||
@@ -22,10 +23,10 @@
|
|||||||
<div class="heading">
|
<div class="heading">
|
||||||
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
|
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
|
||||||
<span class="kind">
|
<span class="kind">
|
||||||
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
|
{isChat ? t("policy.kind.chat") : t("policy.kind.folder")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -14,11 +14,13 @@
|
|||||||
import {
|
import {
|
||||||
type Chip,
|
type Chip,
|
||||||
contentChips,
|
contentChips,
|
||||||
DEFAULT_LABELS,
|
defaultLabel,
|
||||||
diffChips,
|
diffChips,
|
||||||
sameToggles,
|
sameToggles,
|
||||||
summary,
|
summary,
|
||||||
} from "#lib/format/policy.js";
|
} from "#lib/format/policy.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
let folders = $state<Folder[]>([]);
|
let folders = $state<Folder[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let problem = $state<string | null>(null);
|
let problem = $state<MessageKey | null>(null);
|
||||||
let expanded = $state<string | null>(null);
|
let expanded = $state<string | null>(null);
|
||||||
let draft = $state<Draft | null>(null);
|
let draft = $state<Draft | null>(null);
|
||||||
let picking = $state<"folder" | "chat" | null>(null);
|
let picking = $state<"folder" | "chat" | null>(null);
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
const chatRules = $derived(
|
const chatRules = $derived(
|
||||||
rules
|
rules
|
||||||
.filter((rule) => rule.scope_type === "chat")
|
.filter((rule) => rule.scope_type === "chat")
|
||||||
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
|
.toSorted((a, b) => title(a).localeCompare(title(b), locale.tag))
|
||||||
);
|
);
|
||||||
const freeFolders = $derived(
|
const freeFolders = $derived(
|
||||||
folders.filter(
|
folders.filter(
|
||||||
@@ -88,12 +90,12 @@
|
|||||||
|
|
||||||
function title(rule: PolicyRule): string {
|
function title(rule: PolicyRule): string {
|
||||||
if (rule.scope_type === "chat") {
|
if (rule.scope_type === "chat") {
|
||||||
return rule.title ?? `Чат ${rule.scope_id}`;
|
return rule.title ?? t("ui.chat", { id: rule.scope_id ?? "" });
|
||||||
}
|
}
|
||||||
if (rule.scope_type === "folder") {
|
if (rule.scope_type === "folder") {
|
||||||
return rule.title ?? `Папка ${rule.scope_id}`;
|
return rule.title ?? t("policy.folder", { id: rule.scope_id ?? "" });
|
||||||
}
|
}
|
||||||
return DEFAULT_LABELS[rule.scope_type] ?? "";
|
return defaultLabel(rule.scope_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function redundant(rule: PolicyRule): boolean {
|
function redundant(rule: PolicyRule): boolean {
|
||||||
@@ -164,7 +166,7 @@
|
|||||||
await deletePolicy(rule.id);
|
await deletePolicy(rule.id);
|
||||||
await reload();
|
await reload();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось убрать правило";
|
problem = "policy.rule.dropFailed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +229,7 @@
|
|||||||
{#if plain}
|
{#if plain}
|
||||||
<span class="sub">{summary(rule)}</span>
|
<span class="sub">{summary(rule)}</span>
|
||||||
{:else if extra}
|
{:else if extra}
|
||||||
<span class="sub">Ничего не меняет</span>
|
<span class="sub">{t("policy.rule.redundant")}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="chips">
|
<span class="chips">
|
||||||
{#each chips(rule) as chip (chip.sign + chip.label)}
|
{#each chips(rule) as chip (chip.sign + chip.label)}
|
||||||
@@ -242,7 +244,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{#if extra}
|
{#if extra}
|
||||||
<Button onclick={() => drop(rule)} smaller variant="text">
|
<Button onclick={() => drop(rule)} smaller variant="text">
|
||||||
Убрать
|
{t("policy.rule.drop")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
@@ -291,7 +293,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="text">
|
<span class="text">
|
||||||
<span class="name">{item.title}</span>
|
<span class="name">{item.title}</span>
|
||||||
<span class="sub">Новое правило</span>
|
<span class="sub">{t("policy.rule.new")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -301,7 +303,7 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Отмена
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor" {@attach reveal}>
|
<div class="editor" {@attach reveal}>
|
||||||
@@ -318,17 +320,17 @@
|
|||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>Не удалось загрузить политику.</p>
|
<p>{t("policy.loadFailed")}</p>
|
||||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
<Button onclick={reload} smaller variant="text">{t("ui.retry")}</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="policy">
|
<div class="policy">
|
||||||
{#if problem}
|
{#if problem}
|
||||||
<p class="problem">{problem}</p>
|
<p class="problem">{t(problem)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>По умолчанию</h3></div>
|
<div class="section-title"><h3>{t("policy.section.defaults")}</h3></div>
|
||||||
{#each defaults as rule (rule.id)}
|
{#each defaults as rule (rule.id)}
|
||||||
{@render row(rule, true)}
|
{@render row(rule, true)}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -336,8 +338,8 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>Папки</h3>
|
<h3>{t("policy.section.folders")}</h3>
|
||||||
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
|
<span>{t("policy.section.foldersHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
{#each folderRules as rule (rule.id)}
|
{#each folderRules as rule (rule.id)}
|
||||||
{#if draft && draftBefore(rule)}
|
{#if draft && draftBefore(rule)}
|
||||||
@@ -356,7 +358,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="add" size="1.25rem" />
|
<Icon name="add" size="1.25rem" />
|
||||||
<span>Правило для папки</span>
|
<span>{t("policy.add.folder")}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if picking === "folder"}
|
{#if picking === "folder"}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
@@ -376,8 +378,8 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>Чаты</h3>
|
<h3>{t("policy.section.chats")}</h3>
|
||||||
<span>Что меняют против папки или умолчания. Важнее папок.</span>
|
<span>{t("policy.section.chatsHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if draft?.scopeType === "chat"}
|
{#if draft?.scopeType === "chat"}
|
||||||
{@render pending(draft)}
|
{@render pending(draft)}
|
||||||
@@ -392,33 +394,29 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="add" size="1.25rem" />
|
<Icon name="add" size="1.25rem" />
|
||||||
<span>Правило для чата</span>
|
<span>{t("policy.add.chat")}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if picking === "chat"}
|
{#if picking === "chat"}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
<input
|
<input
|
||||||
aria-label="Поиск чата"
|
aria-label={t("ui.searchChat")}
|
||||||
class="filter"
|
class="filter"
|
||||||
oninput={() => picker.search(chatFilter)}
|
oninput={() => picker.search(chatFilter)}
|
||||||
placeholder="Поиск чата"
|
placeholder={t("ui.searchChat")}
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={chatFilter}
|
bind:value={chatFilter}
|
||||||
>
|
>
|
||||||
{#each freeChats as chat (chat.chat_id)}
|
{#each freeChats as chat (chat.chat_id)}
|
||||||
|
{@const name = chat.title ?? t("ui.chat", { id: chat.chat_id })}
|
||||||
<button
|
<button
|
||||||
class="pick"
|
class="pick"
|
||||||
onclick={() =>
|
onclick={() => start("chat", chat.chat_id, name)}
|
||||||
start(
|
|
||||||
"chat",
|
|
||||||
chat.chat_id,
|
|
||||||
chat.title ?? `Чат ${chat.chat_id}`
|
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="nothing">Ничего не найдено.</p>
|
<p class="nothing">{t("ui.nothingFound")}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,35 +24,11 @@
|
|||||||
formatSpan,
|
formatSpan,
|
||||||
isOnline,
|
isOnline,
|
||||||
} from "#lib/format/presence.js";
|
} 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 { presence } from "#lib/stores/presence.svelte.js";
|
import { presence } from "#lib/stores/presence.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
average: "В среднем за день",
|
|
||||||
byDay: "По дням",
|
|
||||||
byHour: "По часам недели",
|
|
||||||
failed: "Не удалось загрузить аналитику.",
|
|
||||||
firstOnline: "Обычно появляется",
|
|
||||||
hidden:
|
|
||||||
"Скрывает, когда был(а) в сети. Telegram показывает только «был(а) недавно», поэтому время в сети не посчитать.",
|
|
||||||
lastOffline: "Обычно уходит",
|
|
||||||
median: "Обычная сессия",
|
|
||||||
messages: "Сообщения",
|
|
||||||
mine: "Я отвечаю",
|
|
||||||
missing: "Нет данных",
|
|
||||||
noData: "нет данных",
|
|
||||||
noMessages: "За этот период сообщений нет.",
|
|
||||||
noStatus: "Статусов пока нет.",
|
|
||||||
openChat: "Откройте чат",
|
|
||||||
period: (count: number) => `${count} дней`,
|
|
||||||
retry: "Повторить",
|
|
||||||
sessions: "Сессии",
|
|
||||||
theirs: "Мне отвечают",
|
|
||||||
title: "Аналитика",
|
|
||||||
total: (count: number) => `в сети за ${count} дней`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PERIODS = [7, 30, 90];
|
const PERIODS = [7, 30, 90];
|
||||||
const DEFAULT_PERIOD = 30;
|
const DEFAULT_PERIOD = 30;
|
||||||
const SESSION_PAGE = 30;
|
const SESSION_PAGE = 30;
|
||||||
@@ -60,7 +36,7 @@
|
|||||||
const MS_IN_SECOND = 1000;
|
const MS_IN_SECOND = 1000;
|
||||||
|
|
||||||
const tz = browserTimeZone();
|
const tz = browserTimeZone();
|
||||||
const zone = timeZoneLabel();
|
const zone = $derived(timeZoneLabel());
|
||||||
|
|
||||||
const chatId = $derived(
|
const chatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
@@ -97,25 +73,25 @@
|
|||||||
const result: { label: string; value: string }[] = [];
|
const result: { label: string; value: string }[] = [];
|
||||||
if (summary.daily_seconds !== null) {
|
if (summary.daily_seconds !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.average,
|
label: t("presence.average"),
|
||||||
value: formatSpan(summary.daily_seconds),
|
value: formatSpan(summary.daily_seconds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.median_session_seconds !== null) {
|
if (summary.median_session_seconds !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.median,
|
label: t("presence.median"),
|
||||||
value: formatSpan(summary.median_session_seconds),
|
value: formatSpan(summary.median_session_seconds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.first_online !== null) {
|
if (summary.first_online !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.firstOnline,
|
label: t("presence.firstOnline"),
|
||||||
value: formatClock(summary.first_online),
|
value: formatClock(summary.first_online),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.last_offline !== null) {
|
if (summary.last_offline !== null) {
|
||||||
result.push({
|
result.push({
|
||||||
label: TEXT.lastOffline,
|
label: t("presence.lastOffline"),
|
||||||
value: formatClock(summary.last_offline),
|
value: formatClock(summary.last_offline),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,13 +100,16 @@
|
|||||||
MS_IN_SECOND;
|
MS_IN_SECOND;
|
||||||
const missing = period - summary.covered_seconds;
|
const missing = period - summary.covered_seconds;
|
||||||
if (missing >= SECONDS_IN_HOUR) {
|
if (missing >= SECONDS_IN_HOUR) {
|
||||||
result.push({ label: TEXT.missing, value: formatLongSpan(missing) });
|
result.push({
|
||||||
|
label: t("presence.missing"),
|
||||||
|
value: formatLongSpan(missing),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function reply(seconds: number | null): string {
|
function reply(seconds: number | null): string {
|
||||||
return seconds === null ? TEXT.noData : formatSpan(seconds);
|
return seconds === null ? t("presence.noData") : formatSpan(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(id: number, period: number) {
|
async function load(id: number, period: number) {
|
||||||
@@ -172,7 +151,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null}
|
{#if chatId === null}
|
||||||
<EmptyState description={TEXT.openChat} title={TEXT.title} />
|
<EmptyState description={t("profile.empty")} title={t("profile.analytics")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="analytics">
|
<div class="analytics">
|
||||||
<div class="periods">
|
<div class="periods">
|
||||||
@@ -185,20 +164,20 @@
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{TEXT.period(period)}
|
{t("presence.days", { count: period })}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if failed}
|
{#if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>{TEXT.failed}</p>
|
<p>{t("presence.failed")}</p>
|
||||||
<Button
|
<Button
|
||||||
onclick={() => chatId !== null && load(chatId, days)}
|
onclick={() => chatId !== null && load(chatId, days)}
|
||||||
smaller
|
smaller
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
{TEXT.retry}
|
{t("ui.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if volume === null || (isDm && stats === null)}
|
{:else if volume === null || (isDm && stats === null)}
|
||||||
@@ -211,7 +190,9 @@
|
|||||||
<span class="total"
|
<span class="total"
|
||||||
>{formatSpan(stats.summary.online_seconds)}</span
|
>{formatSpan(stats.summary.online_seconds)}</span
|
||||||
>
|
>
|
||||||
<span class="count">{TEXT.total(shownDays)}</span>
|
<span class="count"
|
||||||
|
>{t("presence.total", { count: shownDays })}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if sample}
|
{#if sample}
|
||||||
@@ -220,9 +201,9 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if stats.hidden}
|
{#if stats.hidden}
|
||||||
<p class="note">{TEXT.hidden}</p>
|
<p class="note">{t("presence.hidden")}</p>
|
||||||
{:else if !tracked}
|
{:else if !tracked}
|
||||||
<p class="note">{TEXT.noStatus}</p>
|
<p class="note">{t("presence.noStatus")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -242,34 +223,34 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>{TEXT.byHour}</h3>
|
<h3>{t("presence.byHour")}</h3>
|
||||||
<span>{zone}</span>
|
<span>{zone}</span>
|
||||||
</div>
|
</div>
|
||||||
<PresenceHeatmap week={stats.week} />
|
<PresenceHeatmap week={stats.week} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.byDay}</h3></div>
|
<div class="section-title"><h3>{t("presence.byDay")}</h3></div>
|
||||||
<OnlineDays days={stats.days} />
|
<OnlineDays days={stats.days} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.messages}</h3></div>
|
<div class="section-title"><h3>{t("presence.messages")}</h3></div>
|
||||||
{#if quiet}
|
{#if quiet}
|
||||||
<p class="state">{TEXT.noMessages}</p>
|
<p class="state">{t("presence.noMessages")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<VolumeChart {volume} />
|
<VolumeChart {volume} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if response && response.mine_count + response.their_count > 0}
|
{#if response && response.mine_count + response.their_count > 0}
|
||||||
<dl class="rows replies">
|
<dl class="rows replies">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<dt>{TEXT.mine}</dt>
|
<dt>{t("presence.mine")}</dt>
|
||||||
<dd>{reply(response.mine_median_seconds)}</dd>
|
<dd>{reply(response.mine_median_seconds)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<dt>{TEXT.theirs}</dt>
|
<dt>{t("presence.theirs")}</dt>
|
||||||
<dd>{reply(response.their_median_seconds)}</dd>
|
<dd>{reply(response.their_median_seconds)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -278,7 +259,7 @@
|
|||||||
|
|
||||||
{#if isDm && stats && tracked && !stats.hidden}
|
{#if isDm && stats && tracked && !stats.hidden}
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>{TEXT.sessions}</h3></div>
|
<div class="section-title"><h3>{t("presence.sessions")}</h3></div>
|
||||||
{#key `${chatId}:${shownDays}:${stats.until}`}
|
{#key `${chatId}:${shownDays}:${stats.until}`}
|
||||||
<PresenceSessions
|
<PresenceSessions
|
||||||
days={shownDays}
|
days={shownDays}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { PresenceDay } from "#lib/api/types.js";
|
import type { PresenceDay } from "#lib/api/types.js";
|
||||||
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
days: PresenceDay[];
|
days: PresenceDay[];
|
||||||
@@ -12,18 +13,6 @@
|
|||||||
const SECONDS_IN_HOUR = 3600;
|
const SECONDS_IN_HOUR = 3600;
|
||||||
const SECONDS_IN_DAY = 86_400;
|
const SECONDS_IN_DAY = 86_400;
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
bar: (day: string, online: number, missing: number) => {
|
|
||||||
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
|
|
||||||
return `${day}, нет данных`;
|
|
||||||
}
|
|
||||||
const gap =
|
|
||||||
missing >= SECONDS_IN_HOUR ? `, нет данных ${formatSpan(missing)}` : "";
|
|
||||||
return `${day}, ${formatSpan(online)} в сети${gap}`;
|
|
||||||
},
|
|
||||||
today: "сегодня",
|
|
||||||
};
|
|
||||||
|
|
||||||
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
|
const peak = $derived(Math.max(1, ...days.map((day) => day.online_seconds)));
|
||||||
const first = $derived(days[0]);
|
const first = $derived(days[0]);
|
||||||
|
|
||||||
@@ -31,6 +20,23 @@
|
|||||||
return formatDay(parseDay(day).toISOString());
|
return formatDay(parseDay(day).toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function title(day: PresenceDay, index: number): string {
|
||||||
|
const date = label(day.day);
|
||||||
|
const missing = absent(day, index);
|
||||||
|
if (missing >= SECONDS_IN_DAY - SECONDS_IN_HOUR) {
|
||||||
|
return t("presence.dayNoData", { day: date });
|
||||||
|
}
|
||||||
|
const span = formatSpan(day.online_seconds);
|
||||||
|
if (missing >= SECONDS_IN_HOUR) {
|
||||||
|
return t("presence.dayGap", {
|
||||||
|
day: date,
|
||||||
|
gap: formatSpan(missing),
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return t("presence.day", { day: date, span });
|
||||||
|
}
|
||||||
|
|
||||||
function absent(day: PresenceDay, index: number): number {
|
function absent(day: PresenceDay, index: number): number {
|
||||||
if (index === days.length - 1) {
|
if (index === days.length - 1) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -46,11 +52,7 @@
|
|||||||
{#each days as day, index (day.day)}
|
{#each days as day, index (day.day)}
|
||||||
<div
|
<div
|
||||||
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
|
class={["slot", { empty: day.covered_seconds < SECONDS_IN_HOUR }]}
|
||||||
title={TEXT.bar(
|
title={title(day, index)}
|
||||||
label(day.day),
|
|
||||||
day.online_seconds,
|
|
||||||
absent(day, index)
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={["bar", { zero: day.online_seconds === 0 }]}
|
class={["bar", { zero: day.online_seconds === 0 }]}
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
{#if first}
|
{#if first}
|
||||||
<div class="axis">
|
<div class="axis">
|
||||||
<span>{label(first.day)}</span>
|
<span>{label(first.day)}</span>
|
||||||
<span>{TEXT.today}</span>
|
<span>{t("presence.axisToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PresenceHour } from "#lib/api/types.js";
|
import type { PresenceHour } from "#lib/api/types.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan, weekdayLabels } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
week: PresenceHour[];
|
week: PresenceHour[];
|
||||||
@@ -8,28 +9,14 @@
|
|||||||
|
|
||||||
const { week }: Props = $props();
|
const { week }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
cell: (day: string, cell: PresenceHour) => {
|
|
||||||
const slot = `${day}, ${cell.hour}:00`;
|
|
||||||
if (cell.hours === 0) {
|
|
||||||
return slot;
|
|
||||||
}
|
|
||||||
if (cell.online_minutes === null) {
|
|
||||||
return `${slot}, нет данных`;
|
|
||||||
}
|
|
||||||
return `${slot}, в среднем ${formatSpan(cell.online_minutes * 60)} в сети`;
|
|
||||||
},
|
|
||||||
days: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
|
|
||||||
empty: "нет данных",
|
|
||||||
max: (minutes: number) => `до ${formatSpan(minutes * 60)}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const HOURS_IN_DAY = 24;
|
const HOURS_IN_DAY = 24;
|
||||||
|
const SECONDS_IN_MINUTE = 60;
|
||||||
const HOUR_TICKS = [0, 6, 12, 18];
|
const HOUR_TICKS = [0, 6, 12, 18];
|
||||||
const MIN_SHADE = 0.18;
|
const MIN_SHADE = 0.18;
|
||||||
|
|
||||||
|
const weekdays = $derived(weekdayLabels());
|
||||||
const rows = $derived(
|
const rows = $derived(
|
||||||
TEXT.days.map((_, weekday) =>
|
weekdays.map((_, weekday) =>
|
||||||
week
|
week
|
||||||
.filter((cell) => cell.weekday === weekday)
|
.filter((cell) => cell.weekday === weekday)
|
||||||
.sort((a, b) => a.hour - b.hour)
|
.sort((a, b) => a.hour - b.hour)
|
||||||
@@ -44,6 +31,20 @@
|
|||||||
return cell.hours > 0 && cell.online_minutes === null;
|
return cell.hours > 0 && cell.online_minutes === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function title(day: string, cell: PresenceHour): string {
|
||||||
|
const slot = `${day}, ${cell.hour}:00`;
|
||||||
|
if (cell.hours === 0) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
if (cell.online_minutes === null) {
|
||||||
|
return t("presence.cellNoData", { slot });
|
||||||
|
}
|
||||||
|
return t("presence.cellAverage", {
|
||||||
|
slot,
|
||||||
|
span: formatSpan(cell.online_minutes * SECONDS_IN_MINUTE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function shade(minutes: number | null): number {
|
function shade(minutes: number | null): number {
|
||||||
if (minutes === null || minutes === 0 || peak === 0) {
|
if (minutes === null || minutes === 0 || peak === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
<div class="heatmap">
|
<div class="heatmap">
|
||||||
{#each rows as cells, weekday (weekday)}
|
{#each rows as cells, weekday (weekday)}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="day">{TEXT.days[weekday]}</span>
|
<span class="day">{weekdays[weekday]}</span>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
{#each cells as cell (cell.hour)}
|
{#each cells as cell (cell.hour)}
|
||||||
<span
|
<span
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
zero: cell.online_minutes === 0,
|
zero: cell.online_minutes === 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
title={TEXT.cell(TEXT.days[weekday] ?? "", cell)}
|
title={title(weekdays[weekday] ?? "", cell)}
|
||||||
style:--shade="{shade(cell.online_minutes) * 100}%"
|
style:--shade="{shade(cell.online_minutes) * 100}%"
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -87,13 +88,13 @@
|
|||||||
{#if peak > 0}
|
{#if peak > 0}
|
||||||
<span class="key">
|
<span class="key">
|
||||||
<span class="scale"></span>
|
<span class="scale"></span>
|
||||||
{TEXT.max(peak)}
|
{t("presence.max", { span: formatSpan(peak * SECONDS_IN_MINUTE) })}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasGaps}
|
{#if hasGaps}
|
||||||
<span class="key">
|
<span class="key">
|
||||||
<span class="swatch empty"></span>
|
<span class="swatch empty"></span>
|
||||||
{TEXT.empty}
|
{t("presence.noData")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
|
|
||||||
.day {
|
.day {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1.375rem;
|
width: 1.75rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
gap: 0.25rem 0.75rem;
|
gap: 0.25rem 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
margin: 0.375rem 0 0 1.875rem;
|
margin: 0.375rem 0 0 2.25rem;
|
||||||
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
|
import { formatLongDay, formatTime } from "#lib/format/datetime.js";
|
||||||
import { formatSpan } from "#lib/format/presence.js";
|
import { formatSpan } from "#lib/format/presence.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
days: number;
|
days: number;
|
||||||
@@ -16,18 +17,6 @@
|
|||||||
|
|
||||||
const { days, first, pageSize, peerId, tz }: Props = $props();
|
const { days, first, pageSize, peerId, tz }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
failed: "Не удалось загрузить сессии.",
|
|
||||||
more: "Показать ещё",
|
|
||||||
none: "За этот период в сети не был(а).",
|
|
||||||
now: "в сети",
|
|
||||||
retry: "Повторить",
|
|
||||||
since: (time: string) => `с ${time}`,
|
|
||||||
span: (from: string, to: string) => (from === to ? from : `${from}–${to}`),
|
|
||||||
today: "Сегодня",
|
|
||||||
yesterday: "Вчера",
|
|
||||||
};
|
|
||||||
|
|
||||||
const MS_IN_DAY = 86_400_000;
|
const MS_IN_DAY = 86_400_000;
|
||||||
|
|
||||||
let extra = $state<PresenceSession[]>([]);
|
let extra = $state<PresenceSession[]>([]);
|
||||||
@@ -54,18 +43,22 @@
|
|||||||
function dayLabel(date: Date): string {
|
function dayLabel(date: Date): string {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
if (date.toDateString() === today.toDateString()) {
|
if (date.toDateString() === today.toDateString()) {
|
||||||
return TEXT.today;
|
return t("presence.today");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
date.toDateString() ===
|
date.toDateString() ===
|
||||||
new Date(today.getTime() - MS_IN_DAY).toDateString()
|
new Date(today.getTime() - MS_IN_DAY).toDateString()
|
||||||
) {
|
) {
|
||||||
return TEXT.yesterday;
|
return t("presence.yesterday");
|
||||||
}
|
}
|
||||||
const text = formatLongDay(date);
|
const text = formatLongDay(date);
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function span(from: string, to: string): string {
|
||||||
|
return from === to ? from : `${from}–${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return;
|
return;
|
||||||
@@ -92,7 +85,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sessions.length === 0}
|
{#if sessions.length === 0}
|
||||||
<p class="state">{TEXT.none}</p>
|
<p class="state">{t("presence.noSessions")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each groups as group (group.label)}
|
{#each groups as group (group.label)}
|
||||||
<h4 class="day">{group.label}</h4>
|
<h4 class="day">{group.label}</h4>
|
||||||
@@ -101,14 +94,13 @@
|
|||||||
<li>
|
<li>
|
||||||
<span>
|
<span>
|
||||||
{session.ongoing
|
{session.ongoing
|
||||||
? TEXT.since(formatTime(session.started))
|
? t("presence.since", { time: formatTime(session.started) })
|
||||||
: TEXT.span(
|
: span(formatTime(session.started), formatTime(session.ended))}
|
||||||
formatTime(session.started),
|
|
||||||
formatTime(session.ended)
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span class={["length", { online: session.ongoing }]}>
|
<span class={["length", { online: session.ongoing }]}>
|
||||||
{session.ongoing ? TEXT.now : formatSpan(session.seconds)}
|
{session.ongoing
|
||||||
|
? t("presence.online")
|
||||||
|
: formatSpan(session.seconds)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -118,11 +110,13 @@
|
|||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>{TEXT.failed}</p>
|
<p>{t("presence.sessionsFailed")}</p>
|
||||||
<Button onclick={loadMore} smaller variant="text">{TEXT.retry}</Button>
|
<Button onclick={loadMore} smaller variant="text">{t("ui.retry")}</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if !exhausted}
|
{:else if !exhausted}
|
||||||
<button class="more" onclick={loadMore} type="button">{TEXT.more}</button>
|
<button class="more" onclick={loadMore} type="button">
|
||||||
|
{t("ui.showMore")}
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { VolumeBucket } from "#lib/api/types.js";
|
import type { VolumeBucket } from "#lib/api/types.js";
|
||||||
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
import { formatDay, parseDay } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
volume: VolumeBucket[];
|
volume: VolumeBucket[];
|
||||||
@@ -8,22 +9,25 @@
|
|||||||
|
|
||||||
const { volume }: Props = $props();
|
const { volume }: Props = $props();
|
||||||
|
|
||||||
const TEXT = {
|
|
||||||
bar: (day: string, bucket: VolumeBucket) =>
|
|
||||||
bucket.total === 0
|
|
||||||
? `${day}, нет сообщений`
|
|
||||||
: `${day}, всего ${bucket.total}: входящих ${bucket.incoming}, моих ${bucket.outgoing}`,
|
|
||||||
incoming: "входящие",
|
|
||||||
outgoing: "мои",
|
|
||||||
today: "сегодня",
|
|
||||||
};
|
|
||||||
|
|
||||||
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
const peak = $derived(Math.max(1, ...volume.map((bucket) => bucket.total)));
|
||||||
const first = $derived(volume[0]);
|
const first = $derived(volume[0]);
|
||||||
|
|
||||||
function label(day: string): string {
|
function label(day: string): string {
|
||||||
return formatDay(parseDay(day).toISOString());
|
return formatDay(parseDay(day).toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function title(bucket: VolumeBucket): string {
|
||||||
|
const day = label(bucket.bucket);
|
||||||
|
if (bucket.total === 0) {
|
||||||
|
return t("presence.volumeEmpty", { day });
|
||||||
|
}
|
||||||
|
return t("presence.volume", {
|
||||||
|
day,
|
||||||
|
incoming: bucket.incoming,
|
||||||
|
outgoing: bucket.outgoing,
|
||||||
|
total: bucket.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="volume">
|
<div class="volume">
|
||||||
@@ -31,7 +35,7 @@
|
|||||||
<span class="peak">{peak}</span>
|
<span class="peak">{peak}</span>
|
||||||
<div class={["bars", { dense: volume.length > 45 }]}>
|
<div class={["bars", { dense: volume.length > 45 }]}>
|
||||||
{#each volume as bucket (bucket.bucket)}
|
{#each volume as bucket (bucket.bucket)}
|
||||||
<div class="slot" title={TEXT.bar(label(bucket.bucket), bucket)}>
|
<div class="slot" title={title(bucket)}>
|
||||||
<div
|
<div
|
||||||
class={["bar", { zero: bucket.total === 0 }]}
|
class={["bar", { zero: bucket.total === 0 }]}
|
||||||
style:height="{(bucket.total / peak) * 100}%"
|
style:height="{(bucket.total / peak) * 100}%"
|
||||||
@@ -46,12 +50,16 @@
|
|||||||
{#if first}
|
{#if first}
|
||||||
<div class="axis">
|
<div class="axis">
|
||||||
<span>{label(first.bucket)}</span>
|
<span>{label(first.bucket)}</span>
|
||||||
<span>{TEXT.today}</span>
|
<span>{t("presence.axisToday")}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="key"><span class="swatch in"></span>{TEXT.incoming}</span>
|
<span class="key"
|
||||||
<span class="key"><span class="swatch out"></span>{TEXT.outgoing}</span>
|
><span class="swatch in"></span>{t("presence.volumeIncoming")}</span
|
||||||
|
>
|
||||||
|
<span class="key"
|
||||||
|
><span class="swatch out"></span>{t("presence.volumeOutgoing")}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
|
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
|
||||||
import type { DayCount } from "#lib/api/types.js";
|
import type { DayCount } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
interface Props {
|
|
||||||
chatId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { chatId }: Props = $props();
|
|
||||||
|
|
||||||
const LEVELS = 4;
|
|
||||||
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
|
||||||
const MONTHS = [
|
|
||||||
"Январь",
|
|
||||||
"Февраль",
|
|
||||||
"Март",
|
|
||||||
"Апрель",
|
|
||||||
"Май",
|
|
||||||
"Июнь",
|
|
||||||
"Июль",
|
|
||||||
"Август",
|
|
||||||
"Сентябрь",
|
|
||||||
"Октябрь",
|
|
||||||
"Ноябрь",
|
|
||||||
"Декабрь",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface YearMonth {
|
interface YearMonth {
|
||||||
month: number;
|
month: number;
|
||||||
year: number;
|
year: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: number;
|
||||||
|
initial?: YearMonth | null;
|
||||||
|
onjump?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatId, initial = null, onjump }: Props = $props();
|
||||||
|
|
||||||
|
const LEVELS = 4;
|
||||||
|
const MONDAY = Date.UTC(2024, 0, 1);
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
const weekdays = $derived.by(() => {
|
||||||
|
const names = new Intl.DateTimeFormat(locale.tag, {
|
||||||
|
timeZone: "UTC",
|
||||||
|
weekday: "short",
|
||||||
|
});
|
||||||
|
return Array.from({ length: 7 }, (_, offset) =>
|
||||||
|
names.format(new Date(MONDAY + offset * DAY_MS))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface DayCell {
|
interface DayCell {
|
||||||
count: number;
|
count: number;
|
||||||
day: number;
|
day: number;
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
|
|
||||||
let days = $state<DayCount[]>([]);
|
let days = $state<DayCount[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let cursor = $state<YearMonth | null>(null);
|
let cursor = $state<YearMonth | null>(untrack(() => initial));
|
||||||
const cache = new Map<string, DayCount[]>();
|
const cache = new Map<string, DayCount[]>();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -61,6 +63,13 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const view = $derived(cursor ?? currentMonth);
|
const view = $derived(cursor ?? currentMonth);
|
||||||
|
const monthLabel = $derived.by(() => {
|
||||||
|
const name = new Intl.DateTimeFormat(locale.tag, {
|
||||||
|
month: "long",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(new Date(Date.UTC(view.year, view.month, 1)));
|
||||||
|
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${view.year}`;
|
||||||
|
});
|
||||||
|
|
||||||
function index(value: YearMonth): number {
|
function index(value: YearMonth): number {
|
||||||
return value.year * 12 + value.month;
|
return value.year * 12 + value.month;
|
||||||
@@ -115,9 +124,14 @@
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _id = chatId;
|
const key = `${chatId}:${accounts.selectedId}`;
|
||||||
const _account = accounts.selectedId;
|
if (key === shown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shown = key;
|
||||||
cache.clear();
|
cache.clear();
|
||||||
cursor = null;
|
cursor = null;
|
||||||
days = [];
|
days = [];
|
||||||
@@ -161,7 +175,10 @@
|
|||||||
try {
|
try {
|
||||||
const target = await getMessageAt(chatId, cell.key);
|
const target = await getMessageAt(chatId, cell.key);
|
||||||
ui.requestJump(chatId, target.message_id);
|
ui.requestJump(chatId, target.message_id);
|
||||||
goto(`/app/${chatId}`);
|
onjump?.();
|
||||||
|
if (page.params.chatId !== String(chatId)) {
|
||||||
|
goto(`/app/${chatId}`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// no message resolved for that day
|
// no message resolved for that day
|
||||||
}
|
}
|
||||||
@@ -174,7 +191,7 @@
|
|||||||
<div class="calendar">
|
<div class="calendar">
|
||||||
<header class="nav">
|
<header class="nav">
|
||||||
<button
|
<button
|
||||||
aria-label="Предыдущий год"
|
aria-label={t("chat.calendar.prevYear")}
|
||||||
class="step"
|
class="step"
|
||||||
onclick={() => shift(-12)}
|
onclick={() => shift(-12)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -182,16 +199,16 @@
|
|||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Предыдущий месяц"
|
aria-label={t("chat.calendar.prevMonth")}
|
||||||
class="step"
|
class="step"
|
||||||
onclick={() => shift(-1)}
|
onclick={() => shift(-1)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left" />
|
<Icon name="arrow-left" />
|
||||||
</button>
|
</button>
|
||||||
<span class="label">{MONTHS[view.month]} {view.year}</span>
|
<span class="label">{monthLabel}</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Следующий месяц"
|
aria-label={t("chat.calendar.nextMonth")}
|
||||||
class="step"
|
class="step"
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
onclick={() => shift(1)}
|
onclick={() => shift(1)}
|
||||||
@@ -200,7 +217,7 @@
|
|||||||
<Icon name="arrow-right" />
|
<Icon name="arrow-right" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Следующий год"
|
aria-label={t("chat.calendar.nextYear")}
|
||||||
class="step"
|
class="step"
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
onclick={() => shift(12)}
|
onclick={() => shift(12)}
|
||||||
@@ -211,7 +228,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="weekdays">
|
<div class="weekdays">
|
||||||
{#each WEEKDAYS as weekday (weekday)}
|
{#each weekdays as weekday (weekday)}
|
||||||
<span>{weekday}</span>
|
<span>{weekday}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +242,7 @@
|
|||||||
<button
|
<button
|
||||||
class="cell day lvl-{cell.level}"
|
class="cell day lvl-{cell.level}"
|
||||||
onclick={() => jump(cell)}
|
onclick={() => jump(cell)}
|
||||||
title={`${cell.count} сообщений`}
|
title={t("chat.calendar.count", { count: cell.count })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{cell.day}
|
{cell.day}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
|
import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatDate, formatFull } from "#lib/format/datetime.js";
|
import { formatDate, formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { formatPhone } from "#lib/format/peer.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
result.push({
|
result.push({
|
||||||
icon: "phone",
|
icon: "phone",
|
||||||
label: t("profile.phone"),
|
label: t("profile.phone"),
|
||||||
value: `+${peer.phone}`,
|
value: formatPhone(peer.phone),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (peer?.username) {
|
if (peer?.username) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { peerName } from "#lib/format/peer.js";
|
import { formatPhone, peerName } from "#lib/format/peer.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
if (peer?.username) {
|
if (peer?.username) {
|
||||||
return `@${peer.username}`;
|
return `@${peer.username}`;
|
||||||
}
|
}
|
||||||
return peer?.phone ? `+${peer.phone}` : `ID ${targetId}`;
|
return peer?.phone ? formatPhone(peer.phone) : `ID ${targetId}`;
|
||||||
}
|
}
|
||||||
const count = chat?.message_count ?? 0;
|
const count = chat?.message_count ?? 0;
|
||||||
return count > 0 ? t("profile.messages", { count }) : t("profile.group");
|
return count > 0 ? t("profile.messages", { count }) : t("profile.group");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
import { safeHref } from "#lib/format/url.js";
|
import { safeHref } from "#lib/format/url.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 { 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";
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState title="Нет ссылок" />
|
<div class="center"><EmptyState title={t("social.links.empty")} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
onclick={() => loadMore()}
|
onclick={() => loadMore()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{loading ? "Загрузка…" : "Показать ещё"}
|
{loading ? t("ui.loading") : t("ui.showMore")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
@@ -56,14 +57,16 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function displayName(item: MediaView): string {
|
function displayName(item: MediaView): string {
|
||||||
return item.file_name ?? mediaKindLabel(item.kind) ?? "Файл";
|
return (
|
||||||
|
item.file_name ?? mediaKindLabel(item.kind) ?? t("media.kind.document")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(item: MediaView) {
|
async function save(item: MediaView) {
|
||||||
try {
|
try {
|
||||||
await downloadMedia(item.id);
|
await downloadMedia(item.id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать файл");
|
toasts.error(t("ui.downloadFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +178,7 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState title="Пусто" />
|
<div class="center"><EmptyState title={t("shared.media.empty")} /></div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if layout === "grid"}
|
{:else if layout === "grid"}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -217,17 +220,17 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
|
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
|
||||||
>Открыть на весь экран</ContextMenuItem
|
>{t("shared.media.fullscreen")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
|
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
|
||||||
>Перейти к сообщению</ContextMenuItem
|
>{t("ui.jumpToMessage")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{#if item.downloaded}
|
{#if item.downloaded}
|
||||||
<ContextMenuItem icon="download" onselect={() => save(item)}
|
<ContextMenuItem icon="download" onselect={() => save(item)}
|
||||||
>Скачать файл</ContextMenuItem
|
>{t("ui.downloadFile")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
|
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
|
||||||
>Доступ по ссылке</ContextMenuItem
|
>{t("ui.shareLink")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -262,17 +265,17 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
|
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}
|
||||||
>Открыть</ContextMenuItem
|
>{t("ui.open")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
|
<ContextMenuItem icon="reply" onselect={() => jump(item.message_id)}
|
||||||
>Перейти к сообщению</ContextMenuItem
|
>{t("ui.jumpToMessage")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{#if item.downloaded}
|
{#if item.downloaded}
|
||||||
<ContextMenuItem icon="download" onselect={() => save(item)}
|
<ContextMenuItem icon="download" onselect={() => save(item)}
|
||||||
>Скачать файл</ContextMenuItem
|
>{t("ui.downloadFile")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
|
<ContextMenuItem icon="allow-share" onselect={() => share(item)}
|
||||||
>Доступ по ссылке</ContextMenuItem
|
>{t("ui.shareLink")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -289,7 +292,7 @@
|
|||||||
onclick={() => loadMore()}
|
onclick={() => loadMore()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{loading ? "Загрузка…" : "Показать ещё"}
|
{loading ? t("ui.loading") : t("ui.showMore")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { visible } from "#lib/actions/visible.js";
|
import { visible } from "#lib/actions/visible.js";
|
||||||
|
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
|
||||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
@@ -15,7 +16,9 @@
|
|||||||
const DEBOUNCE_MS = 250;
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
const pager = createHitPager();
|
const pager = createHitPager();
|
||||||
|
const everywhere = createHitPager();
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
|
let deep = $state(false);
|
||||||
let typing = $state(false);
|
let typing = $state(false);
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -23,14 +26,30 @@
|
|||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const needle = $derived(query.trim());
|
||||||
|
const empty = $derived(
|
||||||
|
needle !== "" &&
|
||||||
|
!(typing || deep || pager.loading || pager.failed) &&
|
||||||
|
pager.hits.length === 0
|
||||||
|
);
|
||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
typing = false;
|
typing = false;
|
||||||
const value = query.trim();
|
deep = false;
|
||||||
|
everywhere.run(null);
|
||||||
pager.run(
|
pager.run(
|
||||||
value && chatId !== null ? { chat_id: chatId, query: value } : null
|
needle && chatId !== null ? { chat_id: chatId, query: needle } : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchEverywhere() {
|
||||||
|
if (chatId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deep = true;
|
||||||
|
everywhere.run({ chat_id: chatId, everywhere: true, query: needle });
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chatId !== null) {
|
if (chatId !== null) {
|
||||||
untrack(run);
|
untrack(run);
|
||||||
@@ -46,11 +65,11 @@
|
|||||||
timer = setTimeout(run, DEBOUNCE_MS);
|
timer = setTimeout(run, DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHit(messageId: number) {
|
function openHit(messageId: number, marks: string[]) {
|
||||||
if (chatId === null) {
|
if (chatId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.requestJump(chatId, messageId);
|
ui.requestJump(chatId, messageId, marks);
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
ui.closePanel();
|
ui.closePanel();
|
||||||
}
|
}
|
||||||
@@ -73,9 +92,9 @@
|
|||||||
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||||
<SearchMessageItem
|
<SearchMessageItem
|
||||||
{hit}
|
{hit}
|
||||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
href={search.hitHref(hit.chat_id, hit.message_id, pager.terms)}
|
||||||
inChat
|
inChat
|
||||||
onclick={() => openHit(hit.message_id)}
|
onclick={() => openHit(hit.message_id, pager.terms)}
|
||||||
terms={pager.terms}
|
terms={pager.terms}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -96,7 +115,19 @@
|
|||||||
>{t("search.retry")}</Button
|
>{t("search.retry")}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else if query.trim() && !typing}
|
{/if}
|
||||||
|
<SearchEverywhere
|
||||||
|
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
|
||||||
|
inChat
|
||||||
|
{needle}
|
||||||
|
onopen={(hit) => openHit(hit.message_id, [needle])}
|
||||||
|
onstart={searchEverywhere}
|
||||||
|
pager={everywhere}
|
||||||
|
ready={!(typing || pager.loading || pager.failed || pager.hasMore)}
|
||||||
|
shown={pager.hits}
|
||||||
|
started={deep}
|
||||||
|
/>
|
||||||
|
{#if empty}
|
||||||
<EmptyState title={t("search.nothing")} />
|
<EmptyState title={t("search.nothing")} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
|
import { visible } from "#lib/actions/visible.js";
|
||||||
|
import type { SearchHit } from "#lib/api/types.js";
|
||||||
|
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { formatDate } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import {
|
||||||
|
EVERYWHERE_MIN,
|
||||||
|
type HitPager,
|
||||||
|
hitKey,
|
||||||
|
} from "#lib/stores/search.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: (hit: SearchHit) => string;
|
||||||
|
inChat?: boolean;
|
||||||
|
needle: string;
|
||||||
|
onopen?: (hit: SearchHit) => void;
|
||||||
|
onstart: () => void;
|
||||||
|
pager: HitPager;
|
||||||
|
ready: boolean;
|
||||||
|
shown: SearchHit[];
|
||||||
|
started: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
inChat = false,
|
||||||
|
needle,
|
||||||
|
onopen,
|
||||||
|
onstart,
|
||||||
|
pager,
|
||||||
|
ready,
|
||||||
|
shown,
|
||||||
|
started,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const hits = $derived.by(() => {
|
||||||
|
const seen = new Set(shown.map(hitKey));
|
||||||
|
return pager.hits.filter((hit) => {
|
||||||
|
const key = hitKey(hit);
|
||||||
|
const fresh = !seen.has(key);
|
||||||
|
seen.add(key);
|
||||||
|
return fresh;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const busy = $derived(pager.loading || pager.appending);
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
if (pager.hasMore) {
|
||||||
|
pager.more();
|
||||||
|
} else {
|
||||||
|
pager.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !started}
|
||||||
|
{#if ready && needle.length >= EVERYWHERE_MIN}
|
||||||
|
<button
|
||||||
|
class="start"
|
||||||
|
onclick={onstart}
|
||||||
|
type="button"
|
||||||
|
class:in-chat={inChat}
|
||||||
|
use:ripple
|
||||||
|
>
|
||||||
|
<span class="glyph"><Icon name="search" size="1.5rem" /></span>
|
||||||
|
<span class="body">
|
||||||
|
<span class="label">{t("search.everywhere")}</span>
|
||||||
|
<span class="hint">{t("search.everywhereHint")}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if hits.length > 0}
|
||||||
|
<div class="section-label">{t("search.everywhereSection")}</div>
|
||||||
|
{#each hits as hit (hitKey(hit))}
|
||||||
|
<SearchMessageItem
|
||||||
|
{hit}
|
||||||
|
href={href(hit)}
|
||||||
|
{inChat}
|
||||||
|
onclick={() => onopen?.(hit)}
|
||||||
|
terms={[needle]}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if busy}
|
||||||
|
<div class="status">
|
||||||
|
<Spinner size="1.25rem" />
|
||||||
|
<span>
|
||||||
|
{pager.scannedTo
|
||||||
|
? t("search.everywhereScanned", { date: formatDate(pager.scannedTo) })
|
||||||
|
: t("search.everywhereScanning")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else if pager.failed}
|
||||||
|
<div class="status failed">
|
||||||
|
<span>{t("search.failed")}</span>
|
||||||
|
<Button onclick={retry} variant="text">{t("search.retry")}</Button>
|
||||||
|
</div>
|
||||||
|
{:else if pager.hasMore}
|
||||||
|
<div class="sentinel" use:visible={() => pager.more()}></div>
|
||||||
|
{:else if hits.length === 0}
|
||||||
|
<div class="status">{t("search.everywhereNothing")}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.start {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
|
||||||
|
color: var(--color-primary);
|
||||||
|
|
||||||
|
.in-chat & {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1875rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
padding: 0.625rem 0.75rem 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
padding: 1rem 0.75rem 1.5rem;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&.failed {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ripple } from "#lib/actions/ripple.js";
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
import type { SearchHit } from "#lib/api/types.js";
|
import type { FoundWhere, SearchHit } from "#lib/api/types.js";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||||
import { mediaKindLabel } from "#lib/format/media.js";
|
import { mediaKindLabel } from "#lib/format/media.js";
|
||||||
import { peerName } from "#lib/format/peer.js";
|
import { peerName } from "#lib/format/peer.js";
|
||||||
import { highlight } from "#lib/format/search.js";
|
import { highlight, highlightMarks } from "#lib/format/search.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
@@ -22,6 +22,17 @@
|
|||||||
|
|
||||||
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||||
|
|
||||||
|
const FOUND_ICONS: Record<FoundWhere, string> = {
|
||||||
|
button: "bot-command",
|
||||||
|
data: "info",
|
||||||
|
file: "document",
|
||||||
|
forward: "forward",
|
||||||
|
link: "link",
|
||||||
|
preview: "link",
|
||||||
|
reply: "reply",
|
||||||
|
text: "search",
|
||||||
|
};
|
||||||
|
|
||||||
const chat = $derived(chats.byId(hit.chat_id));
|
const chat = $derived(chats.byId(hit.chat_id));
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
||||||
@@ -81,7 +92,21 @@
|
|||||||
const kind = $derived(
|
const kind = $derived(
|
||||||
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||||
);
|
);
|
||||||
const segments = $derived(highlight(body, terms));
|
const byData = $derived(hit.source === "data");
|
||||||
|
const segments = $derived(
|
||||||
|
byData ? highlightMarks(body, terms) : highlight(body, terms)
|
||||||
|
);
|
||||||
|
const found = $derived(
|
||||||
|
hit.found && hit.found.where !== "text" ? hit.found : null
|
||||||
|
);
|
||||||
|
const foundLabel = $derived.by(() => {
|
||||||
|
if (found === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return found.where === "button"
|
||||||
|
? t("search.found.button", { text: found.button ?? "" })
|
||||||
|
: t(`search.found.${found.where}`);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="hit" {href} {onclick} use:ripple>
|
<a class="hit" {href} {onclick} use:ripple>
|
||||||
@@ -126,6 +151,21 @@
|
|||||||
{/each}</span
|
{/each}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{#if found}
|
||||||
|
<div class="note found" title={found.path}>
|
||||||
|
<Icon name={FOUND_ICONS[found.where]} size="0.875rem" />
|
||||||
|
<span class="found-label">{foundLabel}:</span>
|
||||||
|
<span class="found-value"
|
||||||
|
>{#each highlightMarks(found.value, terms) as segment, index (index)}
|
||||||
|
{#if segment.hit}
|
||||||
|
<mark>{segment.text}</mark>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if hit.source === "version"}
|
{#if hit.source === "version"}
|
||||||
<div
|
<div
|
||||||
class="note"
|
class="note"
|
||||||
@@ -244,6 +284,22 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
font-style: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.found-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.found-value {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
mark {
|
mark {
|
||||||
padding: 0 0.0625rem;
|
padding: 0 0.0625rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { visible } from "#lib/actions/visible.js";
|
import { visible } from "#lib/actions/visible.js";
|
||||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||||
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
||||||
|
import SearchEverywhere from "#lib/components/search/SearchEverywhere.svelte";
|
||||||
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
|
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
|
||||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const { messages } = search;
|
const { everywhere, messages } = search;
|
||||||
let scroller = $state<HTMLDivElement | null>(null);
|
let scroller = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -25,12 +26,22 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = search.everywhereRequest;
|
||||||
|
untrack(() => search.syncEverywhere(key));
|
||||||
|
});
|
||||||
|
|
||||||
const activeChatId = $derived(
|
const activeChatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
);
|
);
|
||||||
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
|
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
|
||||||
const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
|
const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
|
||||||
const hasMessages = $derived(messages.hits.length > 0);
|
const hasMessages = $derived(messages.hits.length > 0);
|
||||||
|
const needle = $derived(search.text.trim());
|
||||||
|
const searchedAll = $derived(
|
||||||
|
!(search.typing || messages.loading || messages.failed || messages.hasMore)
|
||||||
|
);
|
||||||
|
const deep = $derived(search.everywhereRequest !== "");
|
||||||
const empty = $derived(
|
const empty = $derived(
|
||||||
!(
|
!(
|
||||||
search.typing ||
|
search.typing ||
|
||||||
@@ -38,7 +49,8 @@
|
|||||||
messages.failed ||
|
messages.failed ||
|
||||||
showChats ||
|
showChats ||
|
||||||
showPeers ||
|
showPeers ||
|
||||||
hasMessages
|
hasMessages ||
|
||||||
|
deep
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,8 +86,9 @@
|
|||||||
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||||
<SearchMessageItem
|
<SearchMessageItem
|
||||||
{hit}
|
{hit}
|
||||||
href={search.hitHref(hit.chat_id, hit.message_id)}
|
href={search.hitHref(hit.chat_id, hit.message_id, messages.terms)}
|
||||||
onclick={() => ui.requestJump(hit.chat_id, hit.message_id)}
|
onclick={() =>
|
||||||
|
ui.requestJump(hit.chat_id, hit.message_id, messages.terms)}
|
||||||
terms={messages.terms}
|
terms={messages.terms}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -98,6 +111,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<SearchEverywhere
|
||||||
|
href={(hit) => search.hitHref(hit.chat_id, hit.message_id, [needle])}
|
||||||
|
{needle}
|
||||||
|
onopen={(hit) => ui.requestJump(hit.chat_id, hit.message_id, [needle])}
|
||||||
|
onstart={() => search.searchEverywhere()}
|
||||||
|
pager={everywhere}
|
||||||
|
ready={searchedAll}
|
||||||
|
shown={messages.hits}
|
||||||
|
started={deep}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if empty}
|
{#if empty}
|
||||||
<EmptyState title={t("search.nothing")} />
|
<EmptyState title={t("search.nothing")} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import QrCode from "#lib/components/ui/QrCode.svelte";
|
import QrCode from "#lib/components/ui/QrCode.svelte";
|
||||||
|
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 { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
@@ -30,12 +32,9 @@
|
|||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
const hints: Record<Step, string> = {
|
function hint(current: Step): string {
|
||||||
code: "Код отправлен в Telegram на этот номер.",
|
return t(`login.hint.${current}`);
|
||||||
password: "Аккаунт защищён двухэтапной аутентификацией.",
|
}
|
||||||
phone: "Номер телефона в международном формате, например +79991234567.",
|
|
||||||
qr: "Telegram → Настройки → Устройства → Подключить устройство, и наведите камеру на код.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const filled = $derived.by(() => {
|
const filled = $derived.by(() => {
|
||||||
if (step === "phone") {
|
if (step === "phone") {
|
||||||
@@ -56,14 +55,13 @@
|
|||||||
loginId = "";
|
loginId = "";
|
||||||
await accounts.load();
|
await accounts.load();
|
||||||
if (state.account) {
|
if (state.account) {
|
||||||
accounts.select(state.account.account_id);
|
accounts.switchTo(state.account.account_id);
|
||||||
}
|
}
|
||||||
toasts.success("Аккаунт добавлен");
|
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(error: unknown, fallback: string) {
|
function fail(error: unknown, fallback: MessageKey) {
|
||||||
toasts.error(error instanceof ApiError ? error.detail : fallback);
|
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drop() {
|
function drop() {
|
||||||
@@ -89,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mine === attempt) {
|
if (mine === attempt) {
|
||||||
fail(error, "Не удалось получить QR-код");
|
fail(error, "login.qrFailed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +133,7 @@
|
|||||||
try {
|
try {
|
||||||
await apply(await next());
|
await apply(await next());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error, "Не удалось войти");
|
fail(error, "login.failed");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -159,29 +157,29 @@
|
|||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
|
<Dialog.Title class="dialog-title">{t("login.title")}</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
{#if step === "qr"}
|
{#if step === "qr"}
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<p class="hint">{hints.qr}</p>
|
<p class="hint">{hint("qr")}</p>
|
||||||
<div class="qr-slot">
|
<div class="qr-slot">
|
||||||
{#if qrUrl}
|
{#if qrUrl}
|
||||||
<QrCode label="QR-код для входа в Telegram" value={qrUrl} />
|
<QrCode label={t("login.qrLabel")} value={qrUrl} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<Button onclick={usePhone} pill variant="text">
|
<Button onclick={usePhone} pill variant="text">
|
||||||
Войти по номеру телефона
|
{t("login.byPhone")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit}>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<p class="hint">{hints[step]}</p>
|
<p class="hint">{hint(step)}</p>
|
||||||
{#if step === "phone"}
|
{#if step === "phone"}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
@@ -192,7 +190,7 @@
|
|||||||
type="tel"
|
type="tel"
|
||||||
bind:value={phone}
|
bind:value={phone}
|
||||||
>
|
>
|
||||||
<label for="login-phone">Номер телефона</label>
|
<label for="login-phone">{t("login.phone")}</label>
|
||||||
</div>
|
</div>
|
||||||
{:else if step === "code"}
|
{:else if step === "code"}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -205,7 +203,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={code}
|
bind:value={code}
|
||||||
>
|
>
|
||||||
<label for="login-code">Код подтверждения</label>
|
<label for="login-code">{t("login.code")}</label>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
@@ -213,17 +211,19 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="login-password"
|
id="login-password"
|
||||||
placeholder="Пароль"
|
placeholder={t("login.passwordPlaceholder")}
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
>
|
>
|
||||||
<label for="login-password">Облачный пароль</label>
|
<label for="login-password">{t("login.password")}</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
{#if step === "phone"}
|
{#if step === "phone"}
|
||||||
<Button onclick={useQr} pill variant="text">QR-код</Button>
|
<Button onclick={useQr} pill variant="text">
|
||||||
|
{t("login.qr")}
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
disabled={busy || !filled}
|
disabled={busy || !filled}
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
pill
|
pill
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{step === "phone" ? "Отправить код" : "Продолжить"}
|
{step === "phone" ? t("login.sendCode") : t("login.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -247,6 +247,10 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control:not(:focus)::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.qr-slot {
|
.qr-slot {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { captureLine } from "#lib/format/capture.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { capture } from "#lib/stores/capture.svelte.js";
|
||||||
|
|
||||||
|
const status = $derived(capture.status);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if status}
|
||||||
|
<div class="capture-line" role="status" class:quiet={!status.live}>
|
||||||
|
<span class="mark"><span class="dot"></span></span>
|
||||||
|
<span class="label"
|
||||||
|
>{captureLine(status, accounts.selected?.is_bot ?? false)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.capture-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet .dot {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { accountName } from "#lib/format/peer.js";
|
import { accountName, formatPhone } from "#lib/format/peer.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<AccountAvatar account={current} size={5} />
|
<AccountAvatar account={current} size={5} />
|
||||||
<div class="name">{accountName(current)}</div>
|
<div class="name">{accountName(current)}</div>
|
||||||
{#if current.phone}
|
{#if current.phone}
|
||||||
<div class="phone">+{current.phone}</div>
|
<div class="phone">{formatPhone(current.phone)}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !current.is_active}
|
{#if !current.is_active}
|
||||||
<div class="inactive">{t("account.inactive")}</div>
|
<div class="inactive">{t("account.inactive")}</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
|
import AlertBadge from "#lib/components/alerts/AlertBadge.svelte";
|
||||||
import About from "#lib/components/settings/About.svelte";
|
import About from "#lib/components/settings/About.svelte";
|
||||||
import Appearance from "#lib/components/settings/Appearance.svelte";
|
import Appearance from "#lib/components/settings/Appearance.svelte";
|
||||||
|
import CaptureLine from "#lib/components/settings/CaptureLine.svelte";
|
||||||
import Language from "#lib/components/settings/Language.svelte";
|
import Language from "#lib/components/settings/Language.svelte";
|
||||||
import MyAccount from "#lib/components/settings/MyAccount.svelte";
|
import MyAccount from "#lib/components/settings/MyAccount.svelte";
|
||||||
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
<CaptureLine />
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="settings"
|
icon="settings"
|
||||||
label={t("settings.policy")}
|
label={t("settings.policy")}
|
||||||
@@ -83,6 +85,11 @@
|
|||||||
<AlertBadge inline />
|
<AlertBadge inline />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
<SettingsItem
|
||||||
|
icon="note"
|
||||||
|
label={t("notes.title")}
|
||||||
|
onclick={() => ui.openPanel("notes")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<About />
|
<About />
|
||||||
|
|||||||
@@ -17,43 +17,53 @@
|
|||||||
import QrCode from "#lib/components/ui/QrCode.svelte";
|
import QrCode from "#lib/components/ui/QrCode.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatShareLimits } from "#lib/format/shares.js";
|
import { formatShareLimits } from "#lib/format/shares.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
import { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label?: string;
|
label?: string | null;
|
||||||
onchange?: (share: FileShare | null) => void;
|
onchange?: (share: FileShare | null) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
subject: ShareSubject | null;
|
subject: ShareSubject | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { open = $bindable(), subject, label, onchange }: Props = $props();
|
||||||
open = $bindable(),
|
|
||||||
subject,
|
interface Choice {
|
||||||
label = "файл",
|
label: () => string;
|
||||||
onchange,
|
value: number | null;
|
||||||
}: Props = $props();
|
}
|
||||||
|
|
||||||
const HOUR = 3600;
|
const HOUR = 3600;
|
||||||
const DAY = 24 * HOUR;
|
const DAY = 24 * HOUR;
|
||||||
|
const DAY_HOURS = 24;
|
||||||
|
const WEEK_DAYS = 7;
|
||||||
|
const MONTH_DAYS = 30;
|
||||||
|
|
||||||
const KEEP = -1;
|
const KEEP = -1;
|
||||||
|
const LIMITS = [1, 5, 25, 100];
|
||||||
|
|
||||||
const expiryChoices = [
|
const expiryChoices: Choice[] = [
|
||||||
{ label: "Бессрочно", value: null },
|
{ label: () => t("shares.noExpiry"), value: null },
|
||||||
{ label: "1 час", value: HOUR },
|
{ label: () => t("shares.hours", { count: 1 }), value: HOUR },
|
||||||
{ label: "24 часа", value: DAY },
|
{ label: () => t("shares.hours", { count: DAY_HOURS }), value: DAY },
|
||||||
{ label: "7 дней", value: 7 * DAY },
|
{
|
||||||
{ label: "30 дней", value: 30 * DAY },
|
label: () => t("shares.days", { count: WEEK_DAYS }),
|
||||||
|
value: WEEK_DAYS * DAY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: () => t("shares.days", { count: MONTH_DAYS }),
|
||||||
|
value: MONTH_DAYS * DAY,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const keepChoice = { label: "Как есть", value: KEEP };
|
const keepChoice: Choice = { label: () => t("shares.keep"), value: KEEP };
|
||||||
|
|
||||||
const limitChoices = [
|
const limitChoices: Choice[] = [
|
||||||
{ label: "Без лимита", value: null },
|
{ label: () => t("shares.noLimit"), value: null },
|
||||||
{ label: "1", value: 1 },
|
...LIMITS.map((value) => ({ label: () => String(value), value })),
|
||||||
{ label: "5", value: 5 },
|
|
||||||
{ label: "25", value: 25 },
|
|
||||||
{ label: "100", value: 100 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let share = $state<FileShare | null>(null);
|
let share = $state<FileShare | null>(null);
|
||||||
@@ -63,6 +73,8 @@
|
|||||||
let maxDownloads = $state<number | null>(null);
|
let maxDownloads = $state<number | null>(null);
|
||||||
let showQr = $state(false);
|
let showQr = $state(false);
|
||||||
let token = 0;
|
let token = 0;
|
||||||
|
const saved = createFlash<number>();
|
||||||
|
const revoked = createFlash<ShareSubject>();
|
||||||
|
|
||||||
const settings = $derived<ShareSettings>({
|
const settings = $derived<ShareSettings>({
|
||||||
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
|
expiresInSeconds: expiresIn === KEEP ? null : expiresIn,
|
||||||
@@ -73,8 +85,8 @@
|
|||||||
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
|
share?.expires_at ? [keepChoice, ...expiryChoices] : expiryChoices
|
||||||
);
|
);
|
||||||
|
|
||||||
function fail(error: unknown, fallback: string) {
|
function fail(error: unknown, fallback: MessageKey) {
|
||||||
toasts.error(error instanceof ApiError ? error.detail : fallback);
|
toasts.error(error instanceof ApiError ? error.detail : t(fallback));
|
||||||
}
|
}
|
||||||
|
|
||||||
function adopt(next: FileShare | null) {
|
function adopt(next: FileShare | null) {
|
||||||
@@ -99,7 +111,7 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
fail(error, "Не удалось проверить ссылку");
|
fail(error, "shares.checkFailed");
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,9 +128,8 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
adopt(await createShare(subject, settings));
|
adopt(await createShare(subject, settings));
|
||||||
toasts.success("Доступ по ссылке открыт");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error, "Не удалось открыть доступ");
|
fail(error, "shares.openFailed");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -130,27 +141,28 @@
|
|||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
adopt(await updateShare(share.id, settings));
|
const next = await updateShare(share.id, settings);
|
||||||
toasts.success("Настройки ссылки обновлены");
|
adopt(next);
|
||||||
|
saved.show(next.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error, "Не удалось обновить ссылку");
|
fail(error, "shares.updateFailed");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke() {
|
async function revoke() {
|
||||||
if (!share || busy) {
|
if (!(share && subject) || busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const target = subject;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await revokeShare(share.id);
|
await revokeShare(share.id);
|
||||||
adopt(null);
|
adopt(null);
|
||||||
toasts.success("Доступ отозван");
|
revoked.show(target);
|
||||||
open = false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error, "Не удалось отозвать доступ");
|
fail(error, "shares.revokeFailed");
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -172,8 +184,8 @@
|
|||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">Доступ по ссылке</Dialog.Title>
|
<Dialog.Title class="dialog-title">{t("ui.shareLink")}</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
@@ -203,25 +215,24 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
|
<Icon name={showQr ? "collapse" : "webapp"} size="1rem" />
|
||||||
{showQr ? "Скрыть QR-код" : "Показать QR-код"}
|
{showQr ? t("shares.qr.hide") : t("shares.qr.show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showQr}
|
{#if showQr}
|
||||||
<div class="qr-slot">
|
<div class="qr-slot">
|
||||||
<QrCode label="QR-код ссылки на файл" value={shareUrl(share)} />
|
<QrCode label={t("shares.qr.label")} value={shareUrl(share)} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="hint raw">
|
<p class="hint raw">
|
||||||
Ссылка отдаёт файл как есть: картинки и видео откроются прямо в
|
{t("shares.raw.before")}<code>curl -O {shareUrl(share)}</code>
|
||||||
браузере, <code>curl -O {shareUrl(share)}</code> скачает под
|
{t("shares.raw.after")}
|
||||||
настоящим именем. Превью-краулеры Telegram скачивания не списывают.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<fieldset class="choices">
|
<fieldset class="choices">
|
||||||
<legend>Лимит скачиваний</legend>
|
<legend>{t("shares.limit")}</legend>
|
||||||
<div class="segments">
|
<div class="segments">
|
||||||
{#each limitChoices as choice (choice.label)}
|
{#each limitChoices as choice (choice.value)}
|
||||||
<button
|
<button
|
||||||
class="segment"
|
class="segment"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -230,16 +241,16 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={maxDownloads === choice.value}
|
class:selected={maxDownloads === choice.value}
|
||||||
>
|
>
|
||||||
{choice.label}
|
{choice.label()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="choices">
|
<fieldset class="choices">
|
||||||
<legend>Срок жизни ссылки</legend>
|
<legend>{t("shares.lifetime")}</legend>
|
||||||
<div class="segments">
|
<div class="segments">
|
||||||
{#each liveExpiryChoices as choice (choice.label)}
|
{#each liveExpiryChoices as choice (choice.value)}
|
||||||
<button
|
<button
|
||||||
class="segment"
|
class="segment"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -248,7 +259,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={expiresIn === choice.value}
|
class:selected={expiresIn === choice.value}
|
||||||
>
|
>
|
||||||
{choice.label}
|
{choice.label()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -259,15 +270,15 @@
|
|||||||
><Icon name="lock" size="1.125rem" /></span
|
><Icon name="lock" size="1.125rem" /></span
|
||||||
>
|
>
|
||||||
<div class="subject-text">
|
<div class="subject-text">
|
||||||
<strong>{label}</strong>
|
<strong>{label ?? t("shares.file")}</strong>
|
||||||
<span>Сейчас доступен только вам</span>
|
<span>{t("shares.private")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="choices">
|
<fieldset class="choices">
|
||||||
<legend>Срок жизни ссылки</legend>
|
<legend>{t("shares.lifetime")}</legend>
|
||||||
<div class="segments">
|
<div class="segments">
|
||||||
{#each expiryChoices as choice (choice.label)}
|
{#each expiryChoices as choice (choice.value)}
|
||||||
<button
|
<button
|
||||||
class="segment"
|
class="segment"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -276,16 +287,16 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={expiresIn === choice.value}
|
class:selected={expiresIn === choice.value}
|
||||||
>
|
>
|
||||||
{choice.label}
|
{choice.label()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="choices">
|
<fieldset class="choices">
|
||||||
<legend>Лимит скачиваний</legend>
|
<legend>{t("shares.limit")}</legend>
|
||||||
<div class="segments">
|
<div class="segments">
|
||||||
{#each limitChoices as choice (choice.label)}
|
{#each limitChoices as choice (choice.value)}
|
||||||
<button
|
<button
|
||||||
class="segment"
|
class="segment"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -294,29 +305,38 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={maxDownloads === choice.value}
|
class:selected={maxDownloads === choice.value}
|
||||||
>
|
>
|
||||||
{choice.label}
|
{choice.label()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">{t("shares.publicHint")}</p>
|
||||||
Файл откроется всем, у кого есть ссылка. Отозвать можно в любой
|
|
||||||
момент.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
{#if share}
|
{#if subject && revoked.current === subject}
|
||||||
|
<p class="done">
|
||||||
|
<Icon name="check" size="1.25rem" />
|
||||||
|
{t("shares.accessRevoked")}
|
||||||
|
</p>
|
||||||
|
{:else if share}
|
||||||
<Button loading={busy} onclick={revoke} pill variant="danger">
|
<Button loading={busy} onclick={revoke} pill variant="danger">
|
||||||
Отозвать
|
{t("shares.revoke")}
|
||||||
|
</Button>
|
||||||
|
<Button loading={busy} onclick={applyLimits} pill>
|
||||||
|
{#if saved.current === share.id}
|
||||||
|
<Icon name="check" />
|
||||||
|
{t("shares.saved")}
|
||||||
|
{:else}
|
||||||
|
{t("common.save")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={busy} onclick={applyLimits} pill>Сохранить</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button fluid loading={busy} onclick={publish} pill>
|
<Button fluid loading={busy} onclick={publish} pill>
|
||||||
Открыть доступ
|
{t("shares.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,6 +346,20 @@
|
|||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.done {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: 3rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-green);
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скопировать ссылку");
|
toasts.error(t("shares.copyFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
copied = true;
|
copied = true;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
import {
|
import {
|
||||||
deleteShare,
|
deleteShare,
|
||||||
listShares,
|
listShares,
|
||||||
@@ -11,13 +12,16 @@
|
|||||||
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
|
import ShareLinkField from "#lib/components/shares/ShareLinkField.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
|
import CopyItem from "#lib/components/ui/CopyItem.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
import { formatDownloads, formatExpiry, plural } from "#lib/format/shares.js";
|
import { formatDownloads, formatExpiry } from "#lib/format/shares.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 { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -25,29 +29,23 @@
|
|||||||
|
|
||||||
type Filter = "active" | "all";
|
type Filter = "active" | "all";
|
||||||
|
|
||||||
const stateLabels: Record<string, string> = {
|
|
||||||
active: "Открыт",
|
|
||||||
exhausted: "Лимит исчерпан",
|
|
||||||
expired: "Истёк",
|
|
||||||
revoked: "Отозван",
|
|
||||||
};
|
|
||||||
|
|
||||||
let items = $state<FileShare[]>([]);
|
let items = $state<FileShare[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let filter = $state<Filter>("active");
|
let filter = $state<Filter>("active");
|
||||||
let fetchedAll = $state(false);
|
|
||||||
let expanded = $state<number | null>(null);
|
let expanded = $state<number | null>(null);
|
||||||
let token = 0;
|
let token = 0;
|
||||||
|
const kept = new SvelteSet<number>();
|
||||||
|
const done = createFlash<number>();
|
||||||
|
|
||||||
const visible = $derived(
|
const visible = $derived(
|
||||||
filter === "active"
|
filter === "active"
|
||||||
? items.filter((item) => shareState(item) === "active")
|
? items.filter(
|
||||||
|
(item) => kept.has(item.id) || shareState(item) === "active"
|
||||||
|
)
|
||||||
: items
|
: items
|
||||||
);
|
);
|
||||||
const activeCount = $derived(
|
const activeCount = $derived(
|
||||||
fetchedAll
|
items.filter((item) => shareState(item) === "active").length
|
||||||
? items.filter((item) => shareState(item) === "active").length
|
|
||||||
: items.length
|
|
||||||
);
|
);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -59,11 +57,11 @@
|
|||||||
const rows = await listShares(activeOnly);
|
const rows = await listShares(activeOnly);
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
items = rows;
|
items = rows;
|
||||||
fetchedAll = !activeOnly;
|
kept.clear();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
toasts.error("Не удалось загрузить список ссылок");
|
toasts.error(t("shares.listFailed"));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
@@ -89,26 +87,22 @@
|
|||||||
ui.closePanel();
|
ui.closePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy(share: FileShare) {
|
|
||||||
await navigator.clipboard.writeText(shareUrl(share));
|
|
||||||
toasts.success("Ссылка скопирована");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke(share: FileShare) {
|
async function revoke(share: FileShare) {
|
||||||
try {
|
try {
|
||||||
replace(await revokeShare(share.id));
|
replace(await revokeShare(share.id));
|
||||||
toasts.success("Доступ отозван");
|
kept.add(share.id);
|
||||||
|
done.show(share.id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось отозвать доступ");
|
toasts.error(t("shares.revokeFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reissue(share: FileShare) {
|
async function reissue(share: FileShare) {
|
||||||
try {
|
try {
|
||||||
replace(await reissueShare(share.id));
|
replace(await reissueShare(share.id));
|
||||||
toasts.success("Выдана новая ссылка");
|
done.show(share.id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось перевыпустить ссылку");
|
toasts.error(t("shares.reissueFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +111,7 @@
|
|||||||
await deleteShare(share.id);
|
await deleteShare(share.id);
|
||||||
items = items.filter((item) => item.id !== share.id);
|
items = items.filter((item) => item.id !== share.id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось удалить запись");
|
toasts.error(t("shares.forgetFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +136,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={filter === "active"}
|
class:selected={filter === "active"}
|
||||||
>
|
>
|
||||||
Открытые{activeCount ? ` · ${activeCount}` : ""}
|
{t("shares.tab.active")}{activeCount ? ` · ${activeCount}` : ""}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab"
|
class="tab"
|
||||||
@@ -152,11 +146,11 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:selected={filter === "all"}
|
class:selected={filter === "all"}
|
||||||
>
|
>
|
||||||
Все
|
{t("shares.tab.all")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="Обновить"
|
aria-label={t("ui.refresh")}
|
||||||
class="reload"
|
class="reload"
|
||||||
onclick={() => load()}
|
onclick={() => load()}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -169,8 +163,8 @@
|
|||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else if visible.length === 0}
|
{:else if visible.length === 0}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Откройте доступ к файлу из меню медиа"
|
description={t("shares.empty.hint")}
|
||||||
title="Ничего не открыто"
|
title={t("shares.empty.title")}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
@@ -198,14 +192,14 @@
|
|||||||
<span class="text">
|
<span class="text">
|
||||||
<span class="name">{share.file_name}</span>
|
<span class="name">{share.file_name}</span>
|
||||||
<span class="sub">
|
<span class="sub">
|
||||||
{stateLabels[state]}
|
{t(`shares.state.${state}`)}
|
||||||
· {formatDownloads(share)}
|
· {formatDownloads(share)}
|
||||||
{#if share.file_size}
|
{#if share.file_size}
|
||||||
· {formatBytes(share.file_size)}
|
· {formatBytes(share.file_size)}
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="sub">
|
<span class="sub">
|
||||||
{share.title ?? "Без чата"}
|
{share.title ?? t("shares.noChat")}
|
||||||
· {formatExpiry(share)}
|
· {formatExpiry(share)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -215,12 +209,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="copy" onselect={() => copy(share)}>
|
<CopyItem
|
||||||
Копировать ссылку
|
done={t("shares.copied")}
|
||||||
</ContextMenuItem>
|
icon="copy"
|
||||||
|
label={t("shares.copy")}
|
||||||
|
text={() => shareUrl(share)}
|
||||||
|
/>
|
||||||
{#if share.message_id !== null || share.peer_id !== null}
|
{#if share.message_id !== null || share.peer_id !== null}
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
|
<ContextMenuItem icon="reply" onselect={() => jump(share)}>
|
||||||
Перейти к сообщению
|
{t("ui.jumpToMessage")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state === "active"}
|
{#if state === "active"}
|
||||||
@@ -228,14 +225,14 @@
|
|||||||
icon="link-broken"
|
icon="link-broken"
|
||||||
onselect={() => revoke(share)}
|
onselect={() => revoke(share)}
|
||||||
>
|
>
|
||||||
Отозвать
|
{t("shares.revoke")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
|
<ContextMenuItem icon="replace" onselect={() => reissue(share)}>
|
||||||
Выдать новую ссылку
|
{t("shares.reissue")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
|
<ContextMenuItem icon="delete" onselect={() => forget(share)}>
|
||||||
Убрать из списка
|
{t("shares.forget")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -246,22 +243,31 @@
|
|||||||
<ShareLinkField url={shareUrl(share)} />
|
<ShareLinkField url={shareUrl(share)} />
|
||||||
{#if share.last_download_at}
|
{#if share.last_download_at}
|
||||||
<p class="last">
|
<p class="last">
|
||||||
Последнее скачивание: {formatFull(share.last_download_at)}
|
{t("shares.lastDownload", {
|
||||||
|
date: formatFull(share.last_download_at),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if share.message_id !== null || share.peer_id !== null}
|
{#if share.message_id !== null || share.peer_id !== null}
|
||||||
<button class="pill" onclick={() => jump(share)} type="button">
|
<button class="pill" onclick={() => jump(share)} type="button">
|
||||||
<Icon name="reply" size="1rem" />К сообщению
|
<Icon name="reply" size="1rem" />{t("shares.toMessage")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state === "active"}
|
{#if done.current === share.id}
|
||||||
|
<span class="done">
|
||||||
|
<Icon name="check" size="1rem" />
|
||||||
|
{state === "active"
|
||||||
|
? t("shares.reissued")
|
||||||
|
: t("shares.accessRevoked")}
|
||||||
|
</span>
|
||||||
|
{:else if state === "active"}
|
||||||
<button
|
<button
|
||||||
class="pill danger"
|
class="pill danger"
|
||||||
onclick={() => revoke(share)}
|
onclick={() => revoke(share)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="link-broken" size="1rem" />Отозвать
|
<Icon name="link-broken" size="1rem" />{t("shares.revoke")}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -269,7 +275,7 @@
|
|||||||
onclick={() => reissue(share)}
|
onclick={() => reissue(share)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="replace" size="1rem" />Новая ссылка
|
<Icon name="replace" size="1rem" />{t("shares.newLink")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,9 +285,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<p class="footnote">
|
<p class="footnote">
|
||||||
{visible.length}
|
{t("shares.footnote", { count: visible.length })}
|
||||||
{plural(visible.length, "ссылка", "ссылки", "ссылок")}
|
|
||||||
· превью-краулеры не списывают скачивания
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -471,6 +475,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.done {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
padding: 0.3125rem 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-green);
|
||||||
|
}
|
||||||
|
|
||||||
.footnote {
|
.footnote {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 1rem;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -90,11 +91,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null || messageId === null}
|
{#if chatId === null || messageId === null}
|
||||||
<EmptyState description="Сообщение не выбрано" title="Callback-данные" />
|
<EmptyState description={t("ui.noMessage")} title={t("social.callbacks")} />
|
||||||
{:else if loading && items.length === 0}
|
{:else if loading && items.length === 0}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<EmptyState title="Нет callback-кнопок" />
|
<EmptyState title={t("social.callbacks.empty")} />
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{#each items as item (item.position)}
|
{#each items as item (item.position)}
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if value}
|
{#if value}
|
||||||
<button
|
<button
|
||||||
aria-label="Скопировать"
|
aria-label={t("ui.copy")}
|
||||||
class="copy"
|
class="copy"
|
||||||
onclick={() => copy(item.position, value)}
|
onclick={() => copy(item.position, value)}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -6,6 +6,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 { safeHref } from "#lib/format/url.js";
|
import { safeHref } from "#lib/format/url.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 { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -56,11 +57,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null || messageId === null}
|
{#if chatId === null || messageId === null}
|
||||||
<EmptyState description="Сообщение не выбрано" title="Ссылки" />
|
<EmptyState description={t("ui.noMessage")} title={t("social.links")} />
|
||||||
{:else if loading && items.length === 0}
|
{:else if loading && items.length === 0}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<EmptyState title="Нет ссылок" />
|
<EmptyState title={t("social.links.empty")} />
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
{#each items as item (item.position)}
|
{#each items as item (item.position)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
import { peerColorIndex, peerName } from "#lib/format/peer.js";
|
import { peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -65,11 +66,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId === null || messageId === null}
|
{#if chatId === null || messageId === null}
|
||||||
<EmptyState description="Сообщение не выбрано" title="Реакции" />
|
<EmptyState description={t("ui.noMessage")} title={t("social.reactions")} />
|
||||||
{:else if loading && items.length === 0}
|
{:else if loading && items.length === 0}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<EmptyState title="Нет реакций" />
|
<EmptyState title={t("social.reactions.empty")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="groups">
|
<div class="groups">
|
||||||
{#each groups as [reaction, reactors] (reaction)}
|
{#each groups as [reaction, reactors] (reaction)}
|
||||||
@@ -104,7 +105,9 @@
|
|||||||
>
|
>
|
||||||
<span class="time">
|
<span class="time">
|
||||||
{#if reactor.removed_at}
|
{#if reactor.removed_at}
|
||||||
снято · {formatFull(reactor.removed_at)}
|
{t("social.reactions.removed", {
|
||||||
|
date: formatFull(reactor.removed_at),
|
||||||
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
{formatFull(reactor.added_at)}
|
{formatFull(reactor.added_at)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
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 { peerName } from "#lib/format/peer.js";
|
import { peerName } from "#lib/format/peer.js";
|
||||||
import { poster } from "#lib/media/poster.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 { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
let viewerItems = $state<StoryView[]>([]);
|
let viewerItems = $state<StoryView[]>([]);
|
||||||
let viewerPeerId = $state(0);
|
let viewerPeerId = $state(0);
|
||||||
let backfilling = $state<number | null>(null);
|
let backfilling = $state<number | null>(null);
|
||||||
|
const started = createFlash<number>();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const current = token;
|
const current = token;
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
return {
|
return {
|
||||||
hasAvatar: Boolean(chat?.has_avatar),
|
hasAvatar: Boolean(chat?.has_avatar),
|
||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
name: chat?.title ?? `Chat ${peerId}`,
|
name: chat?.title ?? t("ui.chat", { id: peerId }),
|
||||||
peerId,
|
peerId,
|
||||||
stories: peerStories,
|
stories: peerStories,
|
||||||
};
|
};
|
||||||
@@ -150,9 +152,9 @@
|
|||||||
backfilling = peerId;
|
backfilling = peerId;
|
||||||
try {
|
try {
|
||||||
await enqueueStoriesBackfill(peerId);
|
await enqueueStoriesBackfill(peerId);
|
||||||
toasts.success("Загружаем старые сторис");
|
started.show(peerId);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось запустить загрузку сторис");
|
toasts.error(t("stories.backfillFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
backfilling = null;
|
backfilling = null;
|
||||||
}
|
}
|
||||||
@@ -174,10 +176,14 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState title="Нет сторис" />
|
<EmptyState title={t("stories.empty")} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each groups as group (group.peerId)}
|
{#each groups as group (group.peerId)}
|
||||||
|
{@const fresh = started.current === group.peerId}
|
||||||
|
{@const backfillLabel = fresh
|
||||||
|
? t("stories.backfillStarted")
|
||||||
|
: t("stories.loadOlderStories")}
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<header class="group-head">
|
<header class="group-head">
|
||||||
<button
|
<button
|
||||||
@@ -200,14 +206,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Загрузить старые сторис"
|
aria-label={backfillLabel}
|
||||||
loading={backfilling === group.peerId}
|
loading={backfilling === group.peerId}
|
||||||
onclick={() => backfill(group.peerId)}
|
onclick={() => backfill(group.peerId)}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
|
title={backfillLabel}
|
||||||
variant="translucent"
|
variant="translucent"
|
||||||
>
|
>
|
||||||
<Icon name="cloud-download" />
|
<Icon name={fresh ? "check" : "cloud-download"} />
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
{#if expanded[group.peerId]}
|
{#if expanded[group.peerId]}
|
||||||
@@ -295,62 +302,4 @@
|
|||||||
gap: 0.125rem;
|
gap: 0.125rem;
|
||||||
padding: 0 0.125rem 0.125rem;
|
padding: 0 0.125rem 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 9 / 16;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
|
|
||||||
&.expired {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile img,
|
|
||||||
.tile video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-white);
|
|
||||||
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ph {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.125rem;
|
|
||||||
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--color-white);
|
|
||||||
background-color: rgb(0 0 0 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.views {
|
|
||||||
bottom: 0.25rem;
|
|
||||||
left: 0.25rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { poster } from "#lib/media/poster.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 { createFlash } from "#lib/stores/flash.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let backfilling = $state(false);
|
let backfilling = $state(false);
|
||||||
|
const started = createFlash<number>();
|
||||||
|
|
||||||
let items = $state<StoryView[]>([]);
|
let items = $state<StoryView[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -109,9 +111,9 @@
|
|||||||
backfilling = true;
|
backfilling = true;
|
||||||
try {
|
try {
|
||||||
await enqueueStoriesBackfill(id);
|
await enqueueStoriesBackfill(id);
|
||||||
toasts.success("Загружаем старые сторис");
|
started.show(id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось запустить загрузку сторис");
|
toasts.error(t("stories.backfillFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
backfilling = false;
|
backfilling = false;
|
||||||
}
|
}
|
||||||
@@ -127,7 +129,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if peerId === null}
|
{#if peerId === null}
|
||||||
<EmptyState description="Откройте чат" title="Сторис" />
|
<EmptyState description={t("stories.openChat")} title={t("stories.title")} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<Button
|
<Button
|
||||||
@@ -137,10 +139,14 @@
|
|||||||
smaller
|
smaller
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
<Icon name="cloud-download" />Загрузить старые
|
{#if started.current === peerId}
|
||||||
|
<Icon name="check" />{t("stories.backfillStarted")}
|
||||||
|
{:else}
|
||||||
|
<Icon name="cloud-download" />{t("stories.loadOlder")}
|
||||||
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Обновить"
|
aria-label={t("ui.refresh")}
|
||||||
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
|
onclick={() => peerId !== null && reload(peerId).catch(() => undefined)}
|
||||||
round
|
round
|
||||||
smaller
|
smaller
|
||||||
@@ -155,8 +161,8 @@
|
|||||||
<div class="center"><Spinner /></div>
|
<div class="center"><Spinner /></div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="Нажмите «Загрузить старые», чтобы забрать архив"
|
description={t("stories.emptyHint")}
|
||||||
title="Нет сторис"
|
title={t("stories.empty")}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -177,7 +183,7 @@
|
|||||||
onclick={() => peerId !== null && loadMore(peerId)}
|
onclick={() => peerId !== null && loadMore(peerId)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{loading ? "Загрузка…" : "Показать ещё"}
|
{loading ? t("ui.loading") : t("ui.showMore")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -212,69 +218,6 @@
|
|||||||
padding: 0.125rem;
|
padding: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 9 / 16;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
|
|
||||||
&.expired {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile img,
|
|
||||||
.tile video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-white);
|
|
||||||
text-shadow: 0 0 4px rgb(0 0 0 / 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ph {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.125rem;
|
|
||||||
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--color-white);
|
|
||||||
background-color: rgb(0 0 0 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pin {
|
|
||||||
top: 0.25rem;
|
|
||||||
left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.views {
|
|
||||||
bottom: 0.25rem;
|
|
||||||
left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
.more {
|
||||||
width: calc(100% - 1rem);
|
width: calc(100% - 1rem);
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
@@ -20,14 +21,16 @@
|
|||||||
const { story, preview, onopen }: Props = $props();
|
const { story, preview, onopen }: Props = $props();
|
||||||
|
|
||||||
const label = $derived(
|
const label = $derived(
|
||||||
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
|
story.date
|
||||||
|
? t("stories.from", { date: formatFull(story.date) })
|
||||||
|
: t("stories.undated")
|
||||||
);
|
);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
await downloadStory(story.peer_id, story.story_id);
|
await downloadStory(story.peer_id, story.story_id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать сторис");
|
toasts.error(t("stories.downloadFailed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,25 +81,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if story.views}
|
{#if story.views}
|
||||||
<span class="badge views">
|
<span class="badge views">
|
||||||
<Icon name="eye" size="0.875rem" />{story.views}
|
<Icon name="eye" size="0.875rem" />
|
||||||
|
{story.views.toLocaleString(locale.tag)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}>
|
||||||
Открыть
|
{t("ui.open")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{#if story.downloaded}
|
{#if story.downloaded}
|
||||||
<ContextMenuItem icon="download" onselect={save}>
|
<ContextMenuItem icon="download" onselect={save}>
|
||||||
Скачать
|
{t("ui.download")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
<ContextMenuItem icon="allow-share" onselect={share}>
|
||||||
Доступ по ссылке
|
{t("ui.shareLink")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuItem icon="info" onselect={openAuthor}>
|
<ContextMenuItem icon="info" onselect={openAuthor}>
|
||||||
Профиль автора
|
{t("stories.author")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -9,6 +9,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 { formatFull } from "#lib/format/datetime.js";
|
import { formatFull } from "#lib/format/datetime.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.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";
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadStory(story.peer_id, story.story_id);
|
await downloadStory(story.peer_id, story.story_id);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать сторис");
|
toasts.error(t("stories.downloadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,9 @@
|
|||||||
}
|
}
|
||||||
shareUi.share(
|
shareUi.share(
|
||||||
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
|
{ kind: "story", peerId: story.peer_id, storyId: story.story_id },
|
||||||
`Сторис от ${story.date ? formatFull(story.date) : "неизвестной даты"}`
|
story.date
|
||||||
|
? t("stories.from", { date: formatFull(story.date) })
|
||||||
|
: t("stories.undated")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +188,9 @@
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="story-overlay" />
|
<Dialog.Overlay class="story-overlay" />
|
||||||
<Dialog.Content class="story-content {held ? "held" : ""}">
|
<Dialog.Content class="story-content {held ? "held" : ""}">
|
||||||
<Dialog.Title class="story-a11y-title">Сторис</Dialog.Title>
|
<Dialog.Title class="story-a11y-title">
|
||||||
|
{t("stories.title")}
|
||||||
|
</Dialog.Title>
|
||||||
<div class="bars">
|
<div class="bars">
|
||||||
{#each items as item, i (item.story_id)}
|
{#each items as item, i (item.story_id)}
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
@@ -214,7 +219,7 @@
|
|||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
{#if isVideo}
|
{#if isVideo}
|
||||||
<button
|
<button
|
||||||
aria-label={muted ? "Включить звук" : "Выключить звук"}
|
aria-label={muted ? t("stories.unmute") : t("stories.mute")}
|
||||||
class="round"
|
class="round"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
muted = !muted;
|
muted = !muted;
|
||||||
@@ -231,7 +236,7 @@
|
|||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
aria-label="Скачать сторис"
|
aria-label={t("stories.download")}
|
||||||
class="round"
|
class="round"
|
||||||
onclick={save}
|
onclick={save}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -241,15 +246,15 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<ContextMenuItem icon="download" onselect={save}>
|
<ContextMenuItem icon="download" onselect={save}>
|
||||||
Скачать
|
{t("ui.download")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
<ContextMenuItem icon="allow-share" onselect={share}>
|
||||||
Доступ по ссылке
|
{t("ui.shareLink")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
{/if}
|
{/if}
|
||||||
<Dialog.Close aria-label="Закрыть" class="round">
|
<Dialog.Close aria-label={t("common.close")} class="round">
|
||||||
<Icon name="close" size="1.5rem" />
|
<Icon name="close" size="1.5rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +288,7 @@
|
|||||||
src={url}
|
src={url}
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="missing">Медиа недоступно</p>
|
<p class="missing">{t("stories.unavailable")}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,12 +301,13 @@
|
|||||||
|
|
||||||
{#if story?.views}
|
{#if story?.views}
|
||||||
<span class="view-count">
|
<span class="view-count">
|
||||||
<Icon name="eye" size="1rem" />{story.views}
|
<Icon name="eye" size="1rem" />
|
||||||
|
{story.views.toLocaleString(locale.tag)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-label="Назад"
|
aria-label={t("ui.previous")}
|
||||||
class="tap prev"
|
class="tap prev"
|
||||||
onclick={() => onTap(-1)}
|
onclick={() => onTap(-1)}
|
||||||
onpointercancel={releaseHold}
|
onpointercancel={releaseHold}
|
||||||
@@ -309,7 +315,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
></button>
|
></button>
|
||||||
<button
|
<button
|
||||||
aria-label="Вперёд"
|
aria-label={t("ui.next")}
|
||||||
class="tap next"
|
class="tap next"
|
||||||
onclick={() => onTap(1)}
|
onclick={() => onTap(1)}
|
||||||
onpointercancel={releaseHold}
|
onpointercancel={releaseHold}
|
||||||
@@ -318,7 +324,7 @@
|
|||||||
></button>
|
></button>
|
||||||
|
|
||||||
{#if held}
|
{#if held}
|
||||||
<span class="hold-hint">Пауза</span>
|
<span class="hold-hint">{t("stories.paused")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -33,13 +33,14 @@
|
|||||||
loading = false,
|
loading = false,
|
||||||
type = "button",
|
type = "button",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
class: className,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="Button {variant}"
|
class={["Button", variant, className]}
|
||||||
{disabled}
|
{disabled}
|
||||||
{type}
|
{type}
|
||||||
class:disabled
|
class:disabled
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 0.375rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import { createContext } from "svelte";
|
||||||
|
|
||||||
type PointerHandler = (event: PointerEvent) => void;
|
type PointerHandler = (event: PointerEvent) => void;
|
||||||
|
|
||||||
const claimed = new WeakSet<Event>();
|
const claimed = new WeakSet<Event>();
|
||||||
|
|
||||||
|
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -18,6 +22,10 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let suppressClick = false;
|
let suppressClick = false;
|
||||||
|
|
||||||
|
provideMenuCloser(() => {
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
|
||||||
function triggerProps(props: Record<string, unknown>) {
|
function triggerProps(props: Record<string, unknown>) {
|
||||||
const onpointerdown = props.onpointerdown as PointerHandler;
|
const onpointerdown = props.onpointerdown as PointerHandler;
|
||||||
const onpointerup = props.onpointerup as PointerHandler;
|
const onpointerup = props.onpointerup as PointerHandler;
|
||||||
@@ -55,7 +63,12 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Portal>
|
<ContextMenu.Portal>
|
||||||
<ContextMenu.Content class="bg-menu-content">
|
<ContextMenu.Content
|
||||||
|
align="start"
|
||||||
|
class="bg-menu-content"
|
||||||
|
collisionPadding={8}
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
{@render menu()}
|
{@render menu()}
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
</ContextMenu.Portal>
|
</ContextMenu.Portal>
|
||||||
|
|||||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user