Compare commits

...
18 Commits
Author SHA1 Message Date
renovate Bot e21e99afae chore(deps): update dependency alembic to v1.20.1 (dev) (#26)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:02:23 +00:00
renovate Bot 597c6e82dd chore(deps): update dependency pydantic-settings to v2.16.1 (dev) (#25)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-24 04:02:56 +00:00
renovate Bot 9191ef9ad4 chore(deps): update dependency fastapi to v0.146.0 (dev) (#24)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-22 04:02:55 +00:00
renovate Bot 7697ba3e3c chore(deps): update dependency fastmcp to v4.1.1 (dev) (#23)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-21 04:02:25 +00:00
renovate Bot e3559da2a7 chore(deps): update code dependencies (dev) (#21)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-18 04:02:20 +00:00
hh dcd6786ca5 fix(backend): avatar job ends without a crash when telegram does not know the owner 2026-10-14 05:25:48 +02:00
hh 32d58193f3 fix(backend): raw update reaches every handler that declares it, contact list follows user updates 2026-10-14 05:25:45 +02:00
hh 59d0e2c8ad fix(backend): shared contact card keeps its phone number in the stored message 2026-10-14 05:25:42 +02:00
hh 850d39ad6f fix(frontend): cleanup block shows its failure in the error color 2026-10-14 05:17:58 +02:00
hh b8a38092eb fix(frontend): emptied chat keeps its storage head, shared-only chat explains why nothing can be bulk moved, back from a chat returns to the same place in the list, cleanup wording agrees in number 2026-10-14 05:15:27 +02:00
hh 2836dcb3a9 fix(backend): stale avatar is refetched again under kurigram 2.2.26, profile photos come as chat photos 2026-10-14 05:09:38 +02:00
hh 01cf01b629 fix(backend): rich messages of the new telegram layer read as plain text instead of no text 2026-10-14 05:06:35 +02:00
hh 7f124059be feat(frontend): storage shows the heaviest chats, a chat opens its own files with selective cleanup and a whole-chat move to trash 2026-10-14 05:02:06 +02:00
hh 5eacabd101 feat(backend): storage lists the heaviest chats, summary and file list take a chat filter 2026-10-14 05:02:03 +02:00
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
36 changed files with 2844 additions and 462 deletions

No files matched your search

+29 -1
View File
@@ -5,13 +5,15 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from utils.policy import repository from utils.policy import overview, repository
from utils.policy.models import ( from utils.policy.models import (
CaptureToggles, CaptureToggles,
ChatKind, ChatKind,
ChatMeta, ChatMeta,
PolicyCreate, PolicyCreate,
PolicyRecord, PolicyRecord,
PolicyRuleView,
PolicyScopeView,
) )
from utils.policy.resolver import resolve from utils.policy.resolver import resolve
@@ -50,6 +52,32 @@ async def effective_policy(
return resolve(chat, folders, policies) 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) @router.post("", status_code=201)
async def create_policy( async def create_policy(
pool: FromDishka[asyncpg.Pool], body: PolicyCreate pool: FromDishka[asyncpg.Pool], body: PolicyCreate
+65 -6
View File
@@ -13,7 +13,9 @@ from utils.read import storage as storage_read
from utils.read.models import ( from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
Page, Page,
StorageChatView,
StorageFileView, StorageFileView,
StorageScopeView,
StorageSummaryView, StorageSummaryView,
TrashItemView, TrashItemView,
TrashOutcome, TrashOutcome,
@@ -30,7 +32,13 @@ class TrashRequest(BaseModel):
class RestoreRequest(BaseModel): class RestoreRequest(BaseModel):
account_id: int account_id: int
keys: list[str] keys: list[str] | None = None
class ScopeRequest(BaseModel):
account_id: int
chat_id: int | None = None
folder_id: int | None = None
class PurgeRequest(BaseModel): class PurgeRequest(BaseModel):
@@ -40,9 +48,11 @@ class PurgeRequest(BaseModel):
@router.get("/summary") @router.get("/summary")
async def summary( async def summary(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
chat_id: Annotated[int | None, Query()] = None,
) -> StorageSummaryView: ) -> StorageSummaryView:
return await storage_read.get_summary(pool, account_id) return await storage_read.get_summary(pool, account_id, chat_id)
@router.get("/files") @router.get("/files")
@@ -50,18 +60,67 @@ async def files(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()], account_id: Annotated[int, Query()],
group: Annotated[str | None, Query()] = None, group: Annotated[str | None, Query()] = None,
chat_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query()] = DEFAULT_LIMIT, limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0, offset: Annotated[int, Query()] = 0,
) -> list[StorageFileView]: ) -> list[StorageFileView]:
page = Page(limit=limit, offset=offset) page = Page(limit=limit, offset=offset)
return await storage_read.list_files(pool, account_id, group, page) return await storage_read.list_files(pool, account_id, group, page, chat_id)
@router.get("/chats")
async def chats(
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[StorageChatView]:
page = Page(limit=limit, offset=offset)
return await storage_read.list_chats(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") @router.get("/trash")
async def trash_items( async def trash_items(
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()] pool: FromDishka[asyncpg.Pool],
account_id: Annotated[int, Query()],
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
offset: Annotated[int, Query()] = 0,
) -> list[TrashItemView]: ) -> list[TrashItemView]:
return await storage_read.list_trash(pool, account_id) 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") @router.post("/trash")
@@ -24,8 +24,9 @@ _MODULES = (
read_receipts, read_receipts,
transcribed, transcribed,
) )
_REGISTRY: dict[type, RawHandler] = { _REGISTRY: dict[type, list[RawHandler]] = {
update_type: module.handle for module in _MODULES for update_type in module.HANDLES update_type: [module.handle for module in _MODULES if update_type in module.HANDLES]
for update_type in {kind for module in _MODULES for kind in module.HANDLES}
} }
@@ -33,8 +34,7 @@ _REGISTRY: dict[type, RawHandler] = {
async def dispatch( async def dispatch(
client: PyroClient, update: raw.base.Update, users: dict, chats: dict client: PyroClient, update: raw.base.Update, users: dict, chats: dict
) -> None: ) -> None:
handler = _REGISTRY.get(type(update)) for handler in _REGISTRY.get(type(update), ()):
if handler is not None:
await handler(client, update, users, chats) await handler(client, update, users, chats)
@@ -1,6 +1,13 @@
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid from pyrogram.errors import (
from pyrogram.types import Photo ChannelInvalid,
ChannelPrivate,
FileIdInvalid,
FileReferenceExpired,
FileReferenceInvalid,
PeerIdInvalid,
)
from pyrogram.types import ChatPhoto
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.download import download_bytes from userbot.modules.download import download_bytes
@@ -8,19 +15,20 @@ from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid) STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
UNKNOWN_OWNER = (PeerIdInvalid, ChannelInvalid, ChannelPrivate)
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None: async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
try:
chat = await client.get_chat(owner_id) chat = await client.get_chat(owner_id)
except UNKNOWN_OWNER:
return None
photo = chat.photo if chat else None photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id: if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id return photo.big_file_id
photos = client.get_chat_photos(owner_id) async for item in client.get_chat_photos(owner_id):
if photos is None: if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
return None return item.big_file_id
async for item in photos:
if isinstance(item, Photo) and item.file_unique_id == unique_id:
return item.file_id
return None return None
+17 -2
View File
@@ -1,8 +1,23 @@
import json
from typing import Any
from pyrogram.raw.core import TLObject from pyrogram.raw.core import TLObject
from pyrogram.types import Object from pyrogram.types import Contact, Object
EMPTY_JSON = "{}" EMPTY_JSON = "{}"
PHONE_KEY = "phone_number"
def _default(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, TLObject):
return TLObject.default(obj)
data = Object.default(obj)
if isinstance(obj, Contact) and isinstance(data, dict) and PHONE_KEY in data:
data[PHONE_KEY] = obj.phone_number
return data
def raw_json(obj: Object | TLObject | None) -> str: def raw_json(obj: Object | TLObject | None) -> str:
return EMPTY_JSON if obj is None else str(obj) if obj is None:
return EMPTY_JSON
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
+18
View File
@@ -79,6 +79,24 @@ class FolderSpec:
is_chatlist: bool = False 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) @dataclass(slots=True)
class PolicySet: class PolicySet:
chat: dict[int, CaptureToggles] = field(default_factory=dict) 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 collections.abc import Iterable
from utils.policy.models import ( from utils.policy.models import (
DEFAULT_SCOPE_BY_KIND,
CaptureToggles, CaptureToggles,
ChatKind, ChatKind,
ChatMeta, ChatMeta,
FolderSpec, FolderSpec,
PolicyParent,
PolicySet, PolicySet,
ScopeType,
) )
@@ -33,14 +36,29 @@ def folder_contains(folder: FolderSpec, chat: ChatMeta) -> bool:
return _category_match(folder, chat) 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( def resolve(
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
) -> CaptureToggles: ) -> CaptureToggles:
chat_override = policies.chat.get(chat.chat_id) chat_override = policies.chat.get(chat.chat_id)
if chat_override is not None: if chat_override is not None:
return chat_override return chat_override
for folder in sorted(folders, key=lambda f: f.order_index): return resolve_parent(chat, folders, policies).toggles
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())
+12
View File
@@ -176,6 +176,18 @@ async def list_chats(
return [_chat_item(row) for row in rows] 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( async def get_chat(
pool: asyncpg.Pool, account_id: int, chat_id: int pool: asyncpg.Pool, account_id: int, chat_id: int
) -> ChatListItem | None: ) -> ChatListItem | None:
+3
View File
@@ -22,6 +22,7 @@ from utils.read.models import (
StickerView, StickerView,
WebPageView, WebPageView,
) )
from utils.read.rich_text import rich_text
_MEDIA_KEYS = ( _MEDIA_KEYS = (
"photo", "photo",
@@ -382,6 +383,8 @@ def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef] row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView: ) -> MessageView:
base = _base_fields(row) base = _base_fields(row)
if base["text"] is None:
base["text"] = rich_text(raw.get("rich_message"))
via_bot = raw.get("via_bot") via_bot = raw.get("via_bot")
sticker = _sticker(raw) sticker = _sticker(raw)
try: try:
+15
View File
@@ -253,6 +253,13 @@ class StorageSummaryView(BaseModel):
groups: list[StorageGroupView] groups: list[StorageGroupView]
class StorageChatView(BaseModel):
chat_id: int
files: int
bytes: int
groups: list[StorageGroupView]
class StorageFileView(BaseModel): class StorageFileView(BaseModel):
media_id: int media_id: int
chat_id: int chat_id: int
@@ -287,6 +294,14 @@ class TrashOutcome(BaseModel):
blocked: list[int] = [] blocked: list[int] = []
class StorageScopeView(BaseModel):
chats: int
files: int
bytes: int
kept: int
gone: int
class AvatarRef(BaseModel): class AvatarRef(BaseModel):
unique_id: str unique_id: str
storage_key: str | None storage_key: str | None
+61
View File
@@ -0,0 +1,61 @@
from typing import Any
CELL_GAP = " | "
_TEXT_KEYS = ("summary", "text", "expression", "credit")
_INLINE_KEYS = ("text", "alternative_text", "expression")
def _button(button: dict[str, Any]) -> str:
label = _inline(button.get("text"))
url = button.get("url")
return f"{label} ({url})" if isinstance(url, str) else label
def _inline(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(_inline(item) for item in value)
if not isinstance(value, dict):
return ""
button = value.get("button")
if isinstance(button, dict):
return _button(button)
return next((_inline(value[key]) for key in _INLINE_KEYS if key in value), "")
def _children(block: dict[str, Any], key: str) -> list[Any]:
value = block.get(key)
return value if isinstance(value, list) else []
def _row(row: object) -> str:
cells = row if isinstance(row, list) else []
return CELL_GAP.join(
_inline(cell.get("text")) for cell in cells if isinstance(cell, dict)
)
def _block(block: object) -> list[str]:
if not isinstance(block, dict):
return []
lines = [_inline(block[key]) for key in _TEXT_KEYS if key in block]
lines += [_row(row) for row in _children(block, "cells")]
lines += [
_button(button)
for button in _children(block, "buttons")
if isinstance(button, dict)
]
for key in ("items", "blocks"):
for child in _children(block, key):
lines += _block(child)
lines += _block(block.get("caption"))
lines = [line for line in lines if line]
label = block.get("label")
if isinstance(label, str) and lines:
lines[0] = f"{label} {lines[0]}"
return lines
def rich_text(rich: object) -> str | None:
return "\n".join(_block(rich)) or None
+168 -16
View File
@@ -1,13 +1,19 @@
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from itertools import groupby
from operator import itemgetter
import asyncpg import asyncpg
from utils.files import media_file_name 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 ( from utils.read.models import (
Page, Page,
StorageChatView,
StorageFileView, StorageFileView,
StorageGroupView, StorageGroupView,
StorageScopeView,
StorageSummaryView, StorageSummaryView,
TrashItemView, TrashItemView,
) )
@@ -31,6 +37,7 @@ WITH blobs AS (
SELECT storage_key, max(file_size) AS size, min(kind) AS kind SELECT storage_key, max(file_size) AS size, min(kind) AS kind
FROM media FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($3::bigint IS NULL OR chat_id = $3)
GROUP BY storage_key GROUP BY storage_key
) )
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes, SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
@@ -46,24 +53,49 @@ FROM media_trash WHERE account_id = $1
_FILES = """ _FILES = """
WITH top AS MATERIALIZED ( WITH top AS MATERIALIZED (
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs SELECT storage_key, max(file_size) AS file_size
FROM media FROM media
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3) AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
AND ($7::bigint IS NULL OR chat_id = $7)
GROUP BY storage_key GROUP BY storage_key
ORDER BY 2 DESC NULLS LAST, 1 ORDER BY 2 DESC NULLS LAST, 1
LIMIT $4 OFFSET $5 LIMIT $4 OFFSET $5
) )
SELECT * FROM ( SELECT * FROM (
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id, SELECT t.storage_key, t.file_size, m.id, m.chat_id, m.message_id,
m.kind, m.mime, m.ttl_seconds, m.kind, m.mime, m.ttl_seconds,
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n count(*) OVER (PARTITION BY t.storage_key) AS refs,
row_number() OVER (
PARTITION BY t.storage_key
ORDER BY (m.chat_id = $7) DESC NULLS LAST, m.id
) AS n
FROM top t FROM top t
JOIN media m ON m.storage_key = t.storage_key JOIN media m ON m.storage_key = t.storage_key
AND m.account_id = $1 AND m.downloaded AND m.account_id = $1 AND m.downloaded
) ranked ) ranked
WHERE n <= $6 WHERE n <= $6
ORDER BY file_size DESC NULLS LAST, storage_key, id ORDER BY file_size DESC NULLS LAST, storage_key, n
"""
_CHATS = """
WITH blobs AS (
SELECT chat_id, 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
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
GROUP BY chat_id, storage_key
), top AS (
SELECT chat_id, coalesce(sum(size), 0)::bigint AS total
FROM blobs GROUP BY chat_id
ORDER BY 2 DESC, 1
LIMIT $4 OFFSET $5
)
SELECT t.chat_id, t.total, b.kind, count(*) AS files,
coalesce(sum(b.size), 0)::bigint AS bytes
FROM top t JOIN blobs b USING (chat_id)
GROUP BY t.chat_id, t.total, b.kind
ORDER BY t.total DESC, t.chat_id
""" """
_TRASH = """ _TRASH = """
@@ -71,6 +103,46 @@ SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
trashed_at trashed_at
FROM media_trash WHERE account_id = $1 FROM media_trash WHERE account_id = $1
ORDER BY file_size DESC NULLS LAST, storage_key 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 = """ _TRASH_REFS = """
@@ -100,9 +172,7 @@ def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
return list(GROUP_KINDS.get(group, ())), True return list(GROUP_KINDS.get(group, ())), True
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView: def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
groups = { groups = {
name: StorageGroupView(group=name, files=0, bytes=0) name: StorageGroupView(group=name, files=0, bytes=0)
for name in (*GROUP_KINDS, OTHER_GROUP) for name in (*GROUP_KINDS, OTHER_GROUP)
@@ -111,6 +181,14 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)] group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
group.files += row["files"] group.files += row["files"]
group.bytes += row["bytes"] group.bytes += row["bytes"]
return list(groups.values())
async def get_summary(
pool: asyncpg.Pool, account_id: int, chat_id: int | None = None
) -> StorageSummaryView:
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES, chat_id)
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
return StorageSummaryView( return StorageSummaryView(
files=sum(row["files"] for row in rows), files=sum(row["files"] for row in rows),
bytes=sum(row["bytes"] for row in rows), bytes=sum(row["bytes"] for row in rows),
@@ -119,10 +197,31 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
heavy_threshold=HEAVY_BYTES, heavy_threshold=HEAVY_BYTES,
trash_files=trash["files"], trash_files=trash["files"],
trash_bytes=trash["bytes"], trash_bytes=trash["bytes"],
groups=list(groups.values()), groups=_groups(rows),
) )
async def list_chats(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
) -> list[StorageChatView]:
kinds, include = _group_filter(group)
rows = await pool.fetch(
_CHATS, account_id, kinds, include, page.capped_limit, page.offset
)
return [
StorageChatView(
chat_id=chat_id,
files=sum(row["files"] for row in items),
bytes=items[0]["total"],
groups=_groups(items),
)
for chat_id, items in (
(chat_id, list(chunk))
for chat_id, chunk in groupby(rows, key=itemgetter("chat_id"))
)
]
async def _messages( async def _messages(
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record] pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
) -> list[asyncpg.Record | None]: ) -> list[asyncpg.Record | None]:
@@ -146,18 +245,31 @@ def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
async def list_files( async def list_files(
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page pool: asyncpg.Pool,
account_id: int,
group: str | None,
page: Page,
chat_id: int | None = None,
) -> list[StorageFileView]: ) -> list[StorageFileView]:
kinds, include = _group_filter(group) kinds, include = _group_filter(group)
refs = await pool.fetch( refs = await pool.fetch(
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED _FILES,
account_id,
kinds,
include,
page.capped_limit,
page.offset,
REFS_CHECKED,
chat_id,
) )
messages = await _messages(pool, account_id, refs) messages = await _messages(pool, account_id, refs)
files: dict[str, StorageFileView] = {} files: dict[str, StorageFileView] = {}
for ref, message in zip(refs, messages, strict=True): for ref, message in zip(refs, messages, strict=True):
alive = _alive(ref, message) alive = _alive(ref, message)
known = files.get(ref["storage_key"]) known = files.get(ref["storage_key"])
if known is not None and (known.refetchable or not alive): inside = chat_id is None or ref["chat_id"] == chat_id
if known is not None and not (alive and inside and not known.refetchable):
known.refetchable = known.refetchable or alive
continue continue
files[ref["storage_key"]] = StorageFileView( files[ref["storage_key"]] = StorageFileView(
media_id=ref["id"], media_id=ref["id"],
@@ -179,20 +291,26 @@ async def list_files(
return list(files.values()) return list(files.values())
async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]: async def list_trash(
rows = await pool.fetch(_TRASH, account_id) 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( refs = await pool.fetch(
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED _TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
) )
messages = await _messages(pool, account_id, refs) messages = await _messages(pool, account_id, refs)
alive: dict[str, bool] = defaultdict(bool) alive: dict[str, bool] = defaultdict(bool)
counts: dict[str, int] = {} counts: dict[str, int] = {}
names: dict[str, str] = {}
for ref, message in zip(refs, messages, strict=True): for ref, message in zip(refs, messages, strict=True):
alive[ref["storage_key"]] |= _alive(ref, message) key = ref["storage_key"]
counts[ref["storage_key"]] = ref["refs"] 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 [ return [
TrashItemView( TrashItemView(
**dict(row), **{**dict(row), "file_name": _trash_name(row, names)},
purge_at=row["trashed_at"] + RETENTION, purge_at=row["trashed_at"] + RETENTION,
refs=counts.get(row["storage_key"], 0), refs=counts.get(row["storage_key"], 0),
refetchable=alive.get(row["storage_key"], True), refetchable=alive.get(row["storage_key"], True),
@@ -201,6 +319,40 @@ async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]
] ]
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: async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
return await pool.fetchrow( return await pool.fetchrow(
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1", "SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
+62 -1
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
import asyncpg import asyncpg
from utils.files import media_file_name
from utils.read.media import get_media from utils.read.media import get_media
from utils.read.models import TrashOutcome from utils.read.models import TrashOutcome
from utils.storage import ContentAddressedStorage from utils.storage import ContentAddressedStorage
@@ -12,6 +13,7 @@ logger = logging.getLogger(__name__)
RETENTION = timedelta(days=7) RETENTION = timedelta(days=7)
PURGE_INTERVAL_SECONDS = 3600 PURGE_INTERVAL_SECONDS = 3600
BATCH = 200
_SHARED_ELSEWHERE = """ _SHARED_ELSEWHERE = """
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1) SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
@@ -47,6 +49,12 @@ _FORGET_KEYS = """
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[]) 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( async def move_to_trash(
pool: asyncpg.Pool, pool: asyncpg.Pool,
@@ -86,12 +94,65 @@ async def move_to_trash(
return outcome 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( async def restore(
pool: asyncpg.Pool, pool: asyncpg.Pool,
storage: ContentAddressedStorage, storage: ContentAddressedStorage,
account_id: int, account_id: int,
keys: list[str], keys: list[str] | None,
) -> TrashOutcome: ) -> 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() outcome = TrashOutcome()
for key in keys: for key in keys:
async with pool.acquire() as conn, conn.transaction(): async with pool.acquire() as conn, conn.transaction():
+29 -18
View File
@@ -21,16 +21,16 @@ wheels = [
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.20.0" version = "1.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mako" }, { name = "mako" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, { url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
] ]
[[package]] [[package]]
@@ -226,6 +226,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
] ]
[[package]]
name = "certifi"
version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -411,7 +420,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.143.0" version = "0.159.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -421,26 +430,26 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, { url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" },
] ]
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "4.1.0" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] }, { name = "fastmcp-slim", extra = ["client", "server"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" } sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" }, { url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
] ]
[[package]] [[package]]
name = "fastmcp-slim" name = "fastmcp-slim"
version = "4.1.0" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mcp-types" }, { name = "mcp-types" },
@@ -451,9 +460,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" }, { url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -470,6 +479,7 @@ client = [
server = [ server = [
{ name = "authlib" }, { name = "authlib" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "certifi" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "griffelib" }, { name = "griffelib" },
@@ -912,14 +922,14 @@ wheels = [
[[package]] [[package]]
name = "openapi-pydantic" name = "openapi-pydantic"
version = "0.5.1" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, { url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
] ]
[[package]] [[package]]
@@ -1080,16 +1090,17 @@ wheels = [
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.15.0" version = "2.16.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, { url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
] ]
[[package]] [[package]]
+61 -6
View File
@@ -26,13 +26,18 @@ import type {
PolicyChatKind, PolicyChatKind,
PolicyCreate, PolicyCreate,
PolicyRecord, PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
ReactionView, ReactionView,
ResponseStats, ResponseStats,
ScopeTarget,
SearchHit, SearchHit,
StorageChat,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
StorageScope,
StorageSummary, StorageSummary,
StoryView, StoryView,
TrashItem, TrashItem,
@@ -148,6 +153,23 @@ export function deletePolicy(id: number): Promise<void> {
return request<void>(`/policy/${id}`, { method: "DELETE" }); 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: { export function effectivePolicy(query: {
chat_id: number; chat_id: number;
is_bot?: boolean; is_bot?: boolean;
@@ -587,22 +609,55 @@ export function removeWatchedBot(botId: number): Promise<void> {
return request<void>(`/bots/${botId}`, { method: "DELETE" }); return request<void>(`/bots/${botId}`, { method: "DELETE" });
} }
export function getStorageSummary(): Promise<StorageSummary> { export function getStorageSummary(
return request<StorageSummary>("/storage/summary", { account: true }); chatId: number | null = null
): Promise<StorageSummary> {
return request<StorageSummary>("/storage/summary", {
account: true,
query: { chat_id: chatId },
});
} }
export function getStorageFiles( export function getStorageFiles(
group: StorageGroupName | null, group: StorageGroupName | null,
page: Page = {} page: Page = {},
chatId: number | null = null
): Promise<StorageFile[]> { ): Promise<StorageFile[]> {
return request<StorageFile[]>("/storage/files", { return request<StorageFile[]>("/storage/files", {
account: true,
query: { chat_id: chatId, group, ...page },
});
}
export function getStorageChats(
group: StorageGroupName | null,
page: Page = {}
): Promise<StorageChat[]> {
return request<StorageChat[]>("/storage/chats", {
account: true, account: true,
query: { group, ...page }, query: { group, ...page },
}); });
} }
export function getTrash(): Promise<TrashItem[]> { export function getTrash(page: Page = {}): Promise<TrashItem[]> {
return request<TrashItem[]>("/storage/trash", { account: true }); 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> { export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
@@ -612,7 +667,7 @@ export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
}); });
} }
export function restoreFromTrash(keys: string[]): Promise<TrashOutcome> { export function restoreFromTrash(keys: string[] | null): Promise<TrashOutcome> {
return request<TrashOutcome>("/storage/trash/restore", { return request<TrashOutcome>("/storage/trash/restore", {
body: { account_id: accounts.selectedId, keys }, body: { account_id: accounts.selectedId, keys },
method: "POST", method: "POST",
+35
View File
@@ -423,6 +423,24 @@ export interface PolicyRecord extends CaptureToggles {
scope_type: PolicyScopeType; 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 type PolicyChatKind = "dm" | "group" | "channel";
export interface PolicyCreate extends CaptureToggles { export interface PolicyCreate extends CaptureToggles {
@@ -580,6 +598,13 @@ export interface StorageSummary {
trash_files: number; trash_files: number;
} }
export interface StorageChat {
bytes: number;
chat_id: number;
files: number;
groups: StorageGroup[];
}
export interface StorageFile { export interface StorageFile {
chat_id: number; chat_id: number;
date: string | null; date: string | null;
@@ -613,3 +638,13 @@ export interface TrashOutcome {
files: number; files: number;
keys: string[]; 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 { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -186,6 +187,11 @@
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem >Сторис</ContextMenuItem
> >
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
@@ -7,6 +7,7 @@
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -108,6 +109,11 @@
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")} <ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem >Сторис</ContextMenuItem
> >
<ContextMenuItem
icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem
>
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
@@ -1,5 +1,8 @@
<script lang="ts"> <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 { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
interface Tab { interface Tab {
id: number | null; 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 containerEl = $state<HTMLDivElement>();
let indicatorEl = $state<HTMLDivElement>(); let indicatorEl = $state<HTMLDivElement>();
let clipPath = $state(""); let clipPath = $state("");
@@ -64,9 +73,31 @@
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}> <div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
{#if tab.id === null}
<button class="tab" onclick={() => folders.select(tab.id)} type="button"> <button class="tab" onclick={() => folders.select(tab.id)} type="button">
{tab.title} {tab.title}
</button> </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} {/each}
<div <div
+28 -1
View File
@@ -19,6 +19,9 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = {
@@ -35,6 +38,7 @@
search: "Поиск", search: "Поиск",
shares: "Файлы по ссылке", shares: "Файлы по ссылке",
storage: "Хранилище", storage: "Хранилище",
"storage-chat": "Файлы чата",
"storage-trash": "Корзина", "storage-trash": "Корзина",
stories: "Сторис", stories: "Сторис",
"stories-all": "Все сторис", "stories-all": "Все сторис",
@@ -43,10 +47,21 @@
}; };
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage",
"storage-trash": "storage", "storage-trash": "storage",
}; };
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined); const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
const title = $derived.by(() => {
if (ui.rightPanel === null) {
return "";
}
const chat =
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId)
: undefined;
return chat?.title ?? titles[ui.rightPanel];
});
</script> </script>
<header class="right-header"> <header class="right-header">
@@ -71,14 +86,16 @@
<Icon name="close" /> <Icon name="close" />
</Button> </Button>
{/if} {/if}
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2> <h2>{title}</h2>
</header> </header>
<div class="right-body custom-scroll"> <div class="right-body custom-scroll">
{#if ui.rightPanel === "profile"} {#if ui.rightPanel === "profile"}
<ProfilePanel /> <ProfilePanel />
{:else if ui.rightPanel === "policy"} {:else if ui.rightPanel === "policy"}
{#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor /> <PolicyEditor />
{/key}
{:else if ui.rightPanel === "jobs"} {:else if ui.rightPanel === "jobs"}
<JobsPanel /> <JobsPanel />
{:else if ui.rightPanel === "presence"} {:else if ui.rightPanel === "presence"}
@@ -109,6 +126,10 @@
{#key accounts.selectedId} {#key accounts.selectedId}
<StoragePanel /> <StoragePanel />
{/key} {/key}
{:else if ui.rightPanel === "storage-chat"}
{#key `${accounts.selectedId}:${storageUi.chatId}`}
<StoragePanel chatId={storageUi.chatId} />
{/key}
{:else if ui.rightPanel === "storage-trash"} {:else if ui.rightPanel === "storage-trash"}
{#key accounts.selectedId} {#key accounts.selectedId}
<TrashPanel /> <TrashPanel />
@@ -128,9 +149,15 @@
border-bottom: 1px solid var(--color-borders); border-bottom: 1px solid var(--color-borders);
h2 { h2 {
overflow: hidden;
min-width: 0;
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
} }
} }
@@ -0,0 +1,251 @@
<script lang="ts">
import { onMount } from "svelte";
import {
getStorageScope,
restoreFromTrash,
trashScope,
} from "#lib/api/endpoints.js";
import type {
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 { formatBytes } from "#lib/format/media.js";
import { plural } from "#lib/format/shares.js";
import { filesLabel } from "#lib/format/storage.js";
interface Props {
label?: string;
onchange?: () => void;
target: ScopeTarget;
}
let { target, label, onchange }: Props = $props();
let files = $state<StorageScope | null>(null);
let confirming = $state(false);
let moving = $state(false);
let moved = $state<TrashOutcome | null>(null);
let problem = $state<string | null>(null);
function where(scope: StorageScope): string {
return "folderId" in target
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
: "в этом чате";
}
async function load() {
try {
files = await getStorageScope(target);
} catch {
files = null;
}
}
async function move() {
if (moving) {
return;
}
moving = true;
problem = null;
try {
moved = await trashScope(target);
confirming = false;
await load();
onchange?.();
} catch {
problem = "Не удалось переместить файлы в корзину";
} finally {
moving = false;
}
}
async function undo() {
if (moved === null || moving) {
return;
}
moving = true;
problem = null;
try {
await restoreFromTrash(moved.keys);
moved = null;
await load();
onchange?.();
} catch {
problem = "Не удалось вернуть файлы";
} finally {
moving = false;
}
}
export function refresh() {
return load();
}
onMount(load);
</script>
{#if moved && moved.files > 0}
<div class="files">
<div class="text">
<span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
{#if problem}
<small class="failed">{problem}</small>
{/if}
</div>
<Button disabled={moving} onclick={undo} 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="text">
<span>
{filesLabel(files.files)}, {formatBytes(files.bytes)},
{plural(
files.files,
"уйдёт в корзину и перестанет скачиваться сам",
"уйдут в корзину и перестанут скачиваться сами",
"уйдут в корзину и перестанут скачиваться сами"
)}.
</span>
{#if files.gone > 0}
<small class="warn">
{files.gone}
из них потом не скачать заново
</small>
{/if}
{#if problem}
<small class="failed">{problem}</small>
{/if}
<div class="actions">
<Button
onclick={() => {
confirming = false;
}}
smaller
variant="text"
>
Отмена
</Button>
<Button
disabled={moving}
loading={moving}
onclick={move}
pill
smaller
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
</div>
</div>
{:else}
<div class="text">
{#if label}
<span>{label}</span>
{#if files.kept > 0}
<small>
{filesLabel(files.files)}
· {formatBytes(files.bytes)}
</small>
{/if}
{:else}
<span>{formatBytes(files.bytes)} уже скачано</span>
<small>{filesLabel(files.files)} {where(files)}</small>
{/if}
{#if files.kept > 0}
<small>
Ещё {filesLabel(files.kept)} есть и в других чатах,
{plural(
files.kept,
"он останется",
"они останутся",
"они останутся"
)}
</small>
{/if}
</div>
<Button
onclick={() => {
confirming = true;
}}
pill
smaller
variant="gray"
>
<Icon name="delete" size="1.25rem" />
<span>В корзину</span>
</Button>
{/if}
</div>
{:else if label && files && files.kept > 0}
<div class="files">
<div class="text">
<span>{label}</span>
<small>
{filesLabel(files.kept)}
есть и в других чатах, убрать
{plural(files.kept, "его", "их", "их")}
можно из списка ниже
</small>
</div>
</div>
{/if}
<style lang="scss">
.files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
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;
}
}
.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);
}
&.failed {
color: var(--color-error);
}
}
}
.actions {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
margin-top: 0.375rem;
}
</style>
@@ -0,0 +1,143 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { StorageChat } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import { formatBytes } from "#lib/format/media.js";
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js";
interface Props {
chat: StorageChat;
hasAvatar: boolean;
menu: Snippet;
onopen: () => void;
title: string;
weight: number;
}
let { chat, title, hasAvatar, weight, onopen, menu }: Props = $props();
const MIN_WEIGHT = 0.02;
const AVATAR_REM = 3;
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
const makeup = $derived(
parts
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
.join(", ")
);
</script>
<ContextMenu {menu}>
{#snippet children({
props,
})}
<button {...props} class="row" onclick={onopen} type="button">
<Avatar
avatar={{ id: chat.chat_id, kind: chat.chat_id > 0 ? "peer" : "chat" }}
colorKey={chat.chat_id}
{hasAvatar}
name={title}
size={AVATAR_REM}
/>
<span class="text">
<span class="name">{title}</span>
<span class="sub">{filesLabel(chat.files)}</span>
</span>
<span class="size">{formatBytes(chat.bytes)}</span>
<span class="weight" title={makeup}>
<span class="fill" style:width="{scale * 100}%">
{#each parts as part (part.group)}
<span
style:background-color="var(--color-storage-{part.group})"
style:flex-grow={part.bytes}
></span>
{/each}
</span>
</span>
</button>
{/snippet}
</ContextMenu>
<style lang="scss">
.row {
cursor: pointer;
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.5rem 1rem 0.625rem;
border: 0;
font: inherit;
color: var(--color-text);
text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
margin-top: 0.125rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.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);
}
.fill {
overflow: hidden;
display: flex;
gap: 2px;
height: 100%;
border-radius: inherit;
span {
min-width: 2px;
}
}
</style>
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js"; import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
import { import {
getStorageChats,
getStorageFiles, getStorageFiles,
getStorageSummary, getStorageSummary,
moveToTrash, moveToTrash,
@@ -11,12 +12,15 @@
} from "#lib/api/endpoints.js"; } from "#lib/api/endpoints.js";
import { forgetMedia, type ViewerItem } from "#lib/api/media.js"; import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
import type { import type {
StorageChat,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
StorageSummary, StorageSummary,
TrashOutcome, TrashOutcome,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
import SelectionBar from "#lib/components/disk/SelectionBar.svelte"; import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
import StorageChatRow from "#lib/components/disk/StorageChatRow.svelte";
import StorageRow from "#lib/components/disk/StorageRow.svelte"; import StorageRow from "#lib/components/disk/StorageRow.svelte";
import MediaViewer from "#lib/components/MediaViewer.svelte"; import MediaViewer from "#lib/components/MediaViewer.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
@@ -31,16 +35,33 @@
messagesLabel, messagesLabel,
} from "#lib/format/storage.js"; } from "#lib/format/storage.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
import {
type StorageListing,
storageUi,
} from "#lib/stores/storage.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
interface Props {
chatId?: number | null;
}
let { chatId = null }: Props = $props();
const PAGE = 30; const PAGE = 30;
const UNDO_MS = 12_000; const UNDO_MS = 12_000;
const UNDO_GUARD_MS = 600; const UNDO_GUARD_MS = 600;
const LISTINGS: { label: string; value: StorageListing }[] = [
{ label: "Файлы", value: "files" },
{ label: "Чаты", value: "chats" },
];
let summary = $state<StorageSummary | null>(null); let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]); let files = $state<StorageFile[]>([]);
let heavyChats = $state<StorageChat[]>([]);
let scope = $state<ReturnType<typeof ScopeFiles>>();
let group = $state<StorageGroupName | null>(null); let group = $state<StorageGroupName | null>(null);
let loading = $state(false); let loading = $state(false);
let failed = $state(false); let failed = $state(false);
@@ -54,10 +75,26 @@
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
let token = 0; let token = 0;
let scroller: HTMLElement | null = null;
let undoTimer: ReturnType<typeof setTimeout> | undefined; let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0; let undoShownAt = 0;
const listing = $derived<StorageListing>(
chatId === null ? storageUi.listing : "files"
);
const shown = $derived(
listing === "chats" ? heavyChats.length : files.length
);
const heaviest = $derived(files[0]?.file_size ?? 0); const heaviest = $derived(files[0]?.file_size ?? 0);
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
const emptiness = $derived.by(() => {
if (group !== null) {
return "В этой группе нет файлов.";
}
return chatId === null
? "На диске нет скачанных файлов."
: "В этом чате нет скачанных файлов.";
});
const chosen = $derived(files.filter((file) => selected.has(file.media_id))); const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
const chosenBytes = $derived( const chosenBytes = $derived(
chosen.reduce((sum, file) => sum + file.file_size, 0) chosen.reduce((sum, file) => sum + file.file_size, 0)
@@ -82,19 +119,43 @@
})) }))
); );
function chatTitle(chatId: number): string { function chatTitle(id: number): string {
return chats.byId(chatId)?.title ?? `Чат ${chatId}`; return chats.byId(id)?.title ?? `Чат ${id}`;
} }
async function loadSummary() { async function loadSummary() {
try { try {
summary = await getStorageSummary(); summary = await getStorageSummary(chatId);
} catch { } catch {
failed = true; failed = true;
} }
} }
async function loadMore() { async function loadPage(current: number, limit: number): Promise<number> {
if (listing === "chats") {
const batch = await getStorageChats(group, {
limit,
offset: heavyChats.length,
});
if (current === token) {
chats.ensureMany(batch.map((chat) => chat.chat_id));
heavyChats = [...heavyChats, ...batch];
}
return batch.length;
}
const batch = await getStorageFiles(
group,
{ limit, offset: files.length },
chatId
);
if (current === token) {
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
}
return batch.length;
}
async function loadMore(limit = PAGE) {
if (loading || done) { if (loading || done) {
return; return;
} }
@@ -102,16 +163,10 @@
loading = true; loading = true;
failed = false; failed = false;
try { try {
const batch = await getStorageFiles(group, { const loaded = await loadPage(current, limit);
limit: PAGE, if (current === token) {
offset: files.length, done = loaded < limit;
});
if (current !== token) {
return;
} }
chats.ensureMany(batch.map((file) => file.chat_id));
files = [...files, ...batch];
done = batch.length < PAGE;
} catch { } catch {
if (current === token) { if (current === token) {
failed = true; failed = true;
@@ -123,14 +178,34 @@
} }
} }
function reload() { function reload(limit = PAGE) {
token += 1; token += 1;
files = []; files = [];
heavyChats = [];
done = false; done = false;
loading = false; loading = false;
selected.clear(); selected.clear();
loadSummary(); return Promise.all([loadSummary(), loadMore(limit)]);
loadMore(); }
async function resume() {
const from = chatId === null ? storageUi.takeResume() : null;
await reload(Math.ceil(Math.max(1, from?.count ?? 0) / PAGE) * PAGE);
if (from && scroller) {
await tick();
scroller.scrollTop = from.top;
}
}
function openChat(id: number) {
storageUi.openChat(id, {
count: heavyChats.length,
top: scroller?.scrollTop ?? 0,
});
}
function hold(node: HTMLElement) {
scroller = node.parentElement;
} }
function toggle(mediaId: number) { function toggle(mediaId: number) {
@@ -171,6 +246,7 @@
} }
files = files.filter((file) => !moved.has(file.media_id)); files = files.filter((file) => !moved.has(file.media_id));
selected.clear(); selected.clear();
scope?.refresh();
showUndo(outcome, trashed); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "Файл занят в сторис или аватаре и остался на диске";
@@ -194,6 +270,7 @@
files = [...files, ...restored.files].sort( files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size (a, b) => b.file_size - a.file_size
); );
scope?.refresh();
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "Не удалось вернуть файлы";
@@ -227,24 +304,45 @@
reload(); reload();
} }
onMount(reload); function show(next: StorageListing) {
if (next === storageUi.listing) {
return;
}
storageUi.listing = next;
problem = null;
hideUndo();
reload();
}
function visit(chat: StorageChat) {
goto(`/app/${chat.chat_id}`);
if (isMobile()) {
ui.closePanel();
}
}
onMount(resume);
onDestroy(() => clearTimeout(undoTimer)); onDestroy(() => clearTimeout(undoTimer));
</script> </script>
<div class="storage"> <div class="storage" {@attach hold}>
{#if summary} {#if summary}
{#if summary.files > 0} {#if summary.files > 0 || chatId !== null}
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span> <span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="count"> <span class="count">
{filesLabel(summary.files)} {filesLabel(summary.files)}
{summary.trash_bytes > 0 {#if chatId !== null}
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине` в этом чате
: "на диске"} {:else if summary.trash_bytes > 0}
· ещё {formatBytes(summary.trash_bytes)} в корзине
{:else}
на диске
{/if}
</span> </span>
</div> </div>
<div class="stack"> <div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
@@ -252,7 +350,7 @@
style:flex-grow={item.bytes} style:flex-grow={item.bytes}
></span> ></span>
{/each} {/each}
{#if summary.trash_bytes > 0} {#if chatId === null && summary.trash_bytes > 0}
<span <span
class="trashed" class="trashed"
title="Корзина: {formatBytes(summary.trash_bytes)}" title="Корзина: {formatBytes(summary.trash_bytes)}"
@@ -281,6 +379,7 @@
</section> </section>
{/if} {/if}
{#if chatId === null}
<button <button
class="trash-link" class="trash-link"
onclick={() => ui.openPanel("storage-trash")} onclick={() => ui.openPanel("storage-trash")}
@@ -299,9 +398,33 @@
</span> </span>
<Icon name="next" size="1.125rem" /> <Icon name="next" size="1.125rem" />
</button> </button>
<div class="tabs">
{#each LISTINGS as item (item.value)}
<button
aria-pressed={listing === item.value}
class="tab"
onclick={() => show(item.value)}
type="button"
class:on={listing === item.value}
>
{item.label}
</button>
{/each}
</div>
{:else}
<ScopeFiles
label="Все файлы чата"
onchange={() => {
hideUndo();
reload();
}}
target={{ chatId }}
bind:this={scope}
/>
{/if}
{/if} {/if}
{#if files.length > 0} {#if listing === "files" && files.length > 0}
<div class="section-title"> <div class="section-title">
<h3>Самые тяжёлые</h3> <h3>Самые тяжёлые</h3>
{#if summary && group === null && summary.heavy_files > 0} {#if summary && group === null && summary.heavy_files > 0}
@@ -314,7 +437,33 @@
</div> </div>
{/if} {/if}
<div class="list"> <div class="list" class:chats={listing === "chats"}>
{#each heavyChats as chat (chat.chat_id)}
<StorageChatRow
{chat}
hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false}
onopen={() => openChat(chat.chat_id)}
title={chatTitle(chat.chat_id)}
weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0}
>
{#snippet menu()}
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
Файлы чата
</ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
Перейти в чат
</ContextMenuItem>
<ContextMenuItem
icon="settings"
onselect={() =>
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
>
Политика захвата
</ContextMenuItem>
{/snippet}
</StorageChatRow>
{/each}
{#each files as file, index (file.media_id)} {#each files as file, index (file.media_id)}
<StorageRow <StorageRow
fileName={file.file_name} fileName={file.file_name}
@@ -327,8 +476,10 @@
selected={selected.has(file.media_id)} selected={selected.has(file.media_id)}
size={file.file_size} size={file.file_size}
src={mediaUrl(file.media_id)} src={mediaUrl(file.media_id)}
sub={chatTitle(file.chat_id)} sub={chatId === null
tail={formatListDate(file.date)} ? chatTitle(file.chat_id)
: formatListDate(file.date)}
tail={chatId === null ? formatListDate(file.date) : undefined}
weight={heaviest > 0 ? file.file_size / heaviest : 0} weight={heaviest > 0 ? file.file_size / heaviest : 0}
> >
{#snippet menu()} {#snippet menu()}
@@ -356,18 +507,14 @@
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить хранилище.</p> <p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button> <Button onclick={() => reload()} smaller variant="text">
</div> Повторить
{:else if files.length === 0} </Button>
<div class="state">
<p>
{group === null
? "На диске нет скачанных файлов."
: "В этой группе нет файлов."}
</p>
</div> </div>
{:else if shown === 0}
<div class="state"><p>{emptiness}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={loadMore} type="button"> <button class="more" onclick={() => loadMore()} type="button">
Показать ещё Показать ещё
</button> </button>
{/if} {/if}
@@ -470,6 +617,11 @@
height: 0.625rem; height: 0.625rem;
margin-top: 0.875rem; margin-top: 0.875rem;
&.bare {
border-radius: 0.3125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
}
span { span {
min-width: 0.1875rem; min-width: 0.1875rem;
border-radius: 2px; border-radius: 2px;
@@ -615,6 +767,47 @@
} }
} }
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
}
.tab {
cursor: pointer;
flex: 1;
height: 2.75rem;
border: 0;
border-bottom: 2px solid transparent;
font: inherit;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
transition: color 0.15s ease-out;
&.on {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:not(.on):hover {
color: var(--color-text);
}
}
}
.section-title { .section-title {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -639,6 +832,10 @@
.list { .list {
flex: 1; flex: 1;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
&.chats {
padding-top: 0.375rem;
}
} }
.state { .state {
@@ -4,6 +4,7 @@
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
import { trashedUrl } from "#lib/api/client.js"; import { trashedUrl } from "#lib/api/client.js";
import { import {
getStorageSummary,
getTrash, getTrash,
purgeTrash, purgeTrash,
restoreFromTrash, restoreFromTrash,
@@ -29,7 +30,12 @@
import { isMobile } from "#lib/viewport.js"; import { isMobile } from "#lib/viewport.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
const PAGE = 30;
let items = $state<TrashItem[]>([]); let items = $state<TrashItem[]>([]);
let total = $state({ bytes: 0, files: 0 });
let done = $state(false);
let everything = $state(false);
let loading = $state(true); let loading = $state(true);
let failed = $state(false); let failed = $state(false);
let busy = $state(false); let busy = $state(false);
@@ -42,7 +48,7 @@
const heaviest = $derived( const heaviest = $derived(
Math.max(0, ...items.map((item) => item.file_size ?? 0)) Math.max(0, ...items.map((item) => item.file_size ?? 0))
); );
const totalBytes = $derived(bytesOf(items)); const dialogOpen = $derived(doomed !== null || everything);
const chosen = $derived( const chosen = $derived(
items.filter((item) => selected.has(item.storage_key)) items.filter((item) => selected.has(item.storage_key))
); );
@@ -74,12 +80,19 @@
return chats.byId(chatId)?.title ?? `Чат ${chatId}`; return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
} }
async function load() { async function loadTotal() {
const summary = await getStorageSummary();
total = { bytes: summary.trash_bytes, files: summary.trash_files };
}
async function loadMore() {
loading = true; loading = true;
failed = false; failed = false;
try { try {
items = await getTrash(); const batch = await getTrash({ limit: PAGE, offset: items.length });
chats.ensureMany(items.map((item) => item.chat_id)); chats.ensureMany(batch.map((item) => item.chat_id));
items = [...items, ...batch];
done = batch.length < PAGE;
} catch { } catch {
failed = true; failed = true;
} finally { } finally {
@@ -87,6 +100,13 @@
} }
} }
async function load() {
items = [];
done = false;
selected.clear();
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
}
function toggle(key: string) { function toggle(key: string) {
problem = null; problem = null;
if (!selected.delete(key)) { if (!selected.delete(key)) {
@@ -98,6 +118,7 @@
const gone = new Set(keys); const gone = new Set(keys);
items = items.filter((item) => !gone.has(item.storage_key)); items = items.filter((item) => !gone.has(item.storage_key));
selected.clear(); selected.clear();
loadTotal().catch(() => undefined);
} }
async function restore(list: TrashItem[]) { async function restore(list: TrashItem[]) {
@@ -117,17 +138,34 @@
} }
} }
async function purge() { async function restoreAll() {
if (busy || doomed === null) { if (busy) {
return; return;
} }
busy = true; busy = true;
problem = null; problem = null;
const keys = doomed.map((item) => item.storage_key); 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 { try {
await purgeTrash(keys); await purgeTrash(keys);
drop(keys);
doomed = null; doomed = null;
everything = false;
await load();
} catch { } catch {
problem = "Не удалось удалить файлы"; problem = "Не удалось удалить файлы";
} finally { } finally {
@@ -157,17 +195,22 @@
вернуть. вернуть.
</p> </p>
{#if items.length > 0} {#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 <button
class="clear" class="act danger"
onclick={() => { onclick={() => {
doomed = items; everything = true;
}} }}
type="button" type="button"
> >
<Icon name="delete" size="1.375rem" /> <Icon name="delete" size="1.375rem" />
<span class="label">Очистить корзину</span> <span class="label">Очистить корзину</span>
<span class="value">{formatBytes(totalBytes)}</span> <span class="value">{formatBytes(total.bytes)}</span>
</button> </button>
{/if} {/if}
@@ -219,12 +262,16 @@
</div> </div>
{:else if items.length === 0} {:else if items.length === 0}
<div class="state"><p>Корзина пуста.</p></div> <div class="state"><p>Корзина пуста.</p></div>
{:else if !done}
<button class="more" onclick={loadMore} type="button">
Показать ещё
</button>
{/if} {/if}
</div> </div>
{#if chosen.length > 0} {#if chosen.length > 0}
<SelectionBar <SelectionBar
failure={doomed === null ? problem : null} failure={dialogOpen ? null : problem}
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined} note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}" title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
warn warn
@@ -250,7 +297,7 @@
<span>Вернуть</span> <span>Вернуть</span>
</Button> </Button>
</SelectionBar> </SelectionBar>
{:else if problem && doomed === null} {:else if problem && !dialogOpen}
<SelectionBar failure={problem} title="Не получилось"> <SelectionBar failure={problem} title="Не получилось">
<Button <Button
onclick={() => { onclick={() => {
@@ -269,26 +316,40 @@
onOpenChange={(next) => { onOpenChange={(next) => {
if (!next) { if (!next) {
doomed = null; doomed = null;
everything = false;
problem = null; problem = null;
} }
}} }}
open={doomed !== null} open={doomed !== null || everything}
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title"> <Dialog.Title class="dialog-title">
{doomed?.length === items.length {everything ? "Очистить корзину?" : "Удалить насовсем?"}
? "Очистить корзину?"
: "Удалить насовсем?"}
</Dialog.Title> </Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label="Закрыть" class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
{#if doomed}
<div class="dialog-body"> <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"> <p class="confirm">
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))}, {filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
{plural( {plural(
@@ -316,11 +377,11 @@
можно скачать из Telegram заново. можно скачать из Telegram заново.
</p> </p>
{/if} {/if}
{/if}
{#if problem} {#if problem}
<p class="confirm failed">{problem}.</p> <p class="confirm failed">{problem}.</p>
{/if} {/if}
</div> </div>
{/if}
<div class="dialog-actions"> <div class="dialog-actions">
<Dialog.Close> <Dialog.Close>
{#snippet child({ {#snippet child({
@@ -366,7 +427,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.clear { .act {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -382,11 +443,20 @@
font: inherit; font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-error); color: var(--color-primary);
text-align: start; text-align: start;
background: none; background: none;
&.danger {
color: var(--color-error);
}
&:disabled {
cursor: default;
opacity: 0.5;
}
.label { .label {
flex: 1; flex: 1;
} }
@@ -398,15 +468,46 @@
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-error); outline: 2px solid currentcolor;
outline-offset: -2px; 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) { @media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
@media (pointer: coarse) {
min-height: 2.75rem;
}
} }
.list { .list {
@@ -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,461 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
getPolicyScope,
updatePolicy,
} from "#lib/api/endpoints.js";
import type {
CaptureToggles,
PolicyParent,
PolicyRecord,
PolicyScopeType,
ScopeTarget,
} from "#lib/api/types.js";
import ScopeFiles from "#lib/components/disk/ScopeFiles.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 {
parentLabel,
pickToggles,
preset,
sameToggles,
summary,
TOGGLE_KEYS,
TOGGLE_LABELS,
type ToggleKey,
} from "#lib/format/policy.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 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;
}
}
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;
}
}
});
</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 target}
<ScopeFiles {target} />
{/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);
}
.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"> <script lang="ts">
import { onMount } from "svelte";
import { import {
createPolicy,
deletePolicy, deletePolicy,
getPolicyOverview,
listFolders, listFolders,
listPolicies,
updatePolicy,
} from "#lib/api/endpoints.js"; } from "#lib/api/endpoints.js";
import type { import type { Folder, PolicyRule, PolicyScopeType } from "#lib/api/types.js";
CaptureToggles, import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
Folder, import Avatar from "#lib/components/ui/Avatar.svelte";
PolicyRecord, import Button from "#lib/components/ui/Button.svelte";
PolicyScopeType,
} from "#lib/api/types.js";
import CaptureToggleList from "#lib/components/policy/CaptureToggleList.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte";
import { 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 { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
let policies = $state<PolicyRecord[]>([]); interface Draft {
scopeId: number;
scopeType: "folder" | "chat";
title: string;
}
const DEFAULT_ORDER: PolicyScopeType[] = [
"default_dm",
"default_group",
"default_channel",
];
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
default_channel: "channel",
default_dm: "user",
default_group: "group",
};
let rules = $state<PolicyRule[]>([]);
let folders = $state<Folder[]>([]); let folders = $state<Folder[]>([]);
let loading = $state(true); let loading = $state(true);
let pickFolder = $state(false); let failed = $state(false);
let pickChat = $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(""); let chatFilter = $state("");
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [ const picker = createChatPicker();
{ label: "Личные чаты", scope: "default_dm" },
{ label: "Группы", scope: "default_group" },
{ label: "Каналы", scope: "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,
};
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,
};
}
const defaults = $derived( const defaults = $derived(
DEFAULT_SCOPES.map((d) => ({ DEFAULT_ORDER.map((scope) =>
label: d.label, rules.find((rule) => rule.scope_type === scope)
record: policies.find((p) => p.scope_type === d.scope), ).filter((rule): rule is PolicyRule => rule !== undefined)
})).filter((d): d is { label: string; record: PolicyRecord } =>
Boolean(d.record)
)
); );
const folderPolicies = $derived( const folderRules = $derived(
policies.filter((p) => p.scope_type === "folder") rules
.filter((rule) => rule.scope_type === "folder")
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
); );
const chatPolicies = $derived( const chatRules = $derived(
policies.filter((p) => p.scope_type === "chat") rules
.filter((rule) => rule.scope_type === "chat")
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
); );
const availableFolders = $derived( const freeFolders = $derived(
folders.filter( 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 freeChats = $derived(
const availableChats = $derived(
picker.results.filter( picker.results.filter(
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id) (chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
) )
); );
$effect(() => { function folderOrder(id: number | null): number {
picker.search(chatFilter); return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
}); }
$effect(() => { function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
chats.ensureMany( return `${scopeType}:${scopeId}`;
chatPolicies }
.map((policy) => policy.scope_id)
.filter((id): id is number => id !== null) 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))
); );
});
function folderTitle(id: number | null): string {
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
}
function chatTitle(id: number | null): string {
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
}
async function reload() { async function reload() {
[policies, folders] = await Promise.all([listPolicies(), listFolders()]); try {
} [rules, folders] = await Promise.all([
getPolicyOverview(),
async function toggle( listFolders(),
record: PolicyRecord, ]);
key: keyof CaptureToggles, failed = false;
value: boolean if (
draft &&
rules.some(
(rule) =>
rule.scope_type === draft?.scopeType &&
rule.scope_id === draft?.scopeId
)
) { ) {
const next = { ...pickToggles(record), [key]: value }; expanded = keyOf(draft.scopeType, draft.scopeId);
policies = policies.map((p) => draft = null;
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));
} catch { } catch {
toasts.error("Не удалось сохранить политику"); failed = true;
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("Не удалось загрузить политики");
} finally { } finally {
loading = false; loading = false;
} }
} }
$effect(() => { function toggle(rule: PolicyRule) {
load(accounts.selectedId); 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> </script>
{#snippet card( {#snippet lead(
record: PolicyRecord, rule: PolicyRule
title: string,
onremove?: () => void
)} )}
<div class="card"> {#if rule.scope_type === "chat" && rule.scope_id !== null}
<div class="card-head"> <Avatar
<span class="card-title">{title}</span> avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
{#if record.account_id === null} colorKey={rule.scope_id}
<span class="shared">для всех аккаунтов</span> 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} {/if}
{#if onremove && record.account_id !== null} {/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 <button
aria-label="Удалить оверрайд" aria-expanded={open}
class="remove" class="main"
onclick={onremove} onclick={() => toggle(rule)}
type="button" type="button"
> >
<Icon name="close" size="1.125rem" /> {@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> </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} {/if}
</div> </div>
<CaptureToggleList {/snippet}
onchange={(key, value) => toggle(record, key, value)}
toggles={record} {#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> </div>
{/snippet} {/snippet}
{#if loading} {#if loading}
<div class="state"><Spinner /></div> <div class="state"><Spinner /></div>
{:else if failed}
<div class="state">
<p>Не удалось загрузить политику.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button>
</div>
{:else} {:else}
<div class="editor"> <div class="policy">
{#if problem}
<p class="problem">{problem}</p>
{/if}
<section> <section>
<div class="section-title">По умолчанию</div> <div class="section-title"><h3>По умолчанию</h3></div>
{#each defaults as item (item.record.id)} {#each defaults as rule (rule.id)}
{@render card(item.record, item.label)} {@render row(rule, true)}
{/each} {/each}
</section> </section>
<section> <section>
<div class="section-title">Оверрайды папок</div> <div class="section-title">
{#each folderPolicies as record (record.id)} <h3>Папки</h3>
{@render card(record, folderTitle(record.scope_id), () => <span>Что сохраняют. Из подходящих папок побеждает первая.</span>
removeOverride(record) </div>
)} {#each folderRules as rule (rule.id)}
{#if draft && draftBefore(rule)}
{@render pending(draft)}
{/if}
{@render row(rule, false)}
{/each} {/each}
{#if availableFolders.length > 0} {#if draft && draftLast}
{@render pending(draft)}
{/if}
{#if freeFolders.length > 0}
<button <button
aria-expanded={picking === "folder"}
class="add" class="add"
onclick={() => { onclick={() => togglePicker("folder")}
pickFolder = !pickFolder;
}}
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить папку</span> <span>Правило для папки</span>
</button> </button>
{#if pickFolder} {#if picking === "folder"}
<div class="picker"> <div class="picker">
{#each availableFolders as folder (folder.folder_id)} {#each freeFolders as folder (folder.folder_id)}
<button <button
class="pick-row" class="pick"
onclick={() => addOverride("folder", folder.folder_id)} onclick={() => start("folder", folder.folder_id, folder.title)}
type="button" type="button"
> >
{folder.title} {folder.title}
@@ -247,38 +375,50 @@
</section> </section>
<section> <section>
<div class="section-title">Оверрайды чатов</div> <div class="section-title">
{#each chatPolicies as record (record.id)} <h3>Чаты</h3>
{@render card(record, chatTitle(record.scope_id), () => <span>Что меняют против папки или умолчания. Важнее папок.</span>
removeOverride(record) </div>
)} {#if draft?.scopeType === "chat"}
{@render pending(draft)}
{/if}
{#each chatRules as rule (rule.id)}
{@render row(rule, false)}
{/each} {/each}
<button <button
aria-expanded={picking === "chat"}
class="add" class="add"
onclick={() => { onclick={() => togglePicker("chat")}
pickChat = !pickChat;
}}
type="button" type="button"
> >
<Icon name="add" size="1.25rem" /> <Icon name="add" size="1.25rem" />
<span>Добавить чат</span> <span>Правило для чата</span>
</button> </button>
{#if pickChat} {#if picking === "chat"}
<div class="picker"> <div class="picker">
<input <input
aria-label="Поиск чата"
class="filter" class="filter"
oninput={() => picker.search(chatFilter)}
placeholder="Поиск чата" placeholder="Поиск чата"
type="text" type="text"
bind:value={chatFilter} bind:value={chatFilter}
> >
{#each availableChats as chat (chat.chat_id)} {#each freeChats as chat (chat.chat_id)}
<button <button
class="pick-row" class="pick"
onclick={() => addOverride("chat", chat.chat_id)} onclick={() =>
start(
"chat",
chat.chat_id,
chat.title ?? `Чат ${chat.chat_id}`
)}
type="button" type="button"
> >
{chat.title ?? `Чат ${chat.chat_id}`} {chat.title ?? `Чат ${chat.chat_id}`}
</button> </button>
{:else}
<p class="nothing">Ничего не найдено.</p>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -289,81 +429,213 @@
<style lang="scss"> <style lang="scss">
.state { .state {
display: flex; display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center; 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; padding-bottom: 1rem;
} }
section { .problem {
padding: 0.5rem 0; margin: 0;
border-bottom: 1px solid var(--color-borders); padding: 0.75rem 1rem 0;
font-size: 0.8125rem;
color: var(--color-error);
} }
.section-title { .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-size: 0.8125rem;
line-height: 1.35;
color: var(--color-text-secondary);
h3 {
margin: 0;
font-size: inherit;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-primary); color: var(--color-primary);
} }
.card {
padding: 0.25rem 0 0.5rem;
} }
.card-head { .rule.open {
display: flex; border-bottom: 1px solid var(--color-borders);
align-items: center;
gap: 0.5rem;
padding: 0.25rem 1rem;
}
.card-title { .line {
flex: 1;
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
}
.shared {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.remove {
cursor: pointer;
display: flex;
padding: 0.25rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
.line {
display: flex;
align-items: center;
padding-right: 0.375rem;
}
.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 { .add {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
gap: 0.75rem;
align-items: center; align-items: center;
gap: 1rem;
width: 100%; width: 100%;
padding: 0.625rem 1rem; height: 2.75rem;
padding: 0 1rem 0 1.5rem;
border: 0; border: 0;
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-primary); color: var(--color-primary);
background-color: transparent; text-align: start;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
}
.picker { .picker {
display: flex; display: flex;
@@ -377,8 +649,10 @@
border: 1px solid var(--color-borders); border: 1px solid var(--color-borders);
border-radius: var(--border-radius-default); border-radius: var(--border-radius-default);
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text); color: var(--color-text);
background-color: var(--color-background); background-color: var(--color-background);
&:focus { &:focus {
@@ -387,8 +661,9 @@
} }
} }
.pick-row { .pick {
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
@@ -396,15 +671,35 @@
border: 0; border: 0;
border-radius: 0.5rem; border-radius: 0.5rem;
font: inherit;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text);
text-align: start; text-align: start;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--color-text);
background-color: transparent;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover { &:hover {
background-color: var(--color-chat-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> </style>
+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")];
}
+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();
+38
View File
@@ -0,0 +1,38 @@
import { ui } from "#lib/stores/ui.svelte.js";
export type StorageListing = "files" | "chats";
interface Resume {
count: number;
top: number;
}
function createStorageUi() {
let listing = $state<StorageListing>("files");
let chatId = $state<number | null>(null);
let resume: Resume | null = null;
return {
get chatId() {
return chatId;
},
get listing() {
return listing;
},
set listing(next: StorageListing) {
listing = next;
},
openChat(id: number, from: Resume) {
chatId = id;
resume = from;
ui.openPanel("storage-chat");
},
takeResume(): Resume | null {
const taken = resume;
resume = null;
return taken;
},
};
}
export const storageUi = createStorageUi();
+1
View File
@@ -16,6 +16,7 @@ export type RightPanel =
| "shares" | "shares"
| "bots" | "bots"
| "storage" | "storage"
| "storage-chat"
| "storage-trash"; | "storage-trash";
export type LeftView = "main" | "settings"; export type LeftView = "main" | "settings";
+4
View File
@@ -7,6 +7,10 @@ html.theme-dark {
--color-storage-warning: #fb8c00; --color-storage-warning: #fb8c00;
--color-storage-trash: #6c6c6c; --color-storage-trash: #6c6c6c;
--storage-tile-mix: 90%; --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: #8774e1;
--color-primary-opacity: #8378db1e; --color-primary-opacity: #8378db1e;
--color-primary-opacity-hover: #8378db40; --color-primary-opacity-hover: #8378db40;
+5
View File
@@ -142,6 +142,11 @@
--color-storage-trash: #939799; --color-storage-trash: #939799;
--storage-tile-mix: 100%; --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-yellow: #{$color-yellow};
--color-orange: #{$color-orange}; --color-orange: #{$color-orange};
+3
View File
@@ -3,6 +3,7 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte"; import SearchInput from "#lib/components/search/SearchInput.svelte";
import SearchResults from "#lib/components/search/SearchResults.svelte"; import SearchResults from "#lib/components/search/SearchResults.svelte";
@@ -101,6 +102,8 @@
{/if} {/if}
</div> </div>
<PolicyDialog />
<ShareDialog <ShareDialog
label={shareUi.label} label={shareUi.label}
onchange={() => shareUi.touch()} onchange={() => shareUi.touch()}