Compare commits

..
16 Commits
Author SHA1 Message Date
hh 0362777a51 fix(frontend): opened policy rule stays in view while its card grows, chevrons hold one column, switch and field sizes match the rest 2026-10-14 04:31:18 +02:00
hh dfc726976b fix(frontend): policy card keeps its wells in the light theme, folder rules show their content, opened rule scrolls into view, rule removal asks first 2026-10-14 04:27:59 +02:00
hh cb01d7dff8 feat(frontend): capture policy shows what each rule changes, rule card opens from folder and chat menus and can trash downloaded files, trash is paged 2026-10-14 04:20:38 +02:00
hh f646f29ff9 feat(backend): policy rules show what they inherit, files of a chat or folder go to trash in one batch, trash list is paged 2026-10-14 04:13:13 +02:00
hh 945adcf879 fix(backend): userbot follows kurigram 2.2.26, user and chat parsing is awaited, missing chat is handled 2026-10-14 03:55:33 +02:00
renovate Bot d213dfdf64 chore(deps): update dependency uv_build to >=0.13.0,<0.14.0 (dev) (#19)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 03:54:09 +02:00
renovate Bot 566ee09431 chore(deps): update code dependencies (dev) (#17)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-14 03:54:09 +02:00
hh 09c5f78061 fix(frontend,backend): all stories panel groups by peer again, missing trashed file answers 404, access log hides the token 2026-10-14 03:53:56 +02:00
hh 897421e7c4 fix(frontend): storage failures use the error colour next to the warning, shared mark on its own line in the list, trash key is visible 2026-10-14 03:28:29 +02:00
hh 3223f62dea fix(frontend): selected storage chip keeps its fill in the light theme, tile label uses the medium weight 2026-10-14 03:24:31 +02:00
hh c9c088ed73 fix(frontend): shared mark on its own line in trash rows, undo ignores the trailing click, trash segment has a key 2026-10-14 03:23:22 +02:00
hh 580927da0a fix(frontend,backend): storage categories keep one hue in both themes, trash counted in the total, undo keeps the list, larger touch targets 2026-10-14 03:18:59 +02:00
hh 06ae3e928f refactor(frontend): storage panel answers in place instead of toasts, empty groups hidden, weight bar animates by transform 2026-10-14 03:14:51 +02:00
hh 55634984fd feat(frontend,backend): storage panel with weight rows and trash, heavy media streams by url in the viewer 2026-10-14 03:09:37 +02:00
hh f2c245707a feat(backend): storage trash with seven day retention, trashed files are not fetched again automatically 2026-10-14 02:52:21 +02:00
renovate Bot c20e132872 chore(deps): pin timescale/timescaledb docker tag to b346edc (main) (#16)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-10 23:22:30 +00:00
56 changed files with 4740 additions and 412 deletions

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ services:
database:
postgres:
image: timescale/timescaledb:2.30.2-pg17
image: timescale/timescaledb:2.30.2-pg17@sha256:b346edcdb51a1fd6020e3965e0bd1c9f3406fa6d5fbce1e28f4852587ef934e2
profiles: [postgres, external]
restart: unless-stopped
environment:
@@ -0,0 +1,57 @@
"""media trash
Revision ID: c9e2a7f4b1d8
Revises: b6d1f4a82e57
Create Date: 2026-10-14 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "c9e2a7f4b1d8"
down_revision: str | None = "b6d1f4a82e57"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TRASH = """
CREATE TABLE media_trash (
storage_key text PRIMARY KEY,
account_id integer NOT NULL,
chat_id bigint NOT NULL,
message_id bigint NOT NULL,
kind text NOT NULL,
mime text,
file_name text NOT NULL,
file_size bigint,
trashed_at timestamptz NOT NULL DEFAULT now()
)
"""
def upgrade() -> None:
op.execute(
"ALTER TABLE media ADD COLUMN evicted_at timestamptz, "
"ADD COLUMN evicted_key text"
)
op.execute(
"CREATE INDEX ix_media_evicted_unique ON media (unique_id) "
"WHERE evicted_at IS NOT NULL"
)
op.execute(
"CREATE INDEX ix_media_evicted_key ON media (evicted_key) "
"WHERE evicted_key IS NOT NULL"
)
op.execute(_TRASH)
op.execute(
"CREATE INDEX ix_media_trash_account ON media_trash "
"(account_id, trashed_at DESC)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS media_trash")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_key")
op.execute("DROP INDEX IF EXISTS ix_media_evicted_unique")
op.execute("ALTER TABLE media DROP COLUMN evicted_key, DROP COLUMN evicted_at")
+9 -5
View File
@@ -1,19 +1,23 @@
import logging
import re
QUERYLESS_PREFIXES = ("/mcp",)
_PATH_ARG = 2
_TOKEN = re.compile(r"(?<=[?&]token=)[^&]+")
class DropQueryFilter(logging.Filter):
class ScrubQueryFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
args = record.args
if isinstance(args, tuple) and len(args) > _PATH_ARG:
path = str(args[_PATH_ARG])
if path.startswith(QUERYLESS_PREFIXES):
bare = path.partition("?")[0]
record.args = (*args[:_PATH_ARG], bare, *args[_PATH_ARG + 1 :])
path = path.partition("?")[0]
else:
path = _TOKEN.sub("***", path)
record.args = (*args[:_PATH_ARG], path, *args[_PATH_ARG + 1 :])
return True
def hide_mcp_query() -> None:
logging.getLogger("uvicorn.access").addFilter(DropQueryFilter())
def scrub_access_log() -> None:
logging.getLogger("uvicorn.access").addFilter(ScrubQueryFilter())
+11 -2
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
@@ -9,7 +10,7 @@ from fastapi.responses import FileResponse, RedirectResponse
from fastmcp.utilities.lifespan import combine_lifespans
from starlette.applications import Starlette
from api.access_log import hide_mcp_query
from api.access_log import scrub_access_log
from api.auth import BearerAuthMiddleware
from api.mcp.server import mcp
from api.realtime import hub
@@ -34,19 +35,22 @@ from api.routers import (
search,
shares,
social,
storage,
stories,
watches,
)
from dependencies.container import container
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
from utils.env import env
from utils.storage import ContentAddressedStorage
from utils.trash import purge_loop
if env.auth.token is None:
msg = "AUTH__TOKEN is required for the api process"
raise RuntimeError(msg)
_token = env.auth.token.get_secret_value()
hide_mcp_query()
scrub_access_log()
mcp_app = mcp.http_app(path="/")
@@ -55,7 +59,11 @@ mcp_app = mcp.http_app(path="/")
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
pool = await container.get(asyncpg.Pool)
await hub.start(pool)
purger = asyncio.create_task(
purge_loop(pool, await container.get(ContentAddressedStorage))
)
yield
purger.cancel()
await hub.stop()
await app_.state.dishka_container.close()
@@ -96,6 +104,7 @@ app.include_router(discover.router)
app.include_router(annotations.router)
app.include_router(watches.router)
app.include_router(shares.router)
app.include_router(storage.router)
app.include_router(files.router)
app.mount("/mcp", mcp_app)
+3 -1
View File
@@ -54,7 +54,7 @@ async def serve_media_version(
download: Download = False,
) -> FileResponse:
version = await get_media_version(pool, version_id)
if version is None:
if version is None or not storage.exists(version.storage_key):
raise HTTPException(status_code=404, detail="media version not found")
headers = IMMUTABLE_HEADERS
if download:
@@ -96,6 +96,8 @@ async def serve_media(
status_code=409,
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
)
if not storage.exists(media.storage_key):
raise HTTPException(status_code=404, detail="media file is missing")
headers = DAY_HEADERS
if download:
headers = _attachment(headers, media.file_name or f"media_{media_id}")
+29 -1
View File
@@ -5,13 +5,15 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from utils.policy import repository
from utils.policy import overview, repository
from utils.policy.models import (
CaptureToggles,
ChatKind,
ChatMeta,
PolicyCreate,
PolicyRecord,
PolicyRuleView,
PolicyScopeView,
)
from utils.policy.resolver import resolve
@@ -50,6 +52,32 @@ async def effective_policy(
return resolve(chat, folders, policies)
@router.get("/overview")
async def policy_overview(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> list[PolicyRuleView]:
return await overview.list_rules(pool, account_id)
@router.get("/scope")
async def policy_scope(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
) -> PolicyScopeView:
if chat_id is not None:
return await overview.chat_scope(pool, account_id, chat_id)
scope = (
await overview.folder_scope(pool, account_id, folder_id)
if folder_id is not None
else None
)
if scope is None:
raise HTTPException(status_code=404, detail="scope not found")
return scope
@router.post("", status_code=201)
async def create_policy(
pool: FromDishka[asyncpg.Pool], body: PolicyCreate
+150
View File
@@ -0,0 +1,150 @@
from typing import Annotated
import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from utils import trash
from utils.cache import NO_STORE_HEADERS
from utils.files import resolve_mime
from utils.read import storage as storage_read
from utils.read.models import (
DEFAULT_LIMIT,
Page,
StorageFileView,
StorageScopeView,
StorageSummaryView,
TrashItemView,
TrashOutcome,
)
from utils.storage import ContentAddressedStorage
router = APIRouter(prefix="/api/storage", tags=["storage"], route_class=DishkaRoute)
class TrashRequest(BaseModel):
account_id: int
media_ids: list[int]
class RestoreRequest(BaseModel):
account_id: int
keys: list[str] | None = None
class ScopeRequest(BaseModel):
account_id: int
chat_id: int | None = None
folder_id: int | None = None
class PurgeRequest(BaseModel):
account_id: int
keys: list[str] | None = None
@router.get("/summary")
async def summary(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id)
@router.get("/files")
async def files(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page)
async def _chat_ids(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
) -> list[int]:
chat_ids = await storage_read.scope_chat_ids(pool, account_id, chat_id, folder_id)
if chat_ids is None:
raise HTTPException(status_code=404, detail="scope not found")
return chat_ids
@router.get("/scope")
async def scope(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
folder_id: Annotated[int | None, Query()] = None,
) -> StorageScopeView:
chat_ids = await _chat_ids(pool, account_id, chat_id, folder_id)
return await storage_read.get_scope(pool, account_id, chat_ids)
@router.get("/trash")
async def trash_items(
pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[TrashItemView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_trash(pool, account_id, page)
@router.post("/trash/scope")
async def trash_scope(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: ScopeRequest,
) -> TrashOutcome:
chat_ids = await _chat_ids(pool, body.account_id, body.chat_id, body.folder_id)
blobs = await pool.fetch(storage_read.SCOPE_BLOBS, body.account_id, chat_ids)
free = [blob for blob in blobs if blob["free"]]
return await trash.trash_blobs(pool, storage, body.account_id, free)
@router.post("/trash")
async def move_to_trash(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: TrashRequest,
) -> TrashOutcome:
return await trash.move_to_trash(pool, storage, body.account_id, body.media_ids)
@router.post("/trash/restore")
async def restore(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: RestoreRequest,
) -> TrashOutcome:
return await trash.restore(pool, storage, body.account_id, body.keys)
@router.post("/trash/purge")
async def purge(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
body: PurgeRequest,
) -> TrashOutcome:
return await trash.purge(pool, storage, body.account_id, body.keys)
@router.get("/trash/{storage_key}/file")
async def serve_trashed(
pool: FromDishka[asyncpg.Pool],
storage: FromDishka[ContentAddressedStorage],
storage_key: str,
) -> FileResponse:
item = await storage_read.get_trash_item(pool, storage_key)
if item is None or not storage.trashed(storage_key):
raise HTTPException(status_code=404, detail="file is not in trash")
return FileResponse(
storage.trash_url(storage_key),
media_type=resolve_mime(item["kind"], item["mime"], item["file_name"]),
headers=NO_STORE_HEADERS,
)
+1 -1
View File
@@ -30,4 +30,4 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
)
handlers = on_deleted_messages.handlers
handlers = on_deleted_messages.handlers # ty: ignore[unresolved-attribute]
+1 -1
View File
@@ -49,4 +49,4 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
)
handlers = on_edited_message.handlers
handlers = on_edited_message.handlers # ty: ignore[unresolved-attribute]
+1 -1
View File
@@ -36,4 +36,4 @@ async def on_message(client: PyroClient, message: Message) -> None:
await safe_transcribe(client, ctx, meta.chat_id, message.id)
handlers = on_message.handlers
handlers = on_message.handlers # ty: ignore[unresolved-attribute]
+1 -1
View File
@@ -27,4 +27,4 @@ async def on_user_status(client: PyroClient, user: User) -> None:
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
handlers = on_user_status.handlers
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
@@ -38,4 +38,4 @@ async def dispatch(
await handler(client, update, users, chats)
handlers = dispatch.handlers
handlers = dispatch.handlers # ty: ignore[unresolved-attribute]
+1 -1
View File
@@ -21,7 +21,7 @@ async def _handle_user(
raw_user = users.get(update.user_id)
if raw_user is None:
return
fields, photo_file_id, photo_unique_id = snapshot_from_user(client, raw_user)
fields, photo_file_id, photo_unique_id = await snapshot_from_user(client, raw_user)
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
if current == fields:
return
+1 -1
View File
@@ -11,4 +11,4 @@ async def on_story(client: PyroClient, story: Story) -> None:
await save_story(client, client.capture, story)
handlers = on_story.handlers
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
@@ -88,8 +88,8 @@ ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
_INSERT_MEDIA = """
INSERT INTO media
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
ttl_seconds, downloaded, unique_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ttl_seconds, downloaded, unique_id, evicted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $11 THEN now() END)
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
kind = EXCLUDED.kind,
storage_key = EXCLUDED.storage_key,
@@ -97,7 +97,27 @@ ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
mime = EXCLUDED.mime,
ttl_seconds = EXCLUDED.ttl_seconds,
downloaded = EXCLUDED.downloaded,
unique_id = EXCLUDED.unique_id
unique_id = EXCLUDED.unique_id,
evicted_at = CASE
WHEN EXCLUDED.downloaded THEN NULL
WHEN media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN EXCLUDED.evicted_at
ELSE coalesce(media.evicted_at, EXCLUDED.evicted_at) END,
evicted_key = CASE WHEN EXCLUDED.downloaded
OR media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
THEN NULL ELSE media.evicted_key END
"""
_IS_EVICTED = """
SELECT EXISTS (
SELECT 1 FROM media WHERE unique_id = $1 AND evicted_at IS NOT NULL
)
"""
_UNEVICT = """
UPDATE media SET storage_key = $2, file_size = $3, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
"""
@@ -229,6 +249,7 @@ async def insert_media( # noqa: PLR0913
unique_id: str | None,
*,
downloaded: bool,
evicted: bool = False,
) -> None:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
@@ -243,6 +264,7 @@ async def insert_media( # noqa: PLR0913
ttl_seconds,
downloaded,
unique_id,
evicted,
)
if storage_key is not None:
await conn.execute(
@@ -259,6 +281,22 @@ async def insert_media( # noqa: PLR0913
)
async def is_evicted(pool: asyncpg.Pool, unique_id: str | None) -> bool:
return unique_id is not None and await pool.fetchval(_IS_EVICTED, unique_id)
async def unevict(
pool: asyncpg.Pool, unique_id: str | None, storage_key: str, file_size: int | None
) -> bool:
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_UNEVICT, unique_id, storage_key, file_size)
dropped = await conn.fetchval(
"DELETE FROM media_trash WHERE storage_key = $1 RETURNING storage_key",
storage_key,
)
return dropped is not None
async def insert_callbacks(
pool: asyncpg.Pool,
account_id: int,
@@ -36,6 +36,8 @@ async def _save_user(ctx: CaptureContext, user: User) -> None:
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
chat = await client.get_chat(chat_id)
if chat is None:
return
photo = chat.photo
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
@@ -12,7 +12,7 @@ STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
chat = await client.get_chat(owner_id)
photo = chat.photo
photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id
photos = client.get_chat_photos(owner_id)
@@ -21,7 +21,9 @@ async def fetch_media(ctx: JobContext) -> None:
msg = f"message {chat_id}/{message_id} is unavailable"
raise RuntimeError(msg)
toggles = CaptureToggles(media=True, self_destruct_media=True)
ok = await capture_media(client, message, capture, chat_id, message_id, toggles)
ok = await capture_media(
client, message, capture, chat_id, message_id, toggles, force=True
)
if not ok:
msg = f"media download failed for {chat_id}/{message_id}"
raise RuntimeError(msg)
@@ -46,7 +46,7 @@ async def _save_found(
source = _source(peer, users, chats)
if source is None:
return None
chat = Chat._parse_chat(client, source) # noqa: SLF001
chat = await Chat._parse_chat(client, source) # noqa: SLF001
if chat is None or chat.id is None:
return None
await save_chat(ctx, chat)
@@ -106,6 +106,8 @@ async def capture_media( # noqa: PLR0913
chat_id: int,
message_id: int,
toggles: CaptureToggles,
*,
force: bool = False,
) -> bool:
kind, obj = media_object(message)
if obj is None:
@@ -117,6 +119,7 @@ async def capture_media( # noqa: PLR0913
mime = getattr(obj, "mime_type", None)
storage_key: str | None = None
downloaded = False
evicted = False
flood: FloodWait | FloodPremiumWait | None = None
if want:
existing = await repository.current_media(
@@ -131,7 +134,7 @@ async def capture_media( # noqa: PLR0913
storage_key = existing["storage_key"]
file_size = existing["file_size"]
downloaded = True
else:
elif force or not await repository.is_evicted(ctx.pool, unique_id):
target = _download_target(message)
try:
data = await _download(client, target, chat_id, message_id)
@@ -142,6 +145,8 @@ async def capture_media( # noqa: PLR0913
storage_key = ctx.storage.put(data)
file_size = len(data)
downloaded = True
else:
evicted = True
await repository.insert_media(
ctx.pool,
ctx.account_id,
@@ -154,7 +159,14 @@ async def capture_media( # noqa: PLR0913
ttl,
unique_id,
downloaded=downloaded,
evicted=evicted,
)
if (
force
and storage_key is not None
and await repository.unevict(ctx.pool, unique_id, storage_key, file_size)
):
ctx.storage.purge(storage_key)
if flood is not None:
raise flood
return downloaded or not want
@@ -21,10 +21,10 @@ def active_username(usernames: list[raw.types.Username]) -> str | None:
return usernames[0].username if usernames else None
def snapshot_from_user(
async def snapshot_from_user(
client: Client, raw_user: raw.types.User
) -> tuple[ProfileFields, str | None, str | None]:
user = User._parse(client, raw_user) # noqa: SLF001
user = await User._parse(client, raw_user) # noqa: SLF001
photo = user.photo if user else None
photo_unique_id = photo.big_photo_unique_id if photo else None
photo_file_id = photo.big_file_id if photo else None
+18
View File
@@ -79,6 +79,24 @@ class FolderSpec:
is_chatlist: bool = False
class PolicyParent(BaseModel):
scope_type: ScopeType
scope_id: int | None
title: str | None = None
toggles: CaptureToggles
class PolicyRuleView(PolicyRecord):
title: str | None = None
parent: PolicyParent | None = None
class PolicyScopeView(BaseModel):
title: str | None
rule: PolicyRecord | None
parent: PolicyParent | None
@dataclass(slots=True)
class PolicySet:
chat: dict[int, CaptureToggles] = field(default_factory=dict)
+103
View File
@@ -0,0 +1,103 @@
import asyncpg
from utils.policy import repository
from utils.policy.models import (
ChatKind,
ChatMeta,
FolderSpec,
PolicyParent,
PolicyRecord,
PolicyRuleView,
PolicyScopeView,
PolicySet,
ScopeType,
)
from utils.policy.resolver import resolve_parent
from utils.read import chats
from utils.read.models import ChatListItem
def _meta(chat_id: int, chat: ChatListItem | None) -> ChatMeta:
if chat_id > 0:
kind = ChatKind.DM
elif chat is not None and chat.is_broadcast:
kind = ChatKind.CHANNEL
else:
kind = ChatKind.GROUP
return ChatMeta(
chat_id=chat_id,
kind=kind,
is_bot=bool(chat and chat.is_bot),
is_contact=chat.is_contact if chat else None,
)
def _without_chats(policies: PolicySet) -> PolicySet:
return PolicySet(folder=policies.folder, defaults=policies.defaults)
def _chat_parent(
chat_id: int,
chat: ChatListItem | None,
folders: list[FolderSpec],
policies: PolicySet,
) -> PolicyParent:
return resolve_parent(_meta(chat_id, chat), folders, _without_chats(policies))
async def list_rules(pool: asyncpg.Pool, account_id: int) -> list[PolicyRuleView]:
records = await repository.list_policies(pool, account_id)
folders = await repository.list_folders(pool, account_id)
policies = await repository.load_policy_set(pool, account_id)
chat_ids = [
record.scope_id
for record in records
if record.scope_type is ScopeType.CHAT and record.scope_id is not None
]
known = {
chat.chat_id: chat for chat in await chats.get_chats(pool, account_id, chat_ids)
}
titles = {folder.folder_id: folder.title for folder in folders}
rules: list[PolicyRuleView] = []
for record in records:
rule = PolicyRuleView(**record.model_dump())
if record.scope_type is ScopeType.FOLDER and record.scope_id is not None:
rule.title = titles.get(record.scope_id)
if record.scope_type is ScopeType.CHAT and record.scope_id is not None:
chat = known.get(record.scope_id)
rule.title = chat.title if chat else None
rule.parent = _chat_parent(record.scope_id, chat, folders, policies)
rules.append(rule)
return rules
async def _rule(
pool: asyncpg.Pool, account_id: int, scope_type: ScopeType, scope_id: int
) -> PolicyRecord | None:
return await repository.find_policy(pool, account_id, scope_type, scope_id)
async def chat_scope(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> PolicyScopeView:
folders = await repository.list_folders(pool, account_id)
policies = await repository.load_policy_set(pool, account_id)
chat = await chats.get_chat(pool, account_id, chat_id)
return PolicyScopeView(
title=chat.title if chat else None,
rule=await _rule(pool, account_id, ScopeType.CHAT, chat_id),
parent=_chat_parent(chat_id, chat, folders, policies),
)
async def folder_scope(
pool: asyncpg.Pool, account_id: int, folder_id: int
) -> PolicyScopeView | None:
folder = await repository.get_folder(pool, account_id, folder_id)
if folder is None:
return None
return PolicyScopeView(
title=folder.title,
rule=await _rule(pool, account_id, ScopeType.FOLDER, folder_id),
parent=None,
)
+23 -5
View File
@@ -1,11 +1,14 @@
from collections.abc import Iterable
from utils.policy.models import (
DEFAULT_SCOPE_BY_KIND,
CaptureToggles,
ChatKind,
ChatMeta,
FolderSpec,
PolicyParent,
PolicySet,
ScopeType,
)
@@ -33,14 +36,29 @@ def folder_contains(folder: FolderSpec, chat: ChatMeta) -> bool:
return _category_match(folder, chat)
def resolve_parent(
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
) -> PolicyParent:
for folder in sorted(folders, key=lambda f: f.order_index):
toggles = policies.folder.get(folder.folder_id)
if toggles is not None and folder_contains(folder, chat):
return PolicyParent(
scope_type=ScopeType.FOLDER,
scope_id=folder.folder_id,
title=folder.title,
toggles=toggles,
)
return PolicyParent(
scope_type=DEFAULT_SCOPE_BY_KIND[chat.kind],
scope_id=None,
toggles=policies.defaults.get(chat.kind, CaptureToggles()),
)
def resolve(
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
) -> CaptureToggles:
chat_override = policies.chat.get(chat.chat_id)
if chat_override is not None:
return chat_override
for folder in sorted(folders, key=lambda f: f.order_index):
toggles = policies.folder.get(folder.folder_id)
if toggles is not None and folder_contains(folder, chat):
return toggles
return policies.defaults.get(chat.kind, CaptureToggles())
return resolve_parent(chat, folders, policies).toggles
+12
View File
@@ -176,6 +176,18 @@ async def list_chats(
return [_chat_item(row) for row in rows]
async def folder_chat_ids(
pool: asyncpg.Pool, account_id: int, folder: FolderSpec
) -> list[int]:
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
rows = await pool.fetch(
f"SELECT chat.chat_id FROM ({rows_sql}) chat WHERE {_folder_filter(1)}", # noqa: S608
account_id,
*_folder_params(folder),
)
return [row["chat_id"] for row in rows]
async def get_chat(
pool: asyncpg.Pool, account_id: int, chat_id: int
) -> ChatListItem | None:
+59
View File
@@ -236,6 +236,65 @@ class MediaVersionView(BaseModel):
observed_at: datetime
class StorageGroupView(BaseModel):
group: str
files: int
bytes: int
class StorageSummaryView(BaseModel):
files: int
bytes: int
heavy_files: int
heavy_bytes: int
heavy_threshold: int
trash_files: int
trash_bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel):
media_id: int
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int
date: datetime | None
refs: int
refetchable: bool
class TrashItemView(BaseModel):
storage_key: str
chat_id: int
message_id: int
kind: str
mime: str | None
file_name: str
file_size: int | None
trashed_at: datetime
purge_at: datetime
refs: int
refetchable: bool
class TrashOutcome(BaseModel):
files: int = 0
bytes: int = 0
keys: list[str] = []
blocked: list[int] = []
class StorageScopeView(BaseModel):
chats: int
files: int
bytes: int
kept: int
gone: int
class AvatarRef(BaseModel):
unique_id: str
storage_key: str | None
+291
View File
@@ -0,0 +1,291 @@
import asyncio
from collections import defaultdict
import asyncpg
from utils.files import media_file_name
from utils.policy import repository as policy_repository
from utils.read import chats
from utils.read.models import (
Page,
StorageFileView,
StorageGroupView,
StorageScopeView,
StorageSummaryView,
TrashItemView,
)
from utils.trash import RETENTION
HEAVY_BYTES = 100 * 1024 * 1024
REFS_CHECKED = 5
LOOKUP_CONCURRENCY = 8
OTHER_GROUP = "other"
GROUP_KINDS: dict[str, tuple[str, ...]] = {
"video": ("video",),
"file": ("document",),
"voice": ("video_note", "voice"),
"audio": ("audio",),
}
_KIND_GROUP = {kind: group for group, kinds in GROUP_KINDS.items() for kind in kinds}
_SUMMARY = """
WITH blobs AS (
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
GROUP BY storage_key
)
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
count(*) FILTER (WHERE size >= $2) AS heavy_files,
coalesce(sum(size) FILTER (WHERE size >= $2), 0)::bigint AS heavy_bytes
FROM blobs GROUP BY kind
"""
_TRASH_TOTAL = """
SELECT count(*) AS files, coalesce(sum(file_size), 0)::bigint AS bytes
FROM media_trash WHERE account_id = $1
"""
_FILES = """
WITH top AS MATERIALIZED (
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs
FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5
)
SELECT * FROM (
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id,
m.kind, m.mime, m.ttl_seconds,
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n
FROM top t
JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded
) ranked
WHERE n <= $6
ORDER BY file_size DESC NULLS LAST, storage_key, id
"""
_TRASH = """
SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
trashed_at
FROM media_trash WHERE account_id = $1
ORDER BY file_size DESC NULLS LAST, storage_key
LIMIT $2 OFFSET $3
"""
SCOPE_BLOBS = """
WITH inside AS (
SELECT DISTINCT storage_key FROM media
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
AND downloaded AND storage_key IS NOT NULL
), blobs AS (
SELECT m.storage_key, max(m.file_size) AS file_size,
min(m.id) FILTER (
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
) AS media_id,
bool_and(m.account_id = $1 AND m.chat_id = ANY($2::bigint[])) AS own
FROM media m JOIN inside i ON i.storage_key = m.storage_key
GROUP BY m.storage_key
)
SELECT b.storage_key, b.file_size, r.chat_id, r.message_id, r.kind, r.mime,
b.own
AND NOT EXISTS (SELECT 1 FROM stories s WHERE s.storage_key = b.storage_key)
AND NOT EXISTS (SELECT 1 FROM avatars a WHERE a.storage_key = b.storage_key)
AND NOT EXISTS (
SELECT 1 FROM custom_emoji e WHERE e.storage_key = b.storage_key
) AS free
FROM blobs b JOIN media r ON r.id = b.media_id
"""
_SCOPE_GONE = """
SELECT (
SELECT count(DISTINCT m.storage_key) FROM media m
JOIN messages g ON g.account_id = m.account_id AND g.chat_id = m.chat_id
AND g.message_id = m.message_id
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
AND m.downloaded AND m.storage_key IS NOT NULL
AND g.deleted_at IS NOT NULL
) + (
SELECT count(DISTINCT storage_key) FROM media
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
AND downloaded AND storage_key IS NOT NULL AND ttl_seconds IS NOT NULL
)
"""
_TRASH_REFS = """
SELECT * FROM (
SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds,
count(*) OVER (PARTITION BY evicted_key) AS refs,
row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n
FROM media
WHERE account_id = $1 AND evicted_key = ANY($2::text[])
) ranked
WHERE n <= $3
"""
_MESSAGE = """
SELECT date, deleted_at, raw->$4::text->>'file_name' AS original_name
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
ORDER BY date DESC LIMIT 1
"""
def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
if group is None:
return None, True
if group == OTHER_GROUP:
return list(_KIND_GROUP), False
return list(GROUP_KINDS.get(group, ())), True
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
groups = {
name: StorageGroupView(group=name, files=0, bytes=0)
for name in (*GROUP_KINDS, OTHER_GROUP)
}
for row in rows:
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
group.files += row["files"]
group.bytes += row["bytes"]
return StorageSummaryView(
files=sum(row["files"] for row in rows),
bytes=sum(row["bytes"] for row in rows),
heavy_files=sum(row["heavy_files"] for row in rows),
heavy_bytes=sum(row["heavy_bytes"] for row in rows),
heavy_threshold=HEAVY_BYTES,
trash_files=trash["files"],
trash_bytes=trash["bytes"],
groups=list(groups.values()),
)
async def _messages(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]:
gate = asyncio.Semaphore(LOOKUP_CONCURRENCY)
async def lookup(ref: asyncpg.Record) -> asyncpg.Record | None:
async with gate:
return await pool.fetchrow(
_MESSAGE, account_id, ref["chat_id"], ref["message_id"], ref["kind"]
)
return await asyncio.gather(*(lookup(ref) for ref in refs))
def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
return (
message is not None
and message["deleted_at"] is None
and ref["ttl_seconds"] is None
)
async def list_files(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
) -> list[StorageFileView]:
kinds, include = _group_filter(group)
refs = await pool.fetch(
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED
)
messages = await _messages(pool, account_id, refs)
files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message)
known = files.get(ref["storage_key"])
if known is not None and (known.refetchable or not alive):
continue
files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"],
chat_id=ref["chat_id"],
message_id=ref["message_id"],
kind=ref["kind"],
mime=ref["mime"],
file_name=media_file_name(
ref["kind"],
ref["mime"],
ref["message_id"],
message["original_name"] if message else None,
),
file_size=ref["file_size"] or 0,
date=message["date"] if message else None,
refs=ref["refs"],
refetchable=alive,
)
return list(files.values())
async def list_trash(
pool: asyncpg.Pool, account_id: int, page: Page
) -> list[TrashItemView]:
rows = await pool.fetch(_TRASH, account_id, page.capped_limit, page.offset)
refs = await pool.fetch(
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
)
messages = await _messages(pool, account_id, refs)
alive: dict[str, bool] = defaultdict(bool)
counts: dict[str, int] = {}
names: dict[str, str] = {}
for ref, message in zip(refs, messages, strict=True):
key = ref["storage_key"]
alive[key] |= _alive(ref, message)
counts[key] = ref["refs"]
if message is not None and message["original_name"]:
names.setdefault(key, message["original_name"])
return [
TrashItemView(
**{**dict(row), "file_name": _trash_name(row, names)},
purge_at=row["trashed_at"] + RETENTION,
refs=counts.get(row["storage_key"], 0),
refetchable=alive.get(row["storage_key"], True),
)
for row in rows
]
def _trash_name(row: asyncpg.Record, names: dict[str, str]) -> str:
original = names.get(row["storage_key"])
if original is None:
return row["file_name"]
return media_file_name(row["kind"], row["mime"], row["message_id"], original)
async def scope_chat_ids(
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
) -> list[int] | None:
if chat_id is not None:
return [chat_id]
if folder_id is None:
return None
folder = await policy_repository.get_folder(pool, account_id, folder_id)
if folder is None:
return None
return await chats.folder_chat_ids(pool, account_id, folder)
async def get_scope(
pool: asyncpg.Pool, account_id: int, chat_ids: list[int]
) -> StorageScopeView:
blobs = await pool.fetch(SCOPE_BLOBS, account_id, chat_ids)
free = [blob for blob in blobs if blob["free"]]
return StorageScopeView(
chats=len(chat_ids),
files=len(free),
bytes=sum(blob["file_size"] or 0 for blob in free),
kept=len(blobs) - len(free),
gone=await pool.fetchval(_SCOPE_GONE, account_id, chat_ids) if free else 0,
)
async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
return await pool.fetchrow(
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
storage_key,
)
+31
View File
@@ -1,6 +1,8 @@
import hashlib
from pathlib import Path
TRASH_DIR = ".trash"
class ContentAddressedStorage:
def __init__(self, root: str | Path, shard_depth: int = 2) -> None:
@@ -27,3 +29,32 @@ class ContentAddressedStorage:
def url(self, key: str) -> str:
return str(self._path(key))
def _trash_path(self, key: str) -> Path:
return self._root / TRASH_DIR / key
def trash(self, key: str) -> None:
target = self._trash_path(key)
target.parent.mkdir(parents=True, exist_ok=True)
self._path(key).replace(target)
def restore(self, key: str) -> bool:
source = self._trash_path(key)
target = self._path(key)
if not source.exists():
return target.exists()
if target.exists():
source.unlink()
else:
target.parent.mkdir(parents=True, exist_ok=True)
source.replace(target)
return True
def purge(self, key: str) -> None:
self._trash_path(key).unlink(missing_ok=True)
def trashed(self, key: str) -> bool:
return self._trash_path(key).is_file()
def trash_url(self, key: str) -> str:
return str(self._trash_path(key))
+228
View File
@@ -0,0 +1,228 @@
import asyncio
import logging
from datetime import timedelta
import asyncpg
from utils.files import media_file_name
from utils.read.media import get_media
from utils.read.models import TrashOutcome
from utils.storage import ContentAddressedStorage
logger = logging.getLogger(__name__)
RETENTION = timedelta(days=7)
PURGE_INTERVAL_SECONDS = 3600
BATCH = 200
_SHARED_ELSEWHERE = """
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM avatars WHERE storage_key = $1)
OR EXISTS (SELECT 1 FROM custom_emoji WHERE storage_key = $1)
"""
_EVICT = """
UPDATE media SET storage_key = NULL, downloaded = false,
evicted_at = now(), evicted_key = $2
WHERE storage_key = $1
"""
_INSERT = """
INSERT INTO media_trash
(storage_key, account_id, chat_id, message_id, kind, mime, file_name, file_size)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (storage_key) DO UPDATE SET trashed_at = now()
"""
_RESTORE = """
UPDATE media SET storage_key = $1, downloaded = true,
evicted_at = NULL, evicted_key = NULL
WHERE evicted_key = $1 OR (
evicted_at IS NOT NULL AND unique_id IN (
SELECT unique_id FROM media
WHERE evicted_key = $1 AND unique_id IS NOT NULL
)
)
"""
_FORGET_KEYS = """
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
"""
_EVICT_MANY = """
UPDATE media SET evicted_key = storage_key, storage_key = NULL,
downloaded = false, evicted_at = now()
WHERE storage_key = ANY($1::text[])
"""
async def move_to_trash(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
media_ids: list[int],
) -> TrashOutcome:
outcome = TrashOutcome()
for media_id in media_ids:
media = await get_media(pool, media_id)
if media is None or media.account_id != account_id or not media.storage_key:
continue
key = media.storage_key
on_disk = storage.exists(key)
async with pool.acquire() as conn, conn.transaction():
if await conn.fetchval(_SHARED_ELSEWHERE, key):
outcome.blocked.append(media_id)
continue
await conn.execute(_EVICT, key, key if on_disk else None)
if not on_disk:
continue
await conn.execute(
_INSERT,
key,
account_id,
media.chat_id,
media.message_id,
media.kind,
media.mime,
media.file_name,
media.file_size,
)
storage.trash(key)
outcome.keys.append(key)
outcome.files += 1
outcome.bytes += media.file_size or 0
return outcome
def _move_batch(storage: ContentAddressedStorage, keys: list[str]) -> None:
moved: list[str] = []
try:
for key in keys:
if storage.exists(key):
storage.trash(key)
moved.append(key)
except OSError:
for key in moved:
storage.restore(key)
raise
async def trash_blobs(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
blobs: list[asyncpg.Record],
) -> TrashOutcome:
outcome = TrashOutcome()
for start in range(0, len(blobs), BATCH):
batch = blobs[start : start + BATCH]
keys = [blob["storage_key"] for blob in batch]
async with pool.acquire() as conn, conn.transaction():
await conn.execute(_EVICT_MANY, keys)
await conn.executemany(
_INSERT,
[
(
blob["storage_key"],
account_id,
blob["chat_id"],
blob["message_id"],
blob["kind"],
blob["mime"],
media_file_name(blob["kind"], blob["mime"], blob["message_id"]),
blob["file_size"],
)
for blob in batch
],
)
await asyncio.to_thread(_move_batch, storage, keys)
outcome.keys.extend(keys)
outcome.files += len(batch)
outcome.bytes += sum(blob["file_size"] or 0 for blob in batch)
return outcome
async def restore(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str] | None,
) -> TrashOutcome:
if keys is None:
rows = await pool.fetch(
"SELECT storage_key FROM media_trash WHERE account_id = $1", account_id
)
keys = [row["storage_key"] for row in rows]
outcome = TrashOutcome()
for key in keys:
async with pool.acquire() as conn, conn.transaction():
row = await conn.fetchrow(
"DELETE FROM media_trash WHERE storage_key = $1 AND account_id = $2 "
"RETURNING file_size",
key,
account_id,
)
if row is None:
continue
if not storage.restore(key):
await conn.execute(_FORGET_KEYS, [key])
continue
await conn.execute(_RESTORE, key)
outcome.files += 1
outcome.bytes += row["file_size"] or 0
return outcome
async def _purge(
pool: asyncpg.Pool, storage: ContentAddressedStorage, rows: list[asyncpg.Record]
) -> TrashOutcome:
keys = [row["storage_key"] for row in rows]
for key in keys:
storage.purge(key)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(
"DELETE FROM media_trash WHERE storage_key = ANY($1::text[])", keys
)
await conn.execute(_FORGET_KEYS, keys)
return TrashOutcome(
files=len(rows), bytes=sum(row["file_size"] or 0 for row in rows)
)
async def purge(
pool: asyncpg.Pool,
storage: ContentAddressedStorage,
account_id: int,
keys: list[str] | None,
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash WHERE account_id = $1 "
"AND ($2::text[] IS NULL OR storage_key = ANY($2))",
account_id,
keys,
)
return await _purge(pool, storage, rows)
async def purge_expired(
pool: asyncpg.Pool, storage: ContentAddressedStorage
) -> TrashOutcome:
rows = await pool.fetch(
"SELECT storage_key, file_size FROM media_trash "
"WHERE trashed_at < now() - $1::interval",
RETENTION,
)
return await _purge(pool, storage, rows)
async def purge_loop(pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
while True:
try:
outcome = await purge_expired(pool, storage)
if outcome.files:
logger.info(
"Trash purged: %s files, %s bytes", outcome.files, outcome.bytes
)
except Exception:
logger.exception("Trash purge failed")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
+13
View File
@@ -95,6 +95,19 @@ export async function request<T>(
return response.json() as Promise<T>;
}
function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`;
}
export function mediaUrl(mediaId: number): string {
return streamUrl(`/media/${mediaId}`);
}
export function trashedUrl(storageKey: string): string {
return streamUrl(`/storage/trash/${storageKey}/file`);
}
export type MediaResult =
| { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" }
+82
View File
@@ -26,12 +26,21 @@ import type {
PolicyChatKind,
PolicyCreate,
PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly,
PresenceSample,
ReactionView,
ResponseStats,
ScopeTarget,
SearchHit,
StorageFile,
StorageGroupName,
StorageScope,
StorageSummary,
StoryView,
TrashItem,
TrashOutcome,
VolumeBucket,
Watch,
WatchedBot,
@@ -143,6 +152,23 @@ export function deletePolicy(id: number): Promise<void> {
return request<void>(`/policy/${id}`, { method: "DELETE" });
}
function scopeQuery(target: ScopeTarget): Record<string, number> {
return "chatId" in target
? { chat_id: target.chatId }
: { folder_id: target.folderId };
}
export function getPolicyOverview(): Promise<PolicyRule[]> {
return request<PolicyRule[]>("/policy/overview", { account: true });
}
export function getPolicyScope(target: ScopeTarget): Promise<PolicyScope> {
return request<PolicyScope>("/policy/scope", {
account: true,
query: scopeQuery(target),
});
}
export function effectivePolicy(query: {
chat_id: number;
is_bot?: boolean;
@@ -581,3 +607,59 @@ export function addWatchedBot(token: string): Promise<WatchedBot> {
export function removeWatchedBot(botId: number): Promise<void> {
return request<void>(`/bots/${botId}`, { method: "DELETE" });
}
export function getStorageSummary(): Promise<StorageSummary> {
return request<StorageSummary>("/storage/summary", { account: true });
}
export function getStorageFiles(
group: StorageGroupName | null,
page: Page = {}
): Promise<StorageFile[]> {
return request<StorageFile[]>("/storage/files", {
account: true,
query: { group, ...page },
});
}
export function getTrash(page: Page = {}): Promise<TrashItem[]> {
return request<TrashItem[]>("/storage/trash", {
account: true,
query: { ...page },
});
}
export function getStorageScope(target: ScopeTarget): Promise<StorageScope> {
return request<StorageScope>("/storage/scope", {
account: true,
query: scopeQuery(target),
});
}
export function trashScope(target: ScopeTarget): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/scope", {
body: { account_id: accounts.selectedId, ...scopeQuery(target) },
method: "POST",
});
}
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash", {
body: { account_id: accounts.selectedId, media_ids: mediaIds },
method: "POST",
});
}
export function restoreFromTrash(keys: string[] | null): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/restore", {
body: { account_id: accounts.selectedId, keys },
method: "POST",
});
}
export function purgeTrash(keys: string[] | null): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/purge", {
body: { account_id: accounts.selectedId, keys },
method: "POST",
});
}
+32
View File
@@ -19,6 +19,7 @@ export type InlineMedia =
| { state: "missing" };
export interface ViewerItem {
caption?: string;
downloaded: boolean;
fileName: string | null;
fileSize: number | null;
@@ -26,6 +27,7 @@ export interface ViewerItem {
mediaId: number | null;
messageId: number;
mime: string | null;
src?: string;
}
export function viewerItemsFrom(
@@ -102,6 +104,16 @@ export function visualKind(kind: string): VisualKind {
return "other";
}
const STREAM_KINDS = new Set(["video", "video_note", "voice", "audio"]);
const STREAM_MIME_PREFIXES = ["video/", "audio/"];
export function isStreamable(kind: string, mime: string | null): boolean {
return (
STREAM_KINDS.has(kind) ||
STREAM_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix))
);
}
const MAX_CACHED = 200;
const ready = new Map<string, InlineMedia>();
@@ -130,6 +142,26 @@ function cacheKey(account: number, chatId: number, messageId: number): string {
return `${account}:${chatId}:${messageId}`;
}
function forget(cache: Map<string, InlineMedia>, key: string) {
const stale = cache.get(key);
cache.delete(key);
if (stale?.state === "ready") {
URL.revokeObjectURL(stale.url);
}
}
export function forgetMedia(
chatId: number,
messageId: number,
mediaId: number
): void {
const account = accounts.selectedId;
if (account !== null) {
forget(ready, cacheKey(account, chatId, messageId));
forget(byId, `${account}:${mediaId}`);
}
}
async function resolve(
chatId: number,
messageId: number
+81
View File
@@ -423,6 +423,24 @@ export interface PolicyRecord extends CaptureToggles {
scope_type: PolicyScopeType;
}
export interface PolicyParent {
scope_id: number | null;
scope_type: PolicyScopeType;
title: string | null;
toggles: CaptureToggles;
}
export interface PolicyRule extends PolicyRecord {
parent: PolicyParent | null;
title: string | null;
}
export interface PolicyScope {
parent: PolicyParent | null;
rule: PolicyRecord | null;
title: string | null;
}
export type PolicyChatKind = "dm" | "group" | "channel";
export interface PolicyCreate extends CaptureToggles {
@@ -560,3 +578,66 @@ export interface WatchedBot {
title: string | null;
username: string | null;
}
export type StorageGroupName = "video" | "file" | "voice" | "audio" | "other";
export interface StorageGroup {
bytes: number;
files: number;
group: StorageGroupName;
}
export interface StorageSummary {
bytes: number;
files: number;
groups: StorageGroup[];
heavy_bytes: number;
heavy_files: number;
heavy_threshold: number;
trash_bytes: number;
trash_files: number;
}
export interface StorageFile {
chat_id: number;
date: string | null;
file_name: string;
file_size: number;
kind: string;
media_id: number;
message_id: number;
mime: string | null;
refetchable: boolean;
refs: number;
}
export interface TrashItem {
chat_id: number;
file_name: string;
file_size: number | null;
kind: string;
message_id: number;
mime: string | null;
purge_at: string;
refetchable: boolean;
refs: number;
storage_key: string;
trashed_at: string;
}
export interface TrashOutcome {
blocked: number[];
bytes: number;
files: number;
keys: string[];
}
export interface StorageScope {
bytes: number;
chats: number;
files: number;
gone: number;
kept: number;
}
export type ScopeTarget = { chatId: number } | { folderId: number };
@@ -15,6 +15,7 @@
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
@@ -186,6 +187,11 @@
onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem
>
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet}
</ContextMenu>
<div class="actions">
@@ -7,6 +7,7 @@
import { formatListDate } from "#lib/format/datetime.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
@@ -108,6 +109,11 @@
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem
>
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet}
</ContextMenu>
+34 -3
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
interface Tab {
id: number | null;
@@ -21,6 +24,12 @@
)
);
function editPolicy(tab: Tab) {
if (tab.id !== null) {
policyUi.edit({ folderId: tab.id }, tab.title);
}
}
let containerEl = $state<HTMLDivElement>();
let indicatorEl = $state<HTMLDivElement>();
let clipPath = $state("");
@@ -64,9 +73,31 @@
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)}
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
{tab.title}
</button>
{#if tab.id === null}
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
{tab.title}
</button>
{:else}
<ContextMenu>
{#snippet children({
props,
})}
<button
{...props}
class="tab"
onclick={() => folders.select(tab.id)}
type="button"
>
{tab.title}
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
Политика захвата
</ContextMenuItem>
{/snippet}
</ContextMenu>
{/if}
{/each}
<div
+76 -10
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { untrack } from "svelte";
import { type MediaResult, requestMedia } from "#lib/api/client.js";
import { type MediaResult, mediaUrl, requestMedia } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js";
import { isPreviewable, type ViewerItem } from "#lib/api/media.js";
import {
isPreviewable,
isStreamable,
type ViewerItem,
} from "#lib/api/media.js";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
@@ -19,6 +23,7 @@
chatId: number;
index: number;
items: ViewerItem[];
ontrash?: (item: ViewerItem) => void;
open: boolean;
}
@@ -27,14 +32,18 @@
index = $bindable(),
chatId,
items,
ontrash,
}: Props = $props();
const STREAM_BYTES = 32 * 1024 * 1024;
let kind = $state("");
let messageId = $state<number | null>(null);
let currentMediaId = $state<number | null>(null);
let fileName = $state<string | null>(null);
let fileSize = $state<number | null>(null);
let fileOnly = $state(false);
let streamed = $state(false);
let result = $state<MediaResult | null>(null);
let loading = $state(false);
let saving = $state(false);
@@ -53,35 +62,54 @@
currentMediaId !== null && (fileOnly || result?.state === "ready")
);
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
const caption = $derived(items[index]?.caption);
function revoke() {
if (result?.state === "ready" && result.url) {
if (result?.state === "ready" && result.url && !streamed) {
URL.revokeObjectURL(result.url);
}
}
function streamSource(
item: ViewerItem,
mediaId: number | null,
size: number | null
): string | null {
if (item.src) {
return item.src;
}
const heavy = size !== null && size > STREAM_BYTES;
return mediaId !== null && heavy && isStreamable(kind, item.mime)
? mediaUrl(mediaId)
: null;
}
async function load(item: ViewerItem) {
revoke();
loading = true;
result = null;
currentMediaId = null;
fileOnly = false;
streamed = false;
({ kind, messageId, fileName, fileSize } = item);
token += 1;
const current = token;
try {
let { mediaId, downloaded, mime: mimeType } = item;
let { fileName: name, fileSize: size } = item;
if (mediaId === null) {
if (mediaId === null && !item.src) {
const meta = await getMessageMedia(chatId, item.messageId);
({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
({ file_name: name, file_size: size } = meta);
}
const plainFile = downloaded && !isPreviewable(kind, mimeType);
const source = downloaded ? streamSource(item, mediaId, size) : null;
let next: MediaResult;
if (plainFile) {
next = { mime: mimeType, state: "ready", url: "" };
} else if (downloaded) {
} else if (source !== null) {
next = { mime: mimeType, state: "ready", url: source };
} else if (downloaded && mediaId !== null) {
next = await requestMedia(mediaId);
} else {
next = { state: "not-downloaded" } as MediaResult;
@@ -92,6 +120,7 @@
fileName = name;
fileSize = size;
fileOnly = plainFile;
streamed = source !== null && !plainFile;
}
} catch {
if (current === token) {
@@ -136,6 +165,14 @@
}
}
function trash() {
const item = items[index];
if (item && ontrash) {
open = false;
ontrash(item);
}
}
function step(delta: number) {
const next = index + delta;
if (next >= 0 && next < items.length) {
@@ -181,8 +218,21 @@
<Dialog.Overlay class="media-overlay" />
<Dialog.Content class="media-content">
<Dialog.Title class="media-title">{title}</Dialog.Title>
{#if caption}
<span class="media-caption">{caption}</span>
{/if}
<div class="media-actions">
{#if canSave}
{#if ontrash}
<button
aria-label="В корзину"
class="media-action"
onclick={trash}
type="button"
>
<Icon name="delete" size="1.375rem" />
</button>
{/if}
<ContextMenu>
{#snippet children({
props,
@@ -234,7 +284,7 @@
class="media-video"
controls
playsinline
preload="auto"
preload={streamed ? "metadata" : "auto"}
src={result.url}
use:poster
></video>
@@ -248,10 +298,12 @@
{#if fileSize}
<p class="file-size">{formatBytes(fileSize)}</p>
{/if}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
</button>
{#if currentMediaId !== null}
<button class="media-download" onclick={save} type="button">
<Icon name="download" />
Скачать файл
</button>
{/if}
</div>
{:else if result?.state === "not-downloaded"}
<div class="media-message">
@@ -323,6 +375,20 @@
white-space: nowrap;
}
.media-caption {
position: absolute;
top: 2.75rem;
left: 1.25rem;
overflow: hidden;
max-width: min(24rem, calc(100% - 14rem));
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.6);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-counter {
position: absolute;
top: 1rem;
+45 -11
View File
@@ -2,6 +2,8 @@
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
@@ -16,6 +18,8 @@
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = {
@@ -23,7 +27,7 @@
annotations: "Заметки",
bots: "Боты",
callbacks: "Callback-данные",
jobs: "Данные и хранилище",
jobs: "Бэкфилл и задачи",
links: "Ссылки",
policy: "Политика захвата",
presence: "Аналитика",
@@ -31,23 +35,43 @@
reactions: "Реакции",
search: "Поиск",
shares: "Файлы по ссылке",
storage: "Хранилище",
"storage-trash": "Корзина",
stories: "Сторис",
"stories-all": "Все сторис",
versions: "Версии",
watches: "Отслеживания",
};
const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-trash": "storage",
};
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
</script>
<header class="right-header">
<Button
aria-label="Закрыть"
onclick={() => ui.closePanel()}
round
smaller
variant="translucent"
>
<Icon name="close" />
</Button>
{#if parent}
<Button
aria-label="Назад"
onclick={() => ui.openPanel(parent)}
round
smaller
variant="translucent"
>
<Icon name="arrow-left" />
</Button>
{:else}
<Button
aria-label="Закрыть"
onclick={() => ui.closePanel()}
round
smaller
variant="translucent"
>
<Icon name="close" />
</Button>
{/if}
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
</header>
@@ -55,7 +79,9 @@
{#if ui.rightPanel === "profile"}
<ProfilePanel />
{:else if ui.rightPanel === "policy"}
<PolicyEditor />
{#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor />
{/key}
{:else if ui.rightPanel === "jobs"}
<JobsPanel />
{:else if ui.rightPanel === "presence"}
@@ -82,6 +108,14 @@
<SharesPanel />
{:else if ui.rightPanel === "bots"}
<BotsPanel />
{:else if ui.rightPanel === "storage"}
{#key accounts.selectedId}
<StoragePanel />
{/key}
{:else if ui.rightPanel === "storage-trash"}
{#key accounts.selectedId}
<TrashPanel />
{/key}
{/if}
</div>
@@ -0,0 +1,75 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
failure?: string | null;
note?: string;
title: string;
warn?: boolean;
}
let { title, note, warn = false, failure, children }: Props = $props();
</script>
<div class="bar">
<div class="info">
<div class="title">{title}</div>
{#if note}
<div class="note" class:warn>{note}</div>
{/if}
{#if failure}
<div class="note failed">{failure}</div>
{/if}
</div>
{@render children()}
</div>
<style lang="scss">
.bar {
position: sticky;
z-index: 1;
bottom: 0;
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 4rem;
padding: 0.625rem 0.75rem calc(0.625rem + env(safe-area-inset-bottom)) 1rem;
border-top: 1px solid var(--color-borders);
background-color: var(--color-background);
box-shadow: 0 -0.5rem 1.5rem var(--color-light-shadow);
}
.bar :global(.Button) {
gap: 0.375rem;
padding-inline: 0.875rem;
}
.info {
flex: 1;
min-width: 0;
}
.title {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
.note {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.warn {
color: var(--color-storage-warning);
}
&.failed {
color: var(--color-error);
}
}
</style>
@@ -0,0 +1,681 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js";
import {
getStorageFiles,
getStorageSummary,
moveToTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type {
StorageFile,
StorageGroupName,
StorageSummary,
TrashOutcome,
} from "#lib/api/types.js";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { formatBytes } from "#lib/format/media.js";
import {
filesLabel,
GROUP_LABELS,
messagesLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 30;
const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600;
let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]);
let group = $state<StorageGroupName | null>(null);
let loading = $state(false);
let failed = $state(false);
let done = $state(false);
let busy = $state(false);
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
null
);
let problem = $state<string | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<number>();
let token = 0;
let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0;
const heaviest = $derived(files[0]?.file_size ?? 0);
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0)
);
const chosenGone = $derived(
chosen.filter((file) => !file.refetchable).length
);
const groups = $derived(
summary?.groups.filter((item) => item.files > 0 || item.group === group) ??
[]
);
const viewerItems = $derived<ViewerItem[]>(
files.map((file) => ({
caption: `${formatBytes(file.file_size)} · ${chatTitle(file.chat_id)}`,
downloaded: true,
fileName: file.file_name,
fileSize: file.file_size,
kind: file.kind,
mediaId: file.media_id,
messageId: file.message_id,
mime: file.mime,
}))
);
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
async function loadSummary() {
try {
summary = await getStorageSummary();
} catch {
failed = true;
}
}
async function loadMore() {
if (loading || done) {
return;
}
const current = token;
loading = true;
failed = false;
try {
const batch = await getStorageFiles(group, {
limit: PAGE,
offset: files.length,
});
if (current !== token) {
return;
}
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
done = batch.length < PAGE;
} catch {
if (current === token) {
failed = true;
}
} finally {
if (current === token) {
loading = false;
}
}
}
function reload() {
token += 1;
files = [];
done = false;
loading = false;
selected.clear();
loadSummary();
loadMore();
}
function toggle(mediaId: number) {
problem = null;
if (!selected.delete(mediaId)) {
selected.add(mediaId);
}
}
function hideUndo() {
clearTimeout(undoTimer);
undo = null;
}
function showUndo(outcome: TrashOutcome, trashed: StorageFile[]) {
hideUndo();
if (outcome.files > 0) {
undo = { files: trashed, outcome };
undoShownAt = Date.now();
undoTimer = setTimeout(hideUndo, UNDO_MS);
}
}
async function trash(mediaIds: number[]) {
if (busy || mediaIds.length === 0) {
return;
}
busy = true;
problem = null;
try {
const outcome = await moveToTrash(mediaIds);
const moved = new Set(
mediaIds.filter((id) => !outcome.blocked.includes(id))
);
const trashed = files.filter((file) => moved.has(file.media_id));
for (const file of trashed) {
forgetMedia(file.chat_id, file.message_id, file.media_id);
}
files = files.filter((file) => !moved.has(file.media_id));
selected.clear();
showUndo(outcome, trashed);
if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске";
}
await loadSummary();
} catch {
problem = "Не удалось переместить в корзину";
} finally {
busy = false;
}
}
async function restoreLast() {
if (undo === null || Date.now() - undoShownAt < UNDO_GUARD_MS) {
return;
}
const restored = undo;
hideUndo();
try {
await restoreFromTrash(restored.outcome.keys);
files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size
);
await loadSummary();
} catch {
problem = "Не удалось вернуть файлы";
}
}
async function save(file: StorageFile) {
try {
await downloadMedia(file.media_id);
} catch {
problem = "Не удалось скачать файл";
}
}
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(file: StorageFile) {
ui.requestJump(file.chat_id, file.message_id);
goto(`/app/${file.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
function pick(next: StorageGroupName) {
group = group === next ? null : next;
problem = null;
reload();
}
onMount(reload);
onDestroy(() => clearTimeout(undoTimer));
</script>
<div class="storage">
{#if summary}
{#if summary.files > 0}
<section class="summary">
<div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span>
<span class="count">
{filesLabel(summary.files)}
{summary.trash_bytes > 0
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
: "на диске"}
</span>
</div>
<div class="stack">
{#each groups as item (item.group)}
<span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
style:background-color="var(--color-storage-{item.group})"
style:flex-grow={item.bytes}
></span>
{/each}
{#if summary.trash_bytes > 0}
<span
class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}"
style:flex-grow={summary.trash_bytes}
></span>
{/if}
</div>
<div class="legend">
{#each groups as item (item.group)}
<button
aria-pressed={group === item.group}
class="chip"
onclick={() => pick(item.group)}
type="button"
class:on={group === item.group}
>
<span
class="dot"
style:background-color="var(--color-storage-{item.group})"
></span>
{GROUP_LABELS[item.group]}
<span class="value">{formatBytes(item.bytes)}</span>
</button>
{/each}
</div>
</section>
{/if}
<button
class="trash-link"
onclick={() => ui.openPanel("storage-trash")}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Корзина</span>
<span class="value">
{#if summary.trash_files > 0}
<span class="swatch"></span>
{filesLabel(summary.trash_files)}
· {formatBytes(summary.trash_bytes)}
{:else}
пусто
{/if}
</span>
<Icon name="next" size="1.125rem" />
</button>
{/if}
{#if files.length > 0}
<div class="section-title">
<h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0}
<span>
{filesLabel(summary.heavy_files)}
крупнее {formatBytes(summary.heavy_threshold)}
= {formatBytes(summary.heavy_bytes)}
</span>
{/if}
</div>
{/if}
<div class="list">
{#each files as file, index (file.media_id)}
<StorageRow
fileName={file.file_name}
kind={file.kind}
mime={file.mime}
note={file.refs > 1 ? messagesLabel(file.refs) : undefined}
onopen={() => open(index)}
ontoggle={() => toggle(file.media_id)}
refetchable={file.refetchable}
selected={selected.has(file.media_id)}
size={file.file_size}
src={mediaUrl(file.media_id)}
sub={chatTitle(file.chat_id)}
tail={formatListDate(file.date)}
weight={heaviest > 0 ? file.file_size / heaviest : 0}
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
Перейти к сообщению
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={() => save(file)}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => trash([file.media_id])}
>
В корзину
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else if files.length === 0}
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
</div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
warn
>
<Button onclick={() => selected.clear()} smaller variant="text">
Снять
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => trash([...selected])}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</SelectionBar>
{:else if undo}
<SelectionBar
failure={problem}
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
title="{filesLabel(undo.outcome.files)} в корзине"
>
<Button onclick={restoreLast} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if}
</div>
<MediaViewer
chatId={files[viewerIndex]?.chat_id ?? 0}
items={viewerItems}
ontrash={(item) => {
if (item.mediaId !== null) {
trash([item.mediaId]);
}
}}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
<style lang="scss">
.storage {
display: flex;
flex-direction: column;
min-height: 100%;
}
.summary {
padding: 1rem 1rem 0.875rem;
}
.hero {
display: flex;
flex-wrap: wrap;
gap: 0 0.625rem;
align-items: baseline;
}
.total {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.2;
letter-spacing: -0.01em;
}
.count {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.stack {
display: flex;
gap: 2px;
height: 0.625rem;
margin-top: 0.875rem;
span {
min-width: 0.1875rem;
border-radius: 2px;
&.trashed {
background-color: var(--color-storage-trash);
}
&:first-child {
border-start-start-radius: 0.3125rem;
border-end-start-radius: 0.3125rem;
}
&:last-child {
border-start-end-radius: 0.3125rem;
border-end-end-radius: 0.3125rem;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 0.25rem;
margin-top: 0.75rem;
}
.chip {
cursor: pointer;
display: inline-flex;
gap: 0.375rem;
align-items: center;
height: 1.75rem;
padding: 0 0.5625rem 0 0.4375rem;
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 0 0.625rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(.on):hover {
background-color: var(--color-background-secondary-accent);
}
}
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.value {
color: var(--color-text-secondary);
}
.trash-link {
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 1rem;
align-items: center;
width: 100%;
height: 3.25rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-top: 1px solid var(--color-borders);
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
background: none;
:global(.icon) {
color: var(--color-text-secondary);
}
.label {
flex: 1;
}
.value {
display: flex;
gap: 0.375rem;
align-items: center;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.swatch {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color-storage-trash);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.section-title {
display: flex;
gap: 0.75rem;
align-items: baseline;
justify-content: space-between;
padding: 0.875rem 1rem 0.375rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
white-space: nowrap;
}
}
.list {
flex: 1;
padding-bottom: 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>
@@ -0,0 +1,260 @@
<script lang="ts">
import type { Snippet } from "svelte";
import StorageThumb from "#lib/components/disk/StorageThumb.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { groupOf } from "#lib/format/storage.js";
interface Props {
fileName: string;
kind: string;
lead?: string;
menu: Snippet;
mime: string | null;
note?: string;
onopen: () => void;
ontoggle: () => void;
refetchable: boolean;
selected: boolean;
size: number | null;
src: string;
sub: string;
tail?: string;
weight: number;
}
let {
fileName,
kind,
mime,
src,
lead,
sub,
tail,
note,
size,
weight,
refetchable,
selected,
onopen,
ontoggle,
menu,
}: Props = $props();
const MIN_WEIGHT = 0.02;
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
</script>
<div
class="row"
style:--group="var(--color-storage-{groupOf(kind)})"
class:selected
>
<span class="check">
<input
aria-label="Выбрать {fileName}"
checked={selected}
onchange={ontoggle}
type="checkbox"
>
<span class="circle"><Icon name="check" size="0.875rem" /></span>
</span>
<ContextMenu {menu}>
{#snippet children({
props,
})}
<button {...props} class="main" onclick={onopen} type="button">
<StorageThumb {fileName} {kind} {mime} {src} />
<span class="text">
<span class="name">{fileName}</span>
<span class="sub">
{#if lead}
<span class="rigid">{`${lead} · `}</span>
{/if}
<span class="fluid">{sub}</span>
{#if tail}
<span class="rigid">{` · ${tail}`}</span>
{/if}
</span>
{#if note}
<span class="note">{note}</span>
{/if}
{#if !refetchable}
<span class="gone">Не скачать заново</span>
{/if}
</span>
<span class="size">{formatBytes(size)}</span>
<span class="weight"><span style:--scale={scale}></span></span>
</button>
{/snippet}
</ContextMenu>
</div>
<style lang="scss">
.row {
display: flex;
align-items: center;
padding-left: 0.4375rem;
&.selected {
background-color: var(--color-chat-hover);
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.check {
cursor: pointer;
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
align-self: stretch;
width: 2.5rem;
min-height: 2.5rem;
input {
cursor: pointer;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
}
}
.circle {
display: flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border: 2px solid var(--color-borders-input);
border-radius: 50%;
color: transparent;
transition:
background-color 0.15s ease-out,
border-color 0.15s ease-out;
input:checked + & {
border-color: var(--color-primary);
color: var(--color-white);
background-color: var(--color-primary);
}
input:focus-visible + & {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.main {
cursor: pointer;
display: grid;
flex: 1;
grid-template-columns: 3rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
min-width: 0;
padding: 0.5rem 1rem 0.625rem 0.3125rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.name,
.fluid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
display: flex;
min-width: 0;
}
.rigid {
flex-shrink: 0;
white-space: pre;
}
.name {
font-size: 0.9375rem;
}
.sub,
.note,
.gone {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.gone {
color: var(--color-storage-warning);
}
.size {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.weight {
grid-column: 2 / 4;
height: 0.1875rem;
margin-top: 0.4375rem;
border-radius: 0.125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
span {
transform: scaleX(var(--scale));
transform-origin: 0 50%;
display: block;
height: 100%;
border-radius: inherit;
background-color: var(--group);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
}
</style>
@@ -0,0 +1,152 @@
<script lang="ts">
import { visible } from "#lib/actions/visible.js";
import { visualKind } from "#lib/api/media.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { fileExtension, groupOf } from "#lib/format/storage.js";
import { poster } from "#lib/media/poster.js";
interface Props {
fileName: string;
kind: string;
mime: string | null;
src: string;
}
let { src, kind, mime, fileName }: Props = $props();
const GLYPHS: Record<string, string> = {
audio: "volume-2",
other: "photo",
video: "video",
voice: "microphone",
};
let near = $state(false);
let shown = $state(false);
let failed = $state(false);
const group = $derived(groupOf(kind));
const extension = $derived(group === "file" ? fileExtension(fileName) : "");
const glyph = $derived(
kind === "video_note" ? "video" : (GLYPHS[group] ?? "document")
);
const preview = $derived.by(() => {
if (mime?.startsWith("image/") || kind === "photo") {
return "image";
}
if (mime?.startsWith("video/") || visualKind(kind) === "video") {
return "video";
}
return "none";
});
const loadable = $derived(near && !failed);
</script>
<span
class="thumb"
style:--group="var(--color-storage-{group})"
use:visible={() => {
near = true;
}}
>
{#if extension}
<span class="extension">{extension}</span>
{:else}
<Icon name={glyph} size="1.375rem" />
{/if}
{#if loadable && preview === "image"}
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: onload is not a user interaction -->
<img
alt=""
decoding="async"
onerror={() => {
failed = true;
}}
onload={() => {
shown = true;
}}
{src}
class:shown
>
{:else if loadable && preview === "video"}
<video
muted
onerror={() => {
failed = true;
}}
onloadeddata={() => {
shown = true;
}}
playsinline
preload="metadata"
{src}
class:shown
use:poster
></video>
{#if shown}
<span class="play"><Icon name="play" size="0.875rem" /></span>
{/if}
{/if}
</span>
<style lang="scss">
.thumb {
position: relative;
overflow: hidden;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
color: var(--color-white);
background-color: color-mix(
in srgb,
var(--group) var(--storage-tile-mix),
black
);
}
.extension {
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
letter-spacing: 0.04em;
}
img,
video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
object-fit: cover;
transition: opacity 0.2s ease-out;
&.shown {
opacity: 1;
}
}
.play {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background-color: rgb(0 0 0 / 45%);
}
</style>
@@ -0,0 +1,559 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { trashedUrl } from "#lib/api/client.js";
import {
getStorageSummary,
getTrash,
purgeTrash,
restoreFromTrash,
} from "#lib/api/endpoints.js";
import type { ViewerItem } from "#lib/api/media.js";
import type { TrashItem } from "#lib/api/types.js";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import {
filesLabel,
messagesLabel,
purgeLabel,
} from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation";
const PAGE = 30;
let items = $state<TrashItem[]>([]);
let total = $state({ bytes: 0, files: 0 });
let done = $state(false);
let everything = $state(false);
let loading = $state(true);
let failed = $state(false);
let busy = $state(false);
let doomed = $state<TrashItem[] | null>(null);
let problem = $state<string | null>(null);
let viewerOpen = $state(false);
let viewerIndex = $state(0);
const selected = new SvelteSet<string>();
const heaviest = $derived(
Math.max(0, ...items.map((item) => item.file_size ?? 0))
);
const dialogOpen = $derived(doomed !== null || everything);
const chosen = $derived(
items.filter((item) => selected.has(item.storage_key))
);
const chosenGone = $derived(
chosen.filter((item) => !item.refetchable).length
);
const doomedGone = $derived(
doomed?.filter((item) => !item.refetchable).length ?? 0
);
const viewerItems = $derived<ViewerItem[]>(
items.map((item) => ({
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
downloaded: true,
fileName: item.file_name,
fileSize: item.file_size,
kind: item.kind,
mediaId: null,
messageId: item.message_id,
mime: item.mime,
src: trashedUrl(item.storage_key),
}))
);
function bytesOf(list: TrashItem[]): number {
return list.reduce((sum, item) => sum + (item.file_size ?? 0), 0);
}
function chatTitle(chatId: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
}
async function loadTotal() {
const summary = await getStorageSummary();
total = { bytes: summary.trash_bytes, files: summary.trash_files };
}
async function loadMore() {
loading = true;
failed = false;
try {
const batch = await getTrash({ limit: PAGE, offset: items.length });
chats.ensureMany(batch.map((item) => item.chat_id));
items = [...items, ...batch];
done = batch.length < PAGE;
} catch {
failed = true;
} finally {
loading = false;
}
}
async function load() {
items = [];
done = false;
selected.clear();
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
}
function toggle(key: string) {
problem = null;
if (!selected.delete(key)) {
selected.add(key);
}
}
function drop(keys: string[]) {
const gone = new Set(keys);
items = items.filter((item) => !gone.has(item.storage_key));
selected.clear();
loadTotal().catch(() => undefined);
}
async function restore(list: TrashItem[]) {
if (busy || list.length === 0) {
return;
}
busy = true;
problem = null;
const keys = list.map((item) => item.storage_key);
try {
await restoreFromTrash(keys);
drop(keys);
} catch {
problem = "Не удалось вернуть файлы";
} finally {
busy = false;
}
}
async function restoreAll() {
if (busy) {
return;
}
busy = true;
problem = null;
try {
await restoreFromTrash(null);
await load();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
busy = false;
}
}
async function purge() {
if (busy || (doomed === null && !everything)) {
return;
}
busy = true;
problem = null;
const keys = doomed?.map((item) => item.storage_key) ?? null;
try {
await purgeTrash(keys);
doomed = null;
everything = false;
await load();
} catch {
problem = "Не удалось удалить файлы";
} finally {
busy = false;
}
}
function open(index: number) {
viewerIndex = index;
viewerOpen = true;
}
function jump(item: TrashItem) {
ui.requestJump(item.chat_id, item.message_id);
goto(`/app/${item.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
onMount(load);
</script>
<div class="trash">
<p class="note">
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
вернуть.
</p>
{#if total.files > 0}
<button class="act" disabled={busy} onclick={restoreAll} type="button">
<Icon name="undo" size="1.375rem" />
<span class="label">Вернуть всё</span>
<span class="value">{filesLabel(total.files)}</span>
</button>
<button
class="act danger"
onclick={() => {
everything = true;
}}
type="button"
>
<Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span>
<span class="value">{formatBytes(total.bytes)}</span>
</button>
{/if}
<div class="list">
{#each items as item, index (item.storage_key)}
<StorageRow
fileName={item.file_name}
kind={item.kind}
lead={purgeLabel(item.purge_at)}
mime={item.mime}
note={item.refs > 1 ? messagesLabel(item.refs) : undefined}
onopen={() => open(index)}
ontoggle={() => toggle(item.storage_key)}
refetchable={item.refetchable}
selected={selected.has(item.storage_key)}
size={item.file_size}
src={trashedUrl(item.storage_key)}
sub={chatTitle(item.chat_id)}
weight={heaviest > 0 ? (item.file_size ?? 0) / heaviest : 0}
>
{#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
Открыть
</ContextMenuItem>
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
Вернуть
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
Перейти к сообщению
</ContextMenuItem>
<ContextMenuItem
icon="delete"
onselect={() => {
doomed = [item];
}}
>
Удалить насовсем
</ContextMenuItem>
{/snippet}
</StorageRow>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить корзину.</p>
<Button onclick={load} smaller variant="text">Повторить</Button>
</div>
{:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if}
</div>
{#if chosen.length > 0}
<SelectionBar
failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn
>
<Button
onclick={() => {
doomed = chosen;
}}
pill
smaller
variant="danger"
>
Удалить
</Button>
<Button
disabled={busy}
loading={busy}
onclick={() => restore(chosen)}
pill
smaller
>
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</SelectionBar>
{:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось">
<Button
onclick={() => {
problem = null;
}}
smaller
variant="text"
>
Понятно
</Button>
</SelectionBar>
{/if}
</div>
<Dialog.Root
onOpenChange={(next) => {
if (!next) {
doomed = null;
everything = false;
problem = null;
}
}}
open={doomed !== null || everything}
>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if everything}
<p class="confirm">
{filesLabel(total.files)}, {formatBytes(total.bytes)},
{plural(
total.files,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
<p class="confirm rest">
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
удалённых там сообщений пропадут насовсем.
</p>
{:else if doomed}
<p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural(
doomed.length,
"будет удалён",
"будут удалены",
"будут удалены"
)}
с диска.
</p>
{#if doomedGone > 0}
<p class="confirm warn">
<Icon name="warning" size="1.25rem" />
<span>
{filesLabel(doomedGone)}
скачать заново нельзя: в Telegram
{doomedGone === 1 ? "его" : "их"}
уже нет.
</span>
</p>
{/if}
{#if doomedGone < doomed.length}
<p class="confirm rest">
{doomedGone === 0 ? "Потом их" : "Остальные"}
можно скачать из Telegram заново.
</p>
{/if}
{/if}
{#if problem}
<p class="confirm failed">{problem}.</p>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">Отмена</Button>
{/snippet}
</Dialog.Close>
<Button
disabled={busy}
loading={busy}
onclick={purge}
pill
variant="danger"
>
Удалить
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<MediaViewer
chatId={items[viewerIndex]?.chat_id ?? 0}
items={viewerItems}
bind:index={viewerIndex}
bind:open={viewerOpen}
/>
<style lang="scss">
.trash {
display: flex;
flex-direction: column;
min-height: 100%;
}
.note {
margin: 0;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.act {
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 0.875rem;
align-items: center;
width: 100%;
height: 3rem;
padding: 0 1rem 0 1.125rem;
border: 0;
border-bottom: 1px solid var(--color-borders);
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
text-align: start;
background: none;
&.danger {
color: var(--color-error);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
.label {
flex: 1;
}
.value {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: -2px;
}
@media (hover: hover) {
&:not(:disabled):hover {
background-color: var(--color-chat-hover);
}
}
}
.more {
cursor: pointer;
width: calc(100% - 1rem);
margin: 0.5rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
.list {
flex: 1;
padding: 0.25rem 0 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
}
}
.confirm {
margin: 0;
font-size: 0.9375rem;
line-height: 1.45;
&.rest,
&.failed {
margin-top: 0.75rem;
}
&.failed {
color: var(--color-error);
}
&.warn {
display: flex;
gap: 0.625rem;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--color-storage-warning);
}
}
</style>
@@ -1,106 +0,0 @@
<script lang="ts">
import type { CaptureToggles } from "#lib/api/types.js";
interface Props {
disabled?: boolean;
onchange: (key: keyof CaptureToggles, value: boolean) => void;
toggles: CaptureToggles;
}
let { toggles, disabled = false, onchange }: Props = $props();
const rows: { key: keyof CaptureToggles; label: string }[] = [
{ key: "messages", label: "Сообщения" },
{ key: "media", label: "Медиа" },
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
{ key: "stt", label: "Расшифровка голоса" },
{ key: "reactions", label: "Реакции" },
{ key: "track_edits_deletes", label: "Правки и удаления" },
{ key: "profile_history", label: "История профиля" },
{ key: "stories", label: "Сторис" },
{ key: "presence", label: "Presence" },
{ key: "backfill", label: "Бэкфилл" },
];
</script>
<div class="toggles">
{#each rows as row (row.key)}
<button
aria-checked={toggles[row.key]}
class="toggle-row"
{disabled}
onclick={() => onchange(row.key, !toggles[row.key])}
role="switch"
type="button"
>
<span class="label">{row.label}</span>
<span class="switch" class:on={toggles[row.key]}>
<span class="knob"></span>
</span>
</button>
{/each}
</div>
<style lang="scss">
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.5rem 1rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--color-chat-hover);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.label {
flex: 1;
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
</style>
@@ -0,0 +1,640 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
getPolicyScope,
getStorageScope,
restoreFromTrash,
trashScope,
updatePolicy,
} from "#lib/api/endpoints.js";
import type {
CaptureToggles,
PolicyParent,
PolicyRecord,
PolicyScopeType,
ScopeTarget,
StorageScope,
TrashOutcome,
} from "#lib/api/types.js";
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 { formatBytes } from "#lib/format/media.js";
import {
parentLabel,
pickToggles,
preset,
sameToggles,
summary,
TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey,
} from "#lib/format/policy.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
interface Props {
onchange?: () => void;
parent?: PolicyParent | null;
rule?: PolicyRecord | null;
scopeId: number | null;
scopeType: PolicyScopeType;
}
let {
scopeType,
scopeId,
rule: initialRule,
parent: initialParent,
onchange,
}: Props = $props();
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
{ label: "Всё", toggles: preset("all") },
{ label: "Только сообщения", toggles: preset(["messages"]) },
{ label: "Ничего", toggles: preset([]) },
];
let rule = $state<PolicyRecord | null>(null);
let parent = $state<PolicyParent | null>(null);
let loading = $state(false);
let saving = $state(false);
let problem = $state<string | null>(null);
let files = $state<StorageScope | null>(null);
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
let removing = $state(false);
const isChat = $derived(scopeType === "chat");
const isFolder = $derived(scopeType === "folder");
const target = $derived<ScopeTarget | null>(scopeTarget());
const shown = $derived<CaptureToggles | null>(
rule ?? parent?.toggles ?? null
);
const hint = $derived(inheritance());
function scopeTarget(): ScopeTarget | null {
if (scopeId === null) {
return null;
}
if (scopeType === "chat") {
return { chatId: scopeId };
}
return scopeType === "folder" ? { folderId: scopeId } : null;
}
function inheritance(): string {
if (isChat && parent) {
const from = parentLabel(parent.scope_type, parent.title);
return rule
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
}
if (isFolder) {
return rule
? "Действует на чаты папки, у которых нет своего правила."
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
}
return "";
}
function differs(key: ToggleKey): boolean {
return (
rule !== null && parent !== null && rule[key] !== parent.toggles[key]
);
}
async function save(next: CaptureToggles) {
if (saving) {
return;
}
saving = true;
problem = null;
try {
rule = rule
? await updatePolicy(rule.id, next)
: await createPolicy({
...next,
scope_id: scopeId,
scope_type: scopeType,
});
onchange?.();
} catch {
problem = "Не удалось сохранить правило";
} finally {
saving = false;
}
}
function flip(key: ToggleKey) {
if (shown !== null) {
save({ ...pickToggles(shown), [key]: !shown[key] });
}
}
async function remove() {
if (rule === null || saving) {
return;
}
saving = true;
problem = null;
try {
await deletePolicy(rule.id);
rule = null;
removing = false;
onchange?.();
} catch {
problem = "Не удалось удалить правило";
} finally {
saving = false;
}
}
async function loadFiles() {
if (target === null) {
return;
}
try {
files = await getStorageScope(target);
} catch {
files = null;
}
}
async function moveFiles() {
if (target === null || moving) {
return;
}
moving = true;
problem = null;
try {
moved = await trashScope(target);
confirming = false;
await loadFiles();
} catch {
problem = "Не удалось переместить файлы в корзину";
} finally {
moving = false;
}
}
async function undoMove() {
if (moved === null || moving) {
return;
}
moving = true;
problem = null;
try {
await restoreFromTrash(moved.keys);
moved = null;
await loadFiles();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
moving = false;
}
}
function where(scope: StorageScope): string {
return isFolder
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
}
onMount(async () => {
rule = initialRule ?? null;
parent = initialParent ?? null;
if (target === null) {
return;
}
if (initialRule === undefined) {
loading = true;
try {
({ rule, parent } = await getPolicyScope(target));
} catch {
problem = "Не удалось загрузить правило";
} finally {
loading = false;
}
}
await loadFiles();
});
</script>
<div class="card">
{#if loading}
<div class="center"><Spinner /></div>
{:else}
{#if hint}
<p class="hint">{hint}</p>
{/if}
<div class="presets">
{#each PRESETS as item (item.label)}
<button
aria-pressed={shown !== null && sameToggles(shown, item.toggles)}
class="preset"
disabled={saving}
onclick={() => save(item.toggles)}
type="button"
class:on={shown !== null && sameToggles(shown, item.toggles)}
>
{item.label}
</button>
{/each}
</div>
{#if shown}
<div class="toggles">
{#each TOGGLE_KEYS as key (key)}
<button
aria-checked={shown[key]}
class="toggle"
disabled={saving}
onclick={() => flip(key)}
role="switch"
type="button"
class:changed={differs(key)}
>
<span class="label">{TOGGLE_LABELS[key]}</span>
<span class="switch" class:on={shown[key]}>
<span class="knob"></span>
</span>
</button>
{/each}
</div>
{/if}
{#if problem}
<p class="problem">{problem}</p>
{/if}
{#if moved && moved.files > 0}
<div class="files">
<div class="files-text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
</div>
<Button disabled={moving} onclick={undoMove} smaller variant="text">
<Icon name="undo" size="1.25rem" />
<span>Вернуть</span>
</Button>
</div>
{:else if files && files.files > 0}
<div class="files">
{#if confirming}
<div class="files-text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в
корзину и перестанут скачиваться сами.
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
</small>
{/if}
<div class="files-actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
Отмена
</Button>
<Button
disabled={moving}
loading={moving}
onclick={moveFiles}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</div>
</div>
{:else}
<div class="files-text">
<span>{formatBytes(files.bytes)} уже скачано</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах, они
останутся
</small>
{/if}
</div>
<Button
onclick={() => {
confirming = true;
}}
pill
smaller
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
{/if}
</div>
{/if}
{#if rule && (isChat || isFolder)}
{#if removing}
<div class="removal">
<span>Удалить правило?</span>
<Button
onclick={() => {
removing = false;
}}
smaller
variant="text"
>
Отмена
</Button>
<Button
disabled={saving}
onclick={remove}
pill
smaller
variant="danger"
>
Удалить
</Button>
</div>
{:else}
<button
class="remove"
onclick={() => {
removing = true;
}}
type="button"
>
Удалить правило
</button>
{/if}
{/if}
{/if}
</div>
<style lang="scss">
.card {
padding: 0.25rem 0 0.5rem;
}
.center {
display: flex;
justify-content: center;
padding: 1.5rem 0;
}
.hint {
margin: 0;
padding: 0.5rem 1rem 0.25rem;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.presets {
display: flex;
gap: 0.25rem;
margin: 0.5rem 1rem 0.25rem;
padding: 0.125rem;
border-radius: 0.625rem;
background-color: var(--color-background-secondary);
}
.preset {
cursor: pointer;
flex: 1;
height: 2rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
background: none;
transition:
background-color 0.15s ease-out,
color 0.15s ease-out;
&.on {
color: var(--color-text);
background-color: var(--color-background);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (pointer: coarse) {
height: 2.75rem;
}
}
.toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 0.5rem;
padding: 0.25rem 0.5rem;
}
.toggle {
cursor: pointer;
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
height: 2.75rem;
padding: 0 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-text);
text-align: start;
background: none;
&.changed .label {
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
&:disabled {
cursor: default;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(:disabled):hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.switch {
display: flex;
flex-shrink: 0;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders-input);
transition: background-color 0.15s ease-out;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease-out;
.on & {
transform: translateX(0.875rem);
}
}
.problem {
margin: 0;
padding: 0.25rem 1rem;
font-size: 0.8125rem;
color: var(--color-error);
}
.files {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin: 0.5rem 1rem 0.25rem;
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
border-radius: 0.75rem;
background-color: var(--color-background-secondary);
:global(.Button) {
gap: 0.375rem;
padding-inline: 0.875rem;
}
}
.files-text {
display: flex;
flex: 1 1 10rem;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.warn {
color: var(--color-storage-warning);
}
}
}
.files-actions {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
margin-top: 0.375rem;
}
.removal {
display: flex;
gap: 0.25rem;
align-items: center;
margin: 0.25rem 0.5rem 0;
padding-left: 0.5rem;
font-size: 0.875rem;
span {
flex: 1;
}
}
.remove {
cursor: pointer;
display: block;
margin: 0.25rem 0.5rem 0;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-error);
background: none;
@media (pointer: coarse) {
min-height: 2.75rem;
}
&:focus-visible {
outline: 2px solid var(--color-error);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { policyUi } from "#lib/stores/policy.svelte.js";
const target = $derived(policyUi.target);
const isChat = $derived(target !== null && "chatId" in target);
const scopeId = $derived.by(() => {
if (target === null) {
return null;
}
return "chatId" in target ? target.chatId : target.folderId;
});
</script>
<Dialog.Root bind:open={policyUi.open}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<div class="heading">
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
<span class="kind">
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
</span>
</div>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="body custom-scroll">
{#key target}
<PolicyCard
onchange={() => policyUi.touch()}
{scopeId}
scopeType={isChat ? "chat" : "folder"}
/>
{/key}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.heading {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.kind {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.body {
overflow-y: auto;
padding-bottom: 0.5rem;
}
</style>
@@ -1,241 +1,369 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
getPolicyOverview,
listFolders,
listPolicies,
updatePolicy,
} from "#lib/api/endpoints.js";
import type {
CaptureToggles,
Folder,
PolicyRecord,
PolicyScopeType,
} from "#lib/api/types.js";
import CaptureToggleList from "#lib/components/policy/CaptureToggleList.svelte";
import type { Folder, PolicyRule, PolicyScopeType } from "#lib/api/types.js";
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
import Avatar from "#lib/components/ui/Avatar.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 { accounts } from "#lib/stores/accounts.svelte.js";
import {
type Chip,
contentChips,
DEFAULT_LABELS,
diffChips,
sameToggles,
summary,
} from "#lib/format/policy.js";
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
let policies = $state<PolicyRecord[]>([]);
let folders = $state<Folder[]>([]);
let loading = $state(true);
let pickFolder = $state(false);
let pickChat = $state(false);
let chatFilter = $state("");
interface Draft {
scopeId: number;
scopeType: "folder" | "chat";
title: string;
}
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
{ label: "Личные чаты", scope: "default_dm" },
{ label: "Группы", scope: "default_group" },
{ label: "Каналы", scope: "default_channel" },
const DEFAULT_ORDER: PolicyScopeType[] = [
"default_dm",
"default_group",
"default_channel",
];
const ALL_FALSE: CaptureToggles = {
backfill: false,
media: false,
messages: false,
presence: false,
profile_history: false,
reactions: false,
self_destruct_media: false,
stories: false,
stt: false,
track_edits_deletes: false,
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "channel",
default_dm: "user",
default_group: "group",
};
function pickToggles(t: CaptureToggles): CaptureToggles {
return {
backfill: t.backfill,
media: t.media,
messages: t.messages,
presence: t.presence,
profile_history: t.profile_history,
reactions: t.reactions,
self_destruct_media: t.self_destruct_media,
stories: t.stories,
stt: t.stt,
track_edits_deletes: t.track_edits_deletes,
};
}
let rules = $state<PolicyRule[]>([]);
let folders = $state<Folder[]>([]);
let loading = $state(true);
let failed = $state(false);
let problem = $state<string | null>(null);
let expanded = $state<string | null>(null);
let draft = $state<Draft | null>(null);
let picking = $state<"folder" | "chat" | null>(null);
let chatFilter = $state("");
const picker = createChatPicker();
const defaults = $derived(
DEFAULT_SCOPES.map((d) => ({
label: d.label,
record: policies.find((p) => p.scope_type === d.scope),
})).filter((d): d is { label: string; record: PolicyRecord } =>
Boolean(d.record)
)
DEFAULT_ORDER.map((scope) =>
rules.find((rule) => rule.scope_type === scope)
).filter((rule): rule is PolicyRule => rule !== undefined)
);
const folderPolicies = $derived(
policies.filter((p) => p.scope_type === "folder")
const folderRules = $derived(
rules
.filter((rule) => rule.scope_type === "folder")
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
);
const chatPolicies = $derived(
policies.filter((p) => p.scope_type === "chat")
const chatRules = $derived(
rules
.filter((rule) => rule.scope_type === "chat")
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
);
const availableFolders = $derived(
const freeFolders = $derived(
folders.filter(
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
(folder) =>
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
)
);
const picker = createChatPicker();
const availableChats = $derived(
const freeChats = $derived(
picker.results.filter(
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
(chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
)
);
$effect(() => {
picker.search(chatFilter);
});
$effect(() => {
chats.ensureMany(
chatPolicies
.map((policy) => policy.scope_id)
.filter((id): id is number => id !== null)
);
});
function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
function folderOrder(id: number | null): number {
return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
}
function chatTitle(id: number | null): string {
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
return `${scopeType}:${scopeId}`;
}
function title(rule: PolicyRule): string {
if (rule.scope_type === "chat") {
return rule.title ?? `Чат ${rule.scope_id}`;
}
if (rule.scope_type === "folder") {
return rule.title ?? `Папка ${rule.scope_id}`;
}
return DEFAULT_LABELS[rule.scope_type] ?? "";
}
function redundant(rule: PolicyRule): boolean {
return rule.parent !== null && sameToggles(rule, rule.parent.toggles);
}
function chips(rule: PolicyRule): Chip[] {
return rule.parent
? diffChips(rule, rule.parent.toggles)
: contentChips(rule);
}
function reveal(node: HTMLElement) {
const observer = new ResizeObserver(() => {
node.parentElement?.scrollIntoView({ block: "nearest" });
});
observer.observe(node);
return () => observer.disconnect();
}
function draftBefore(rule: PolicyRule): boolean {
if (draft?.scopeType !== "folder") {
return false;
}
const slot = folderOrder(draft.scopeId);
const next = folderRules.find((item) => folderOrder(item.scope_id) > slot);
return next?.id === rule.id;
}
const draftLast = $derived(
draft?.scopeType === "folder" &&
!folderRules.some((rule) => draftBefore(rule))
);
async function reload() {
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
}
async function toggle(
record: PolicyRecord,
key: keyof CaptureToggles,
value: boolean
) {
const next = { ...pickToggles(record), [key]: value };
policies = policies.map((p) =>
p.id === record.id ? { ...p, [key]: value } : p
);
try {
const saved = await updatePolicy(record.id, next);
policies = policies.map((p) => (p.id === record.id ? saved : p));
[rules, folders] = await Promise.all([
getPolicyOverview(),
listFolders(),
]);
failed = false;
if (
draft &&
rules.some(
(rule) =>
rule.scope_type === draft?.scopeType &&
rule.scope_id === draft?.scopeId
)
) {
expanded = keyOf(draft.scopeType, draft.scopeId);
draft = null;
}
} catch {
toasts.error("Не удалось сохранить политику");
await reload();
}
}
async function addOverride(scope: "folder" | "chat", scopeId: number) {
try {
const record = await createPolicy({
...ALL_FALSE,
scope_id: scopeId,
scope_type: scope,
});
policies = [...policies, record];
pickFolder = false;
pickChat = false;
chatFilter = "";
} catch {
toasts.error("Не удалось создать оверрайд");
}
}
async function removeOverride(record: PolicyRecord) {
policies = policies.filter((p) => p.id !== record.id);
try {
await deletePolicy(record.id);
} catch {
toasts.error("Не удалось удалить оверрайд");
await reload();
}
}
async function load(_account: number | null) {
loading = true;
chats.load();
try {
await reload();
} catch {
toasts.error("Не удалось загрузить политики");
failed = true;
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId);
function toggle(rule: PolicyRule) {
const key = keyOf(rule.scope_type, rule.scope_id);
expanded = expanded === key ? null : key;
}
async function drop(rule: PolicyRule) {
problem = null;
try {
await deletePolicy(rule.id);
await reload();
} catch {
problem = "Не удалось убрать правило";
}
}
function start(scopeType: "folder" | "chat", scopeId: number, name: string) {
draft = { scopeId, scopeType, title: name };
picking = null;
chatFilter = "";
expanded = null;
}
function togglePicker(kind: "folder" | "chat") {
picking = picking === kind ? null : kind;
if (picking === "chat") {
picker.search(chatFilter);
}
}
onMount(() => {
chats.load();
reload();
});
</script>
{#snippet card(
record: PolicyRecord,
title: string,
onremove?: () => void
{#snippet lead(
rule: PolicyRule
)}
<div class="card">
<div class="card-head">
<span class="card-title">{title}</span>
{#if record.account_id === null}
<span class="shared">для всех аккаунтов</span>
{/if}
{#if onremove && record.account_id !== null}
<button
aria-label="Удалить оверрайд"
class="remove"
onclick={onremove}
type="button"
>
<Icon name="close" size="1.125rem" />
</button>
{/if}
</div>
<CaptureToggleList
onchange={(key, value) => toggle(record, key, value)}
toggles={record}
{#if rule.scope_type === "chat" && rule.scope_id !== null}
<Avatar
avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
colorKey={rule.scope_id}
hasAvatar={chats.byId(rule.scope_id)?.has_avatar ?? false}
name={title(rule)}
size={2.25}
/>
{:else}
<span class="glyph">
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
</span>
{/if}
{/snippet}
{#snippet row(
rule: PolicyRule,
plain: boolean
)}
{@const key = keyOf(rule.scope_type, rule.scope_id)}
{@const open = expanded === key}
{@const extra = !plain && redundant(rule)}
<div class="rule" class:open>
<div class="line">
<button
aria-expanded={open}
class="main"
onclick={() => toggle(rule)}
type="button"
>
{@render lead(rule)}
<span class="text">
<span class="name">{title(rule)}</span>
{#if plain}
<span class="sub">{summary(rule)}</span>
{:else if extra}
<span class="sub">Ничего не меняет</span>
{:else}
<span class="chips">
{#each chips(rule) as chip (chip.sign + chip.label)}
<span class="chip {chip.sign}" title={chip.hint}>
{chip.sign === "plus" ? "+" : "−"}
{chip.label}
</span>
{/each}
</span>
{/if}
</span>
</button>
{#if extra}
<Button onclick={() => drop(rule)} smaller variant="text">
Убрать
</Button>
{/if}
<button
aria-hidden="true"
class="chevron"
onclick={() => toggle(rule)}
tabindex="-1"
type="button"
>
<Icon name={open ? "up" : "down"} size="1.125rem" />
</button>
</div>
{#if open}
<div class="editor" {@attach reveal}>
<PolicyCard
onchange={reload}
parent={rule.parent}
{rule}
scopeId={rule.scope_id}
scopeType={rule.scope_type}
/>
</div>
{/if}
</div>
{/snippet}
{#snippet pending(
item: Draft
)}
<div class="rule open">
<div class="line">
<div class="main static">
{#if item.scopeType === "chat"}
<Avatar
avatar={{
id: item.scopeId,
kind: item.scopeId > 0 ? "peer" : "chat",
}}
colorKey={item.scopeId}
hasAvatar={chats.byId(item.scopeId)?.has_avatar ?? false}
name={item.title}
size={2.25}
/>
{:else}
<span class="glyph"><Icon name="folder" size="1.5rem" /></span>
{/if}
<span class="text">
<span class="name">{item.title}</span>
<span class="sub">Новое правило</span>
</span>
</div>
<Button
onclick={() => {
draft = null;
}}
smaller
variant="text"
>
Отмена
</Button>
</div>
<div class="editor" {@attach reveal}>
<PolicyCard
onchange={reload}
scopeId={item.scopeId}
scopeType={item.scopeType}
/>
</div>
</div>
{/snippet}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить политику.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else}
<div class="editor">
<div class="policy">
{#if problem}
<p class="problem">{problem}</p>
{/if}
<section>
<div class="section-title">По умолчанию</div>
{#each defaults as item (item.record.id)}
{@render card(item.record, item.label)}
<div class="section-title"><h3>По умолчанию</h3></div>
{#each defaults as rule (rule.id)}
{@render row(rule, true)}
{/each}
</section>
<section>
<div class="section-title">Оверрайды папок</div>
{#each folderPolicies as record (record.id)}
{@render card(record, folderTitle(record.scope_id), () =>
removeOverride(record)
)}
<div class="section-title">
<h3>Папки</h3>
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
</div>
{#each folderRules as rule (rule.id)}
{#if draft && draftBefore(rule)}
{@render pending(draft)}
{/if}
{@render row(rule, false)}
{/each}
{#if availableFolders.length > 0}
{#if draft && draftLast}
{@render pending(draft)}
{/if}
{#if freeFolders.length > 0}
<button
aria-expanded={picking === "folder"}
class="add"
onclick={() => {
pickFolder = !pickFolder;
}}
onclick={() => togglePicker("folder")}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Добавить папку</span>
<span>Правило для папки</span>
</button>
{#if pickFolder}
{#if picking === "folder"}
<div class="picker">
{#each availableFolders as folder (folder.folder_id)}
{#each freeFolders as folder (folder.folder_id)}
<button
class="pick-row"
onclick={() => addOverride("folder", folder.folder_id)}
class="pick"
onclick={() => start("folder", folder.folder_id, folder.title)}
type="button"
>
{folder.title}
@@ -247,38 +375,50 @@
</section>
<section>
<div class="section-title">Оверрайды чатов</div>
{#each chatPolicies as record (record.id)}
{@render card(record, chatTitle(record.scope_id), () =>
removeOverride(record)
)}
<div class="section-title">
<h3>Чаты</h3>
<span>Что меняют против папки или умолчания. Важнее папок.</span>
</div>
{#if draft?.scopeType === "chat"}
{@render pending(draft)}
{/if}
{#each chatRules as rule (rule.id)}
{@render row(rule, false)}
{/each}
<button
aria-expanded={picking === "chat"}
class="add"
onclick={() => {
pickChat = !pickChat;
}}
onclick={() => togglePicker("chat")}
type="button"
>
<Icon name="add" size="1.25rem" />
<span>Добавить чат</span>
<span>Правило для чата</span>
</button>
{#if pickChat}
{#if picking === "chat"}
<div class="picker">
<input
aria-label="Поиск чата"
class="filter"
oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата"
type="text"
bind:value={chatFilter}
>
{#each availableChats as chat (chat.chat_id)}
{#each freeChats as chat (chat.chat_id)}
<button
class="pick-row"
onclick={() => addOverride("chat", chat.chat_id)}
class="pick"
onclick={() =>
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
type="button"
>
{chat.title ?? `Чат ${chat.chat_id}`}
</button>
{:else}
<p class="nothing">Ничего не найдено.</p>
{/each}
</div>
{/if}
@@ -289,79 +429,211 @@
<style lang="scss">
.state {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
justify-content: center;
padding: 2rem;
padding: 2rem 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
p {
margin: 0;
}
}
.editor {
.policy {
padding-bottom: 1rem;
}
section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-borders);
.problem {
margin: 0;
padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
}
.section-title {
padding: 0.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.875rem 1rem 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.card {
padding: 0.25rem 0 0.5rem;
}
.card-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
}
.card-title {
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
}
.shared {
font-size: 0.75rem;
line-height: 1.35;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
}
.remove {
cursor: pointer;
display: flex;
padding: 0.25rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
.rule.open {
border-bottom: 1px solid var(--color-borders);
&:hover {
.line {
background-color: var(--color-chat-hover);
}
}
.add {
cursor: pointer;
.line {
display: flex;
align-items: center;
gap: 1rem;
padding-right: 0.375rem;
}
width: 100%;
padding: 0.625rem 1rem;
.main {
cursor: pointer;
display: flex;
flex: 1;
gap: 0.75rem;
align-items: center;
min-width: 0;
min-height: 3.25rem;
padding: 0.4375rem 0.5rem 0.4375rem 1rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
:global(.icon) {
color: var(--color-text-secondary);
}
&.static {
cursor: default;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.static):hover {
background-color: var(--color-chat-hover);
}
}
}
.chevron {
cursor: pointer;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2rem;
height: 2.5rem;
padding: 0;
border: 0;
color: var(--color-text-secondary);
background: none;
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.1875rem;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.chip {
display: inline-flex;
align-items: center;
height: 1.375rem;
padding: 0 0.5rem;
border-radius: 0.6875rem;
font-size: 0.75rem;
white-space: nowrap;
&.plus {
color: var(--color-policy-plus);
background-color: var(--color-policy-plus-fill);
}
&.minus {
color: var(--color-policy-minus);
background-color: var(--color-policy-minus-fill);
}
}
.editor {
padding-bottom: 0.25rem;
}
.add {
cursor: pointer;
display: flex;
gap: 0.75rem;
align-items: center;
width: 100%;
height: 2.75rem;
padding: 0 1rem 0 1.5rem;
border: 0;
font: inherit;
font-size: 0.9375rem;
color: var(--color-primary);
background-color: transparent;
text-align: start;
&:hover {
background-color: var(--color-chat-hover);
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
@@ -377,8 +649,10 @@
border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default);
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
background-color: var(--color-background);
&:focus {
@@ -387,8 +661,9 @@
}
}
.pick-row {
.pick {
cursor: pointer;
overflow: hidden;
width: 100%;
@@ -396,15 +671,35 @@
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.9375rem;
color: var(--color-text);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
@media (pointer: coarse) {
min-height: 2.75rem;
}
}
.nothing {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
</style>
@@ -43,10 +43,15 @@
onclick={() => ui.openPanel("policy")}
/>
<SettingsItem
icon="data"
label="Данные и хранилище"
icon="cloud-download"
label="Бэкфилл и задачи"
onclick={() => ui.openPanel("jobs")}
/>
<SettingsItem
icon="data"
label="Хранилище"
onclick={() => ui.openPanel("storage")}
/>
<SettingsItem
icon="play-story"
label="Сторис"
@@ -32,10 +32,14 @@
let groups = $state<Group[]>([]);
let loading = $state(false);
const previews = $state<Record<number, string | null>>({});
const previews = $state<Record<string, string | null>>({});
const expanded = $state<Record<number, boolean>>({});
let token = 0;
function previewKey(story: StoryView): string {
return `${story.peer_id}:${story.story_id}`;
}
let viewerOpen = $state(false);
let viewerIndex = $state(0);
let viewerItems = $state<StoryView[]>([]);
@@ -78,7 +82,7 @@
kind: "peer" as const,
name: peerName(peer) || `ID ${peerId}`,
peerId,
stories,
stories: peerStories,
};
}
const chat = chatById.get(peerId);
@@ -87,7 +91,7 @@
kind: "chat" as const,
name: chat?.title ?? `Chat ${peerId}`,
peerId,
stories,
stories: peerStories,
};
});
} finally {
@@ -106,7 +110,7 @@
groups = [];
loading = false;
for (const key of Object.keys(previews)) {
delete previews[Number(key)];
delete previews[key];
}
for (const key of Object.keys(expanded)) {
delete expanded[Number(key)];
@@ -122,13 +126,14 @@
let active = true;
untrack(() => {
for (const item of list) {
if (!item.downloaded || previews[item.story_id] !== undefined) {
const key = previewKey(item);
if (!item.downloaded || previews[key] !== undefined) {
continue;
}
previews[item.story_id] = null;
previews[key] = null;
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
if (active) {
previews[item.story_id] = url;
previews[key] = url;
}
});
}
@@ -210,7 +215,7 @@
{#each group.stories as item, index (item.story_id)}
<StoryTile
onopen={() => openViewer(group, index)}
preview={previews[item.story_id]}
preview={previews[previewKey(item)]}
story={item}
/>
{/each}
+167
View File
@@ -0,0 +1,167 @@
import type { CaptureToggles, PolicyScopeType } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
export type ToggleKey = keyof CaptureToggles;
export const TOGGLE_LABELS: Record<ToggleKey, string> = {
backfill: "Бэкфилл",
media: "Медиа",
messages: "Сообщения",
presence: "Присутствие",
profile_history: "История профиля",
reactions: "Реакции",
self_destruct_media: "Исчезающие",
stories: "Сторис",
stt: "Расшифровка",
track_edits_deletes: "Правки, удаления",
};
export const TOGGLE_KEYS: ToggleKey[] = [
"messages",
"media",
"self_destruct_media",
"stt",
"reactions",
"track_edits_deletes",
"profile_history",
"stories",
"presence",
"backfill",
];
export const DEFAULT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "Каналы",
default_dm: "Личные чаты",
default_group: "Группы",
};
const PARENT_LABELS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "у каналов",
default_dm: "у личных чатов",
default_group: "у групп",
};
const SHOWN_CHIPS = 3;
const SHORT_LIST = 2;
export interface Chip {
hint?: string;
label: string;
sign: "plus" | "minus";
}
export function pickToggles(source: CaptureToggles): CaptureToggles {
return {
backfill: source.backfill,
media: source.media,
messages: source.messages,
presence: source.presence,
profile_history: source.profile_history,
reactions: source.reactions,
self_destruct_media: source.self_destruct_media,
stories: source.stories,
stt: source.stt,
track_edits_deletes: source.track_edits_deletes,
};
}
export function preset(only: ToggleKey[] | "all"): CaptureToggles {
const toggles = {} as CaptureToggles;
for (const key of TOGGLE_KEYS) {
toggles[key] = only === "all" || only.includes(key);
}
return toggles;
}
export function sameToggles(a: CaptureToggles, b: CaptureToggles): boolean {
return TOGGLE_KEYS.every((key) => a[key] === b[key]);
}
const EXCEPT_LABELS: Record<ToggleKey, string> = {
backfill: "бэкфилла",
media: "медиа",
messages: "сообщений",
presence: "присутствия",
profile_history: "истории профиля",
reactions: "реакций",
self_destruct_media: "исчезающих",
stories: "сторис",
stt: "расшифровки",
track_edits_deletes: "правок и удалений",
};
export function summary(toggles: CaptureToggles): string {
const on = TOGGLE_KEYS.filter((key) => toggles[key]);
const off = TOGGLE_KEYS.filter((key) => !toggles[key]);
if (off.length === 0) {
return "Всё";
}
if (on.length === 0) {
return "Ничего";
}
if (on.length === 1 && on[0] === "messages") {
return "Только сообщения";
}
if (off.length <= SHORT_LIST) {
return `Всё, кроме ${off.map((key) => EXCEPT_LABELS[key]).join(" и ")}`;
}
const names = on.slice(0, SHORT_LIST).map((key) => TOGGLE_LABELS[key]);
const rest = on.length - names.length;
return rest > 0 ? `${names.join(", ")} и ещё ${rest}` : names.join(", ");
}
export function parentLabel(
scopeType: PolicyScopeType,
title: string | null
): string {
if (scopeType === "folder") {
return `у папки «${title ?? "без названия"}»`;
}
return PARENT_LABELS[scopeType] ?? "по умолчанию";
}
function labelList(keys: ToggleKey[]): string {
return keys.map((key) => TOGGLE_LABELS[key]).join(", ");
}
function named(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
return keys.map((key) => ({ label: TOGGLE_LABELS[key], sign }));
}
function counted(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
if (keys.length === 0) {
return [];
}
if (keys.length <= SHOWN_CHIPS) {
return named(keys, sign);
}
const word = plural(keys.length, "настройка", "настройки", "настроек");
return [{ hint: labelList(keys), label: `${keys.length} ${word}`, sign }];
}
export function contentChips(rule: CaptureToggles): Chip[] {
const on = TOGGLE_KEYS.filter((key) => rule[key]);
const off = TOGGLE_KEYS.filter((key) => !rule[key]);
if (off.length === 0) {
return [{ label: "Всё включено", sign: "plus" }];
}
if (on.length === 0) {
return [{ label: "Всё выключено", sign: "minus" }];
}
if (off.length <= SHOWN_CHIPS) {
return [{ label: "Всё", sign: "plus" }, ...named(off, "minus")];
}
return [...counted(on, "plus"), ...counted(off, "minus")];
}
export function diffChips(rule: CaptureToggles, base: CaptureToggles): Chip[] {
const plus = TOGGLE_KEYS.filter((key) => rule[key] && !base[key]);
const minus = TOGGLE_KEYS.filter((key) => !rule[key] && base[key]);
if (plus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => rule[key])) {
return [{ label: "Всё включено", sign: "plus" }];
}
if (minus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => !rule[key])) {
return [{ label: "Всё выключено", sign: "minus" }];
}
return [...counted(plus, "plus"), ...counted(minus, "minus")];
}
+51
View File
@@ -0,0 +1,51 @@
import type { StorageGroupName } from "#lib/api/types.js";
import { plural } from "#lib/format/shares.js";
const DAY_MS = 86_400_000;
const EXTENSION_LIMIT = 4;
export const GROUP_LABELS: Record<StorageGroupName, string> = {
audio: "Аудио",
file: "Файлы",
other: "Фото и прочее",
video: "Видео",
voice: "Кружки и голосовые",
};
const KIND_GROUPS: Record<string, StorageGroupName> = {
audio: "audio",
document: "file",
video: "video",
video_note: "voice",
voice: "voice",
};
export function groupOf(kind: string): StorageGroupName {
return KIND_GROUPS[kind] ?? "other";
}
export function filesLabel(count: number): string {
return `${count.toLocaleString("ru-RU")} ${plural(count, "файл", "файла", "файлов")}`;
}
export function messagesLabel(count: number): string {
return `в ${count} ${plural(count, "сообщении", "сообщениях", "сообщениях")}`;
}
export function fileExtension(fileName: string): string {
const tail = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
return tail.length > 0 && tail.length <= EXTENSION_LIMIT
? tail.toUpperCase()
: "";
}
export function purgeLabel(iso: string): string {
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS);
if (days <= 0) {
return "сегодня";
}
if (days === 1) {
return "завтра";
}
return `через ${days} дн.`;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ScopeTarget } from "#lib/api/types.js";
function createPolicyUi() {
let open = $state(false);
let target = $state<ScopeTarget | null>(null);
let title = $state("");
let revision = $state(0);
return {
edit(next: ScopeTarget, name: string) {
target = next;
title = name;
open = true;
},
get open() {
return open;
},
set open(value: boolean) {
open = value;
},
get revision() {
return revision;
},
get target() {
return target;
},
get title() {
return title;
},
touch() {
revision += 1;
},
};
}
export const policyUi = createPolicyUi();
+3 -1
View File
@@ -14,7 +14,9 @@ export type RightPanel =
| "watches"
| "alerts"
| "shares"
| "bots";
| "bots"
| "storage"
| "storage-trash";
export type LeftView = "main" | "settings";
+12
View File
@@ -1,4 +1,16 @@
html.theme-dark {
--color-storage-video: #8774e1;
--color-storage-file: #d95926;
--color-storage-voice: #199e70;
--color-storage-audio: #c98500;
--color-storage-other: #3987e5;
--color-storage-warning: #fb8c00;
--color-storage-trash: #6c6c6c;
--storage-tile-mix: 90%;
--color-policy-plus: #7fd6a4;
--color-policy-plus-fill: #199e7029;
--color-policy-minus: #ff8a80;
--color-policy-minus-fill: #e5393524;
--color-primary: #8774e1;
--color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40;
+14
View File
@@ -133,6 +133,20 @@
--color-warning: #{$color-warning};
--color-storage-video: #6250d6;
--color-storage-file: #c4501c;
--color-storage-voice: #12805a;
--color-storage-audio: #a36a00;
--color-storage-other: #2a78c8;
--color-storage-warning: #a85a00;
--color-storage-trash: #939799;
--storage-tile-mix: 100%;
--color-policy-plus: #0d6b49;
--color-policy-plus-fill: #12805a24;
--color-policy-minus: #b3261e;
--color-policy-minus-fill: #e5393524;
--color-yellow: #{$color-yellow};
--color-orange: #{$color-orange};
+3
View File
@@ -3,6 +3,7 @@
import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte";
import SearchResults from "#lib/components/search/SearchResults.svelte";
@@ -101,6 +102,8 @@
{/if}
</div>
<PolicyDialog />
<ShareDialog
label={shareUi.label}
onchange={() => shareUi.touch()}