Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc726976b | ||
|
|
cb01d7dff8 | ||
|
|
f646f29ff9 |
No files matched your search
@@ -5,13 +5,15 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.policy import repository
|
||||
from utils.policy import overview, repository
|
||||
from utils.policy.models import (
|
||||
CaptureToggles,
|
||||
ChatKind,
|
||||
ChatMeta,
|
||||
PolicyCreate,
|
||||
PolicyRecord,
|
||||
PolicyRuleView,
|
||||
PolicyScopeView,
|
||||
)
|
||||
from utils.policy.resolver import resolve
|
||||
|
||||
@@ -50,6 +52,32 @@ async def effective_policy(
|
||||
return resolve(chat, folders, policies)
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def policy_overview(
|
||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
||||
) -> list[PolicyRuleView]:
|
||||
return await overview.list_rules(pool, account_id)
|
||||
|
||||
|
||||
@router.get("/scope")
|
||||
async def policy_scope(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: Annotated[int, Query()],
|
||||
chat_id: Annotated[int | None, Query()] = None,
|
||||
folder_id: Annotated[int | None, Query()] = None,
|
||||
) -> PolicyScopeView:
|
||||
if chat_id is not None:
|
||||
return await overview.chat_scope(pool, account_id, chat_id)
|
||||
scope = (
|
||||
await overview.folder_scope(pool, account_id, folder_id)
|
||||
if folder_id is not None
|
||||
else None
|
||||
)
|
||||
if scope is None:
|
||||
raise HTTPException(status_code=404, detail="scope not found")
|
||||
return scope
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_policy(
|
||||
pool: FromDishka[asyncpg.Pool], body: PolicyCreate
|
||||
|
||||
@@ -14,6 +14,7 @@ from utils.read.models import (
|
||||
DEFAULT_LIMIT,
|
||||
Page,
|
||||
StorageFileView,
|
||||
StorageScopeView,
|
||||
StorageSummaryView,
|
||||
TrashItemView,
|
||||
TrashOutcome,
|
||||
@@ -30,7 +31,13 @@ class TrashRequest(BaseModel):
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
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):
|
||||
@@ -57,11 +64,47 @@ async def files(
|
||||
return await storage_read.list_files(pool, account_id, group, page)
|
||||
|
||||
|
||||
async def _chat_ids(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
|
||||
) -> list[int]:
|
||||
chat_ids = await storage_read.scope_chat_ids(pool, account_id, chat_id, folder_id)
|
||||
if chat_ids is None:
|
||||
raise HTTPException(status_code=404, detail="scope not found")
|
||||
return chat_ids
|
||||
|
||||
|
||||
@router.get("/scope")
|
||||
async def scope(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: Annotated[int, Query()],
|
||||
chat_id: Annotated[int | None, Query()] = None,
|
||||
folder_id: Annotated[int | None, Query()] = None,
|
||||
) -> StorageScopeView:
|
||||
chat_ids = await _chat_ids(pool, account_id, chat_id, folder_id)
|
||||
return await storage_read.get_scope(pool, account_id, chat_ids)
|
||||
|
||||
|
||||
@router.get("/trash")
|
||||
async def trash_items(
|
||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: Annotated[int, Query()],
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> 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")
|
||||
|
||||
@@ -79,6 +79,24 @@ class FolderSpec:
|
||||
is_chatlist: bool = False
|
||||
|
||||
|
||||
class PolicyParent(BaseModel):
|
||||
scope_type: ScopeType
|
||||
scope_id: int | None
|
||||
title: str | None = None
|
||||
toggles: CaptureToggles
|
||||
|
||||
|
||||
class PolicyRuleView(PolicyRecord):
|
||||
title: str | None = None
|
||||
parent: PolicyParent | None = None
|
||||
|
||||
|
||||
class PolicyScopeView(BaseModel):
|
||||
title: str | None
|
||||
rule: PolicyRecord | None
|
||||
parent: PolicyParent | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PolicySet:
|
||||
chat: dict[int, CaptureToggles] = field(default_factory=dict)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,11 +1,14 @@
|
||||
from collections.abc import Iterable
|
||||
|
||||
from utils.policy.models import (
|
||||
DEFAULT_SCOPE_BY_KIND,
|
||||
CaptureToggles,
|
||||
ChatKind,
|
||||
ChatMeta,
|
||||
FolderSpec,
|
||||
PolicyParent,
|
||||
PolicySet,
|
||||
ScopeType,
|
||||
)
|
||||
|
||||
|
||||
@@ -33,14 +36,29 @@ def folder_contains(folder: FolderSpec, chat: ChatMeta) -> bool:
|
||||
return _category_match(folder, chat)
|
||||
|
||||
|
||||
def resolve_parent(
|
||||
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
|
||||
) -> PolicyParent:
|
||||
for folder in sorted(folders, key=lambda f: f.order_index):
|
||||
toggles = policies.folder.get(folder.folder_id)
|
||||
if toggles is not None and folder_contains(folder, chat):
|
||||
return PolicyParent(
|
||||
scope_type=ScopeType.FOLDER,
|
||||
scope_id=folder.folder_id,
|
||||
title=folder.title,
|
||||
toggles=toggles,
|
||||
)
|
||||
return PolicyParent(
|
||||
scope_type=DEFAULT_SCOPE_BY_KIND[chat.kind],
|
||||
scope_id=None,
|
||||
toggles=policies.defaults.get(chat.kind, CaptureToggles()),
|
||||
)
|
||||
|
||||
|
||||
def resolve(
|
||||
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
|
||||
) -> CaptureToggles:
|
||||
chat_override = policies.chat.get(chat.chat_id)
|
||||
if chat_override is not None:
|
||||
return chat_override
|
||||
for folder in sorted(folders, key=lambda f: f.order_index):
|
||||
toggles = policies.folder.get(folder.folder_id)
|
||||
if toggles is not None and folder_contains(folder, chat):
|
||||
return toggles
|
||||
return policies.defaults.get(chat.kind, CaptureToggles())
|
||||
return resolve_parent(chat, folders, policies).toggles
|
||||
@@ -176,6 +176,18 @@ async def list_chats(
|
||||
return [_chat_item(row) for row in rows]
|
||||
|
||||
|
||||
async def folder_chat_ids(
|
||||
pool: asyncpg.Pool, account_id: int, folder: FolderSpec
|
||||
) -> list[int]:
|
||||
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
|
||||
rows = await pool.fetch(
|
||||
f"SELECT chat.chat_id FROM ({rows_sql}) chat WHERE {_folder_filter(1)}", # noqa: S608
|
||||
account_id,
|
||||
*_folder_params(folder),
|
||||
)
|
||||
return [row["chat_id"] for row in rows]
|
||||
|
||||
|
||||
async def get_chat(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||
) -> ChatListItem | None:
|
||||
|
||||
@@ -287,6 +287,14 @@ class TrashOutcome(BaseModel):
|
||||
blocked: list[int] = []
|
||||
|
||||
|
||||
class StorageScopeView(BaseModel):
|
||||
chats: int
|
||||
files: int
|
||||
bytes: int
|
||||
kept: int
|
||||
gone: int
|
||||
|
||||
|
||||
class AvatarRef(BaseModel):
|
||||
unique_id: str
|
||||
storage_key: str | None
|
||||
|
||||
@@ -4,10 +4,13 @@ from collections import defaultdict
|
||||
import asyncpg
|
||||
|
||||
from utils.files import media_file_name
|
||||
from utils.policy import repository as policy_repository
|
||||
from utils.read import chats
|
||||
from utils.read.models import (
|
||||
Page,
|
||||
StorageFileView,
|
||||
StorageGroupView,
|
||||
StorageScopeView,
|
||||
StorageSummaryView,
|
||||
TrashItemView,
|
||||
)
|
||||
@@ -71,6 +74,46 @@ SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
|
||||
trashed_at
|
||||
FROM media_trash WHERE account_id = $1
|
||||
ORDER BY file_size DESC NULLS LAST, storage_key
|
||||
LIMIT $2 OFFSET $3
|
||||
"""
|
||||
|
||||
SCOPE_BLOBS = """
|
||||
WITH inside AS (
|
||||
SELECT DISTINCT storage_key FROM media
|
||||
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
|
||||
AND downloaded AND storage_key IS NOT NULL
|
||||
), blobs AS (
|
||||
SELECT m.storage_key, max(m.file_size) AS file_size,
|
||||
min(m.id) FILTER (
|
||||
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
|
||||
) AS media_id,
|
||||
bool_and(m.account_id = $1 AND m.chat_id = ANY($2::bigint[])) AS own
|
||||
FROM media m JOIN inside i ON i.storage_key = m.storage_key
|
||||
GROUP BY m.storage_key
|
||||
)
|
||||
SELECT b.storage_key, b.file_size, r.chat_id, r.message_id, r.kind, r.mime,
|
||||
b.own
|
||||
AND NOT EXISTS (SELECT 1 FROM stories s WHERE s.storage_key = b.storage_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM avatars a WHERE a.storage_key = b.storage_key)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM custom_emoji e WHERE e.storage_key = b.storage_key
|
||||
) AS free
|
||||
FROM blobs b JOIN media r ON r.id = b.media_id
|
||||
"""
|
||||
|
||||
_SCOPE_GONE = """
|
||||
SELECT (
|
||||
SELECT count(DISTINCT m.storage_key) FROM media m
|
||||
JOIN messages g ON g.account_id = m.account_id AND g.chat_id = m.chat_id
|
||||
AND g.message_id = m.message_id
|
||||
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
|
||||
AND m.downloaded AND m.storage_key IS NOT NULL
|
||||
AND g.deleted_at IS NOT NULL
|
||||
) + (
|
||||
SELECT count(DISTINCT storage_key) FROM media
|
||||
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
|
||||
AND downloaded AND storage_key IS NOT NULL AND ttl_seconds IS NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
_TRASH_REFS = """
|
||||
@@ -179,20 +222,26 @@ async def list_files(
|
||||
return list(files.values())
|
||||
|
||||
|
||||
async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]:
|
||||
rows = await pool.fetch(_TRASH, account_id)
|
||||
async def list_trash(
|
||||
pool: asyncpg.Pool, account_id: int, page: Page
|
||||
) -> list[TrashItemView]:
|
||||
rows = await pool.fetch(_TRASH, account_id, page.capped_limit, page.offset)
|
||||
refs = await pool.fetch(
|
||||
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
|
||||
)
|
||||
messages = await _messages(pool, account_id, refs)
|
||||
alive: dict[str, bool] = defaultdict(bool)
|
||||
counts: dict[str, int] = {}
|
||||
names: dict[str, str] = {}
|
||||
for ref, message in zip(refs, messages, strict=True):
|
||||
alive[ref["storage_key"]] |= _alive(ref, message)
|
||||
counts[ref["storage_key"]] = ref["refs"]
|
||||
key = ref["storage_key"]
|
||||
alive[key] |= _alive(ref, message)
|
||||
counts[key] = ref["refs"]
|
||||
if message is not None and message["original_name"]:
|
||||
names.setdefault(key, message["original_name"])
|
||||
return [
|
||||
TrashItemView(
|
||||
**dict(row),
|
||||
**{**dict(row), "file_name": _trash_name(row, names)},
|
||||
purge_at=row["trashed_at"] + RETENTION,
|
||||
refs=counts.get(row["storage_key"], 0),
|
||||
refetchable=alive.get(row["storage_key"], True),
|
||||
@@ -201,6 +250,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:
|
||||
return await pool.fetchrow(
|
||||
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.files import media_file_name
|
||||
from utils.read.media import get_media
|
||||
from utils.read.models import TrashOutcome
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -12,6 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RETENTION = timedelta(days=7)
|
||||
PURGE_INTERVAL_SECONDS = 3600
|
||||
BATCH = 200
|
||||
|
||||
_SHARED_ELSEWHERE = """
|
||||
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
|
||||
@@ -47,6 +49,12 @@ _FORGET_KEYS = """
|
||||
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
|
||||
"""
|
||||
|
||||
_EVICT_MANY = """
|
||||
UPDATE media SET evicted_key = storage_key, storage_key = NULL,
|
||||
downloaded = false, evicted_at = now()
|
||||
WHERE storage_key = ANY($1::text[])
|
||||
"""
|
||||
|
||||
|
||||
async def move_to_trash(
|
||||
pool: asyncpg.Pool,
|
||||
@@ -86,12 +94,65 @@ async def move_to_trash(
|
||||
return outcome
|
||||
|
||||
|
||||
def _move_batch(storage: ContentAddressedStorage, keys: list[str]) -> None:
|
||||
moved: list[str] = []
|
||||
try:
|
||||
for key in keys:
|
||||
if storage.exists(key):
|
||||
storage.trash(key)
|
||||
moved.append(key)
|
||||
except OSError:
|
||||
for key in moved:
|
||||
storage.restore(key)
|
||||
raise
|
||||
|
||||
|
||||
async def trash_blobs(
|
||||
pool: asyncpg.Pool,
|
||||
storage: ContentAddressedStorage,
|
||||
account_id: int,
|
||||
blobs: list[asyncpg.Record],
|
||||
) -> TrashOutcome:
|
||||
outcome = TrashOutcome()
|
||||
for start in range(0, len(blobs), BATCH):
|
||||
batch = blobs[start : start + BATCH]
|
||||
keys = [blob["storage_key"] for blob in batch]
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
await conn.execute(_EVICT_MANY, keys)
|
||||
await conn.executemany(
|
||||
_INSERT,
|
||||
[
|
||||
(
|
||||
blob["storage_key"],
|
||||
account_id,
|
||||
blob["chat_id"],
|
||||
blob["message_id"],
|
||||
blob["kind"],
|
||||
blob["mime"],
|
||||
media_file_name(blob["kind"], blob["mime"], blob["message_id"]),
|
||||
blob["file_size"],
|
||||
)
|
||||
for blob in batch
|
||||
],
|
||||
)
|
||||
await asyncio.to_thread(_move_batch, storage, keys)
|
||||
outcome.keys.extend(keys)
|
||||
outcome.files += len(batch)
|
||||
outcome.bytes += sum(blob["file_size"] or 0 for blob in batch)
|
||||
return outcome
|
||||
|
||||
|
||||
async def restore(
|
||||
pool: asyncpg.Pool,
|
||||
storage: ContentAddressedStorage,
|
||||
account_id: int,
|
||||
keys: list[str],
|
||||
keys: list[str] | None,
|
||||
) -> TrashOutcome:
|
||||
if keys is None:
|
||||
rows = await pool.fetch(
|
||||
"SELECT storage_key FROM media_trash WHERE account_id = $1", account_id
|
||||
)
|
||||
keys = [row["storage_key"] for row in rows]
|
||||
outcome = TrashOutcome()
|
||||
for key in keys:
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
|
||||
@@ -26,13 +26,17 @@ import type {
|
||||
PolicyChatKind,
|
||||
PolicyCreate,
|
||||
PolicyRecord,
|
||||
PolicyRule,
|
||||
PolicyScope,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
ReactionView,
|
||||
ResponseStats,
|
||||
ScopeTarget,
|
||||
SearchHit,
|
||||
StorageFile,
|
||||
StorageGroupName,
|
||||
StorageScope,
|
||||
StorageSummary,
|
||||
StoryView,
|
||||
TrashItem,
|
||||
@@ -148,6 +152,23 @@ export function deletePolicy(id: number): Promise<void> {
|
||||
return request<void>(`/policy/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
function scopeQuery(target: ScopeTarget): Record<string, number> {
|
||||
return "chatId" in target
|
||||
? { chat_id: target.chatId }
|
||||
: { folder_id: target.folderId };
|
||||
}
|
||||
|
||||
export function getPolicyOverview(): Promise<PolicyRule[]> {
|
||||
return request<PolicyRule[]>("/policy/overview", { account: true });
|
||||
}
|
||||
|
||||
export function getPolicyScope(target: ScopeTarget): Promise<PolicyScope> {
|
||||
return request<PolicyScope>("/policy/scope", {
|
||||
account: true,
|
||||
query: scopeQuery(target),
|
||||
});
|
||||
}
|
||||
|
||||
export function effectivePolicy(query: {
|
||||
chat_id: number;
|
||||
is_bot?: boolean;
|
||||
@@ -601,8 +622,25 @@ export function getStorageFiles(
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrash(): Promise<TrashItem[]> {
|
||||
return request<TrashItem[]>("/storage/trash", { account: true });
|
||||
export function getTrash(page: Page = {}): Promise<TrashItem[]> {
|
||||
return request<TrashItem[]>("/storage/trash", {
|
||||
account: true,
|
||||
query: { ...page },
|
||||
});
|
||||
}
|
||||
|
||||
export function getStorageScope(target: ScopeTarget): Promise<StorageScope> {
|
||||
return request<StorageScope>("/storage/scope", {
|
||||
account: true,
|
||||
query: scopeQuery(target),
|
||||
});
|
||||
}
|
||||
|
||||
export function trashScope(target: ScopeTarget): Promise<TrashOutcome> {
|
||||
return request<TrashOutcome>("/storage/trash/scope", {
|
||||
body: { account_id: accounts.selectedId, ...scopeQuery(target) },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
|
||||
@@ -612,7 +650,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", {
|
||||
body: { account_id: accounts.selectedId, keys },
|
||||
method: "POST",
|
||||
|
||||
@@ -423,6 +423,24 @@ export interface PolicyRecord extends CaptureToggles {
|
||||
scope_type: PolicyScopeType;
|
||||
}
|
||||
|
||||
export interface PolicyParent {
|
||||
scope_id: number | null;
|
||||
scope_type: PolicyScopeType;
|
||||
title: string | null;
|
||||
toggles: CaptureToggles;
|
||||
}
|
||||
|
||||
export interface PolicyRule extends PolicyRecord {
|
||||
parent: PolicyParent | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyScope {
|
||||
parent: PolicyParent | null;
|
||||
rule: PolicyRecord | null;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export type PolicyChatKind = "dm" | "group" | "channel";
|
||||
|
||||
export interface PolicyCreate extends CaptureToggles {
|
||||
@@ -613,3 +631,13 @@ export interface TrashOutcome {
|
||||
files: number;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface StorageScope {
|
||||
bytes: number;
|
||||
chats: number;
|
||||
files: number;
|
||||
gone: number;
|
||||
kept: number;
|
||||
}
|
||||
|
||||
export type ScopeTarget = { chatId: number } | { folderId: number };
|
||||
@@ -15,6 +15,7 @@
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { discover } from "#lib/stores/discover.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -186,6 +187,11 @@
|
||||
onselect={() => ui.openPanel("stories")}
|
||||
>Сторис</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="settings"
|
||||
onselect={() => policyUi.edit({ chatId }, title)}
|
||||
>Политика захвата</ContextMenuItem
|
||||
>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
<div class="actions">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { formatListDate } from "#lib/format/datetime.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { peers } from "#lib/stores/peers.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
@@ -108,6 +109,11 @@
|
||||
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
||||
>Сторис</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="settings"
|
||||
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
|
||||
>Политика захвата</ContextMenuItem
|
||||
>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||
import { folders } from "#lib/stores/folders.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
|
||||
interface Tab {
|
||||
id: number | null;
|
||||
@@ -21,6 +24,12 @@
|
||||
)
|
||||
);
|
||||
|
||||
function editPolicy(tab: Tab) {
|
||||
if (tab.id !== null) {
|
||||
policyUi.edit({ folderId: tab.id }, tab.title);
|
||||
}
|
||||
}
|
||||
|
||||
let containerEl = $state<HTMLDivElement>();
|
||||
let indicatorEl = $state<HTMLDivElement>();
|
||||
let clipPath = $state("");
|
||||
@@ -64,9 +73,31 @@
|
||||
|
||||
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
|
||||
{#each tabs as tab (tab.id)}
|
||||
{#if tab.id === null}
|
||||
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
||||
{tab.title}
|
||||
</button>
|
||||
{:else}
|
||||
<ContextMenu>
|
||||
{#snippet children({
|
||||
props,
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
class="tab"
|
||||
onclick={() => folders.select(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
|
||||
Политика захвата
|
||||
</ContextMenuItem>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = {
|
||||
@@ -78,7 +79,9 @@
|
||||
{#if ui.rightPanel === "profile"}
|
||||
<ProfilePanel />
|
||||
{:else if ui.rightPanel === "policy"}
|
||||
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
||||
<PolicyEditor />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "jobs"}
|
||||
<JobsPanel />
|
||||
{:else if ui.rightPanel === "presence"}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { trashedUrl } from "#lib/api/client.js";
|
||||
import {
|
||||
getStorageSummary,
|
||||
getTrash,
|
||||
purgeTrash,
|
||||
restoreFromTrash,
|
||||
@@ -29,7 +30,12 @@
|
||||
import { isMobile } from "#lib/viewport.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const PAGE = 30;
|
||||
|
||||
let items = $state<TrashItem[]>([]);
|
||||
let total = $state({ bytes: 0, files: 0 });
|
||||
let done = $state(false);
|
||||
let everything = $state(false);
|
||||
let loading = $state(true);
|
||||
let failed = $state(false);
|
||||
let busy = $state(false);
|
||||
@@ -42,7 +48,7 @@
|
||||
const heaviest = $derived(
|
||||
Math.max(0, ...items.map((item) => item.file_size ?? 0))
|
||||
);
|
||||
const totalBytes = $derived(bytesOf(items));
|
||||
const dialogOpen = $derived(doomed !== null || everything);
|
||||
const chosen = $derived(
|
||||
items.filter((item) => selected.has(item.storage_key))
|
||||
);
|
||||
@@ -74,12 +80,19 @@
|
||||
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;
|
||||
failed = false;
|
||||
try {
|
||||
items = await getTrash();
|
||||
chats.ensureMany(items.map((item) => item.chat_id));
|
||||
const batch = await getTrash({ limit: PAGE, offset: items.length });
|
||||
chats.ensureMany(batch.map((item) => item.chat_id));
|
||||
items = [...items, ...batch];
|
||||
done = batch.length < PAGE;
|
||||
} catch {
|
||||
failed = true;
|
||||
} finally {
|
||||
@@ -87,6 +100,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
items = [];
|
||||
done = false;
|
||||
selected.clear();
|
||||
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
problem = null;
|
||||
if (!selected.delete(key)) {
|
||||
@@ -98,6 +118,7 @@
|
||||
const gone = new Set(keys);
|
||||
items = items.filter((item) => !gone.has(item.storage_key));
|
||||
selected.clear();
|
||||
loadTotal().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function restore(list: TrashItem[]) {
|
||||
@@ -117,17 +138,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
if (busy || doomed === null) {
|
||||
async function restoreAll() {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
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 {
|
||||
await purgeTrash(keys);
|
||||
drop(keys);
|
||||
doomed = null;
|
||||
everything = false;
|
||||
await load();
|
||||
} catch {
|
||||
problem = "Не удалось удалить файлы";
|
||||
} finally {
|
||||
@@ -157,17 +195,22 @@
|
||||
вернуть.
|
||||
</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
|
||||
class="clear"
|
||||
class="act danger"
|
||||
onclick={() => {
|
||||
doomed = items;
|
||||
everything = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="1.375rem" />
|
||||
<span class="label">Очистить корзину</span>
|
||||
<span class="value">{formatBytes(totalBytes)}</span>
|
||||
<span class="value">{formatBytes(total.bytes)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -219,12 +262,16 @@
|
||||
</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="state"><p>Корзина пуста.</p></div>
|
||||
{:else if !done}
|
||||
<button class="more" onclick={loadMore} type="button">
|
||||
Показать ещё
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chosen.length > 0}
|
||||
<SelectionBar
|
||||
failure={doomed === null ? problem : null}
|
||||
failure={dialogOpen ? null : problem}
|
||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
||||
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
||||
warn
|
||||
@@ -250,7 +297,7 @@
|
||||
<span>Вернуть</span>
|
||||
</Button>
|
||||
</SelectionBar>
|
||||
{:else if problem && doomed === null}
|
||||
{:else if problem && !dialogOpen}
|
||||
<SelectionBar failure={problem} title="Не получилось">
|
||||
<Button
|
||||
onclick={() => {
|
||||
@@ -269,26 +316,40 @@
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
doomed = null;
|
||||
everything = false;
|
||||
problem = null;
|
||||
}
|
||||
}}
|
||||
open={doomed !== null}
|
||||
open={doomed !== null || everything}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<Dialog.Title class="dialog-title">
|
||||
{doomed?.length === items.length
|
||||
? "Очистить корзину?"
|
||||
: "Удалить насовсем?"}
|
||||
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
{#if doomed}
|
||||
<div class="dialog-body">
|
||||
{#if everything}
|
||||
<p class="confirm">
|
||||
{filesLabel(total.files)}, {formatBytes(total.bytes)},
|
||||
{plural(
|
||||
total.files,
|
||||
"будет удалён",
|
||||
"будут удалены",
|
||||
"будут удалены"
|
||||
)}
|
||||
с диска.
|
||||
</p>
|
||||
<p class="confirm rest">
|
||||
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
|
||||
удалённых там сообщений пропадут насовсем.
|
||||
</p>
|
||||
{:else if doomed}
|
||||
<p class="confirm">
|
||||
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
|
||||
{plural(
|
||||
@@ -316,11 +377,11 @@
|
||||
можно скачать из Telegram заново.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if problem}
|
||||
<p class="confirm failed">{problem}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="dialog-actions">
|
||||
<Dialog.Close>
|
||||
{#snippet child({
|
||||
@@ -366,7 +427,7 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.clear {
|
||||
.act {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
@@ -382,11 +443,20 @@
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-error);
|
||||
color: var(--color-primary);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -398,15 +468,46 @@
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-error);
|
||||
outline: 2px solid currentcolor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(:disabled):hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more {
|
||||
cursor: pointer;
|
||||
|
||||
width: calc(100% - 1rem);
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary);
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { CaptureToggles } from "#lib/api/types.js";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
onchange: (key: keyof CaptureToggles, value: boolean) => void;
|
||||
toggles: CaptureToggles;
|
||||
}
|
||||
|
||||
let { toggles, disabled = false, onchange }: Props = $props();
|
||||
|
||||
const rows: { key: keyof CaptureToggles; label: string }[] = [
|
||||
{ key: "messages", label: "Сообщения" },
|
||||
{ key: "media", label: "Медиа" },
|
||||
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
|
||||
{ key: "stt", label: "Расшифровка голоса" },
|
||||
{ key: "reactions", label: "Реакции" },
|
||||
{ key: "track_edits_deletes", label: "Правки и удаления" },
|
||||
{ key: "profile_history", label: "История профиля" },
|
||||
{ key: "stories", label: "Сторис" },
|
||||
{ key: "presence", label: "Presence" },
|
||||
{ key: "backfill", label: "Бэкфилл" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="toggles">
|
||||
{#each rows as row (row.key)}
|
||||
<button
|
||||
aria-checked={toggles[row.key]}
|
||||
class="toggle-row"
|
||||
{disabled}
|
||||
onclick={() => onchange(row.key, !toggles[row.key])}
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span class="label">{row.label}</span>
|
||||
<span class="switch" class:on={toggles[row.key]}>
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-row {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 1.375rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
background-color: var(--color-borders);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&.on {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
.on & {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,640 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createPolicy,
|
||||
deletePolicy,
|
||||
getPolicyScope,
|
||||
getStorageScope,
|
||||
restoreFromTrash,
|
||||
trashScope,
|
||||
updatePolicy,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type {
|
||||
CaptureToggles,
|
||||
PolicyParent,
|
||||
PolicyRecord,
|
||||
PolicyScopeType,
|
||||
ScopeTarget,
|
||||
StorageScope,
|
||||
TrashOutcome,
|
||||
} from "#lib/api/types.js";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { formatBytes } from "#lib/format/media.js";
|
||||
import {
|
||||
parentLabel,
|
||||
pickToggles,
|
||||
preset,
|
||||
sameToggles,
|
||||
summary,
|
||||
TOGGLE_KEYS,
|
||||
TOGGLE_LABELS,
|
||||
type ToggleKey,
|
||||
} from "#lib/format/policy.js";
|
||||
import { plural } from "#lib/format/shares.js";
|
||||
import { filesLabel } from "#lib/format/storage.js";
|
||||
|
||||
interface Props {
|
||||
onchange?: () => void;
|
||||
parent?: PolicyParent | null;
|
||||
rule?: PolicyRecord | null;
|
||||
scopeId: number | null;
|
||||
scopeType: PolicyScopeType;
|
||||
}
|
||||
|
||||
let {
|
||||
scopeType,
|
||||
scopeId,
|
||||
rule: initialRule,
|
||||
parent: initialParent,
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
|
||||
{ label: "Всё", toggles: preset("all") },
|
||||
{ label: "Только сообщения", toggles: preset(["messages"]) },
|
||||
{ label: "Ничего", toggles: preset([]) },
|
||||
];
|
||||
|
||||
let rule = $state<PolicyRecord | null>(null);
|
||||
let parent = $state<PolicyParent | null>(null);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let problem = $state<string | null>(null);
|
||||
let files = $state<StorageScope | null>(null);
|
||||
let confirming = $state(false);
|
||||
let moving = $state(false);
|
||||
let moved = $state<TrashOutcome | null>(null);
|
||||
let removing = $state(false);
|
||||
|
||||
const isChat = $derived(scopeType === "chat");
|
||||
const isFolder = $derived(scopeType === "folder");
|
||||
const target = $derived<ScopeTarget | null>(scopeTarget());
|
||||
const shown = $derived<CaptureToggles | null>(
|
||||
rule ?? parent?.toggles ?? null
|
||||
);
|
||||
const hint = $derived(inheritance());
|
||||
|
||||
function scopeTarget(): ScopeTarget | null {
|
||||
if (scopeId === null) {
|
||||
return null;
|
||||
}
|
||||
if (scopeType === "chat") {
|
||||
return { chatId: scopeId };
|
||||
}
|
||||
return scopeType === "folder" ? { folderId: scopeId } : null;
|
||||
}
|
||||
|
||||
function inheritance(): string {
|
||||
if (isChat && parent) {
|
||||
const from = parentLabel(parent.scope_type, parent.title);
|
||||
return rule
|
||||
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
|
||||
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
|
||||
}
|
||||
if (isFolder) {
|
||||
return rule
|
||||
? "Действует на чаты папки, у которых нет своего правила."
|
||||
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function differs(key: ToggleKey): boolean {
|
||||
return (
|
||||
rule !== null && parent !== null && rule[key] !== parent.toggles[key]
|
||||
);
|
||||
}
|
||||
|
||||
async function save(next: CaptureToggles) {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
problem = null;
|
||||
try {
|
||||
rule = rule
|
||||
? await updatePolicy(rule.id, next)
|
||||
: await createPolicy({
|
||||
...next,
|
||||
scope_id: scopeId,
|
||||
scope_type: scopeType,
|
||||
});
|
||||
onchange?.();
|
||||
} catch {
|
||||
problem = "Не удалось сохранить правило";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function flip(key: ToggleKey) {
|
||||
if (shown !== null) {
|
||||
save({ ...pickToggles(shown), [key]: !shown[key] });
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (rule === null || saving) {
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
problem = null;
|
||||
try {
|
||||
await deletePolicy(rule.id);
|
||||
rule = null;
|
||||
removing = false;
|
||||
onchange?.();
|
||||
} catch {
|
||||
problem = "Не удалось удалить правило";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
files = await getStorageScope(target);
|
||||
} catch {
|
||||
files = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFiles() {
|
||||
if (target === null || moving) {
|
||||
return;
|
||||
}
|
||||
moving = true;
|
||||
problem = null;
|
||||
try {
|
||||
moved = await trashScope(target);
|
||||
confirming = false;
|
||||
await loadFiles();
|
||||
} catch {
|
||||
problem = "Не удалось переместить файлы в корзину";
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function undoMove() {
|
||||
if (moved === null || moving) {
|
||||
return;
|
||||
}
|
||||
moving = true;
|
||||
problem = null;
|
||||
try {
|
||||
await restoreFromTrash(moved.keys);
|
||||
moved = null;
|
||||
await loadFiles();
|
||||
} catch {
|
||||
problem = "Не удалось вернуть файлы";
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function where(scope: StorageScope): string {
|
||||
return isFolder
|
||||
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
||||
: "в этом чате";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
rule = initialRule ?? null;
|
||||
parent = initialParent ?? null;
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
if (initialRule === undefined) {
|
||||
loading = true;
|
||||
try {
|
||||
({ rule, parent } = await getPolicyScope(target));
|
||||
} catch {
|
||||
problem = "Не удалось загрузить правило";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
await loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{#if loading}
|
||||
<div class="center"><Spinner /></div>
|
||||
{:else}
|
||||
{#if hint}
|
||||
<p class="hint">{hint}</p>
|
||||
{/if}
|
||||
|
||||
<div class="presets">
|
||||
{#each PRESETS as item (item.label)}
|
||||
<button
|
||||
aria-pressed={shown !== null && sameToggles(shown, item.toggles)}
|
||||
class="preset"
|
||||
disabled={saving}
|
||||
onclick={() => save(item.toggles)}
|
||||
type="button"
|
||||
class:on={shown !== null && sameToggles(shown, item.toggles)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if shown}
|
||||
<div class="toggles">
|
||||
{#each TOGGLE_KEYS as key (key)}
|
||||
<button
|
||||
aria-checked={shown[key]}
|
||||
class="toggle"
|
||||
disabled={saving}
|
||||
onclick={() => flip(key)}
|
||||
role="switch"
|
||||
type="button"
|
||||
class:changed={differs(key)}
|
||||
>
|
||||
<span class="label">{TOGGLE_LABELS[key]}</span>
|
||||
<span class="switch" class:on={shown[key]}>
|
||||
<span class="knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if problem}
|
||||
<p class="problem">{problem}</p>
|
||||
{/if}
|
||||
|
||||
{#if moved && moved.files > 0}
|
||||
<div class="files">
|
||||
<div class="files-text">
|
||||
<span>{filesLabel(moved.files)} в корзине</span>
|
||||
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
||||
</div>
|
||||
<Button disabled={moving} onclick={undoMove} smaller variant="text">
|
||||
<Icon name="undo" size="1.25rem" />
|
||||
<span>Вернуть</span>
|
||||
</Button>
|
||||
</div>
|
||||
{:else if files && files.files > 0}
|
||||
<div class="files">
|
||||
{#if confirming}
|
||||
<div class="files-text">
|
||||
<span>
|
||||
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в
|
||||
корзину и перестанут скачиваться сами.
|
||||
</span>
|
||||
{#if files.gone > 0}
|
||||
<small class="warn">
|
||||
{files.gone}
|
||||
из них потом не скачать заново
|
||||
</small>
|
||||
{/if}
|
||||
<div class="files-actions">
|
||||
<Button
|
||||
onclick={() => {
|
||||
confirming = false;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={moving}
|
||||
loading={moving}
|
||||
onclick={moveFiles}
|
||||
pill
|
||||
smaller
|
||||
>
|
||||
<Icon name="delete" size="1.25rem" />
|
||||
<span>В корзину</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="files-text">
|
||||
<span>{formatBytes(files.bytes)} уже скачано</span>
|
||||
<small>{filesLabel(files.files)} {where(files)}</small>
|
||||
{#if files.kept > 0}
|
||||
<small>
|
||||
Ещё {filesLabel(files.kept)} есть и в других чатах, они
|
||||
останутся
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
confirming = true;
|
||||
}}
|
||||
pill
|
||||
smaller
|
||||
variant="gray"
|
||||
>
|
||||
<Icon name="delete" size="1.25rem" />
|
||||
<span>В корзину</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rule && (isChat || isFolder)}
|
||||
{#if removing}
|
||||
<div class="removal">
|
||||
<span>Удалить правило?</span>
|
||||
<Button
|
||||
onclick={() => {
|
||||
removing = false;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onclick={remove}
|
||||
pill
|
||||
smaller
|
||||
variant="danger"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="remove"
|
||||
onclick={() => {
|
||||
removing = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Удалить правило
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
margin: 0.5rem 1rem 0.25rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.preset {
|
||||
cursor: pointer;
|
||||
|
||||
flex: 1;
|
||||
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
background: none;
|
||||
|
||||
transition:
|
||||
background-color 0.15s ease-out,
|
||||
color 0.15s ease-out;
|
||||
|
||||
&.on {
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toggles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
min-width: 0;
|
||||
height: 2.75rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&.changed .label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:not(:disabled):hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
width: 2.125rem;
|
||||
height: 1.25rem;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
background-color: var(--color-borders-input);
|
||||
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&.on {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-white);
|
||||
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
.on & {
|
||||
transform: translateX(0.875rem);
|
||||
}
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
margin: 0.5rem 1rem 0.25rem;
|
||||
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
background-color: var(--color-background-secondary);
|
||||
|
||||
:global(.Button) {
|
||||
gap: 0.375rem;
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.files-text {
|
||||
display: flex;
|
||||
flex: 1 1 10rem;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
small {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.warn {
|
||||
color: var(--color-storage-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.removal {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
margin: 0.25rem 0.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
|
||||
display: block;
|
||||
|
||||
margin: 0.25rem 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-error);
|
||||
|
||||
background: none;
|
||||
|
||||
@media (pointer: coarse) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-error);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "bits-ui";
|
||||
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
|
||||
const target = $derived(policyUi.target);
|
||||
const isChat = $derived(target !== null && "chatId" in target);
|
||||
const scopeId = $derived.by(() => {
|
||||
if (target === null) {
|
||||
return null;
|
||||
}
|
||||
return "chatId" in target ? target.chatId : target.folderId;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={policyUi.open}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="dialog-overlay" />
|
||||
<Dialog.Content class="dialog-content">
|
||||
<header class="dialog-head">
|
||||
<div class="heading">
|
||||
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
|
||||
<span class="kind">
|
||||
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
|
||||
</span>
|
||||
</div>
|
||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||
<Icon name="close" size="1.25rem" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div class="body custom-scroll">
|
||||
{#key target}
|
||||
<PolicyCard
|
||||
onchange={() => policyUi.touch()}
|
||||
{scopeId}
|
||||
scopeType={isChat ? "chat" : "folder"}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<style lang="scss">
|
||||
.heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow-y: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,241 +1,359 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
createPolicy,
|
||||
deletePolicy,
|
||||
getPolicyOverview,
|
||||
listFolders,
|
||||
listPolicies,
|
||||
updatePolicy,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import type {
|
||||
CaptureToggles,
|
||||
Folder,
|
||||
PolicyRecord,
|
||||
PolicyScopeType,
|
||||
} from "#lib/api/types.js";
|
||||
import CaptureToggleList from "#lib/components/policy/CaptureToggleList.svelte";
|
||||
import type { Folder, PolicyRule, PolicyScopeType } from "#lib/api/types.js";
|
||||
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import {
|
||||
type Chip,
|
||||
contentChips,
|
||||
DEFAULT_LABELS,
|
||||
diffChips,
|
||||
sameToggles,
|
||||
summary,
|
||||
} from "#lib/format/policy.js";
|
||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
|
||||
let policies = $state<PolicyRecord[]>([]);
|
||||
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 loading = $state(true);
|
||||
let pickFolder = $state(false);
|
||||
let pickChat = $state(false);
|
||||
let failed = $state(false);
|
||||
let problem = $state<string | null>(null);
|
||||
let expanded = $state<string | null>(null);
|
||||
let draft = $state<Draft | null>(null);
|
||||
let picking = $state<"folder" | "chat" | null>(null);
|
||||
let chatFilter = $state("");
|
||||
|
||||
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
||||
{ 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 picker = createChatPicker();
|
||||
|
||||
const defaults = $derived(
|
||||
DEFAULT_SCOPES.map((d) => ({
|
||||
label: d.label,
|
||||
record: policies.find((p) => p.scope_type === d.scope),
|
||||
})).filter((d): d is { label: string; record: PolicyRecord } =>
|
||||
Boolean(d.record)
|
||||
)
|
||||
DEFAULT_ORDER.map((scope) =>
|
||||
rules.find((rule) => rule.scope_type === scope)
|
||||
).filter((rule): rule is PolicyRule => rule !== undefined)
|
||||
);
|
||||
const folderPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "folder")
|
||||
const folderRules = $derived(
|
||||
rules
|
||||
.filter((rule) => rule.scope_type === "folder")
|
||||
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
|
||||
);
|
||||
const chatPolicies = $derived(
|
||||
policies.filter((p) => p.scope_type === "chat")
|
||||
const chatRules = $derived(
|
||||
rules
|
||||
.filter((rule) => rule.scope_type === "chat")
|
||||
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
|
||||
);
|
||||
const availableFolders = $derived(
|
||||
const freeFolders = $derived(
|
||||
folders.filter(
|
||||
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
|
||||
(folder) =>
|
||||
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
|
||||
)
|
||||
);
|
||||
const picker = createChatPicker();
|
||||
const availableChats = $derived(
|
||||
const freeChats = $derived(
|
||||
picker.results.filter(
|
||||
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
|
||||
(chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
|
||||
)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
picker.search(chatFilter);
|
||||
});
|
||||
function folderOrder(id: number | null): number {
|
||||
return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
chats.ensureMany(
|
||||
chatPolicies
|
||||
.map((policy) => policy.scope_id)
|
||||
.filter((id): id is number => id !== null)
|
||||
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
|
||||
return `${scopeType}:${scopeId}`;
|
||||
}
|
||||
|
||||
function title(rule: PolicyRule): string {
|
||||
if (rule.scope_type === "chat") {
|
||||
return rule.title ?? `Чат ${rule.scope_id}`;
|
||||
}
|
||||
if (rule.scope_type === "folder") {
|
||||
return rule.title ?? `Папка ${rule.scope_id}`;
|
||||
}
|
||||
return DEFAULT_LABELS[rule.scope_type] ?? "";
|
||||
}
|
||||
|
||||
function redundant(rule: PolicyRule): boolean {
|
||||
return rule.parent !== null && sameToggles(rule, rule.parent.toggles);
|
||||
}
|
||||
|
||||
function chips(rule: PolicyRule): Chip[] {
|
||||
return rule.parent
|
||||
? diffChips(rule, rule.parent.toggles)
|
||||
: contentChips(rule);
|
||||
}
|
||||
|
||||
function reveal(node: HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
node.parentElement?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
||||
}
|
||||
|
||||
async function toggle(
|
||||
record: PolicyRecord,
|
||||
key: keyof CaptureToggles,
|
||||
value: boolean
|
||||
try {
|
||||
[rules, folders] = await Promise.all([
|
||||
getPolicyOverview(),
|
||||
listFolders(),
|
||||
]);
|
||||
failed = false;
|
||||
if (
|
||||
draft &&
|
||||
rules.some(
|
||||
(rule) =>
|
||||
rule.scope_type === draft?.scopeType &&
|
||||
rule.scope_id === draft?.scopeId
|
||||
)
|
||||
) {
|
||||
const next = { ...pickToggles(record), [key]: value };
|
||||
policies = policies.map((p) =>
|
||||
p.id === record.id ? { ...p, [key]: value } : p
|
||||
);
|
||||
try {
|
||||
const saved = await updatePolicy(record.id, next);
|
||||
policies = policies.map((p) => (p.id === record.id ? saved : p));
|
||||
expanded = keyOf(draft.scopeType, draft.scopeId);
|
||||
draft = null;
|
||||
}
|
||||
} catch {
|
||||
toasts.error("Не удалось сохранить политику");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function addOverride(scope: "folder" | "chat", scopeId: number) {
|
||||
try {
|
||||
const record = await createPolicy({
|
||||
...ALL_FALSE,
|
||||
scope_id: scopeId,
|
||||
scope_type: scope,
|
||||
});
|
||||
policies = [...policies, record];
|
||||
pickFolder = false;
|
||||
pickChat = false;
|
||||
chatFilter = "";
|
||||
} catch {
|
||||
toasts.error("Не удалось создать оверрайд");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOverride(record: PolicyRecord) {
|
||||
policies = policies.filter((p) => p.id !== record.id);
|
||||
try {
|
||||
await deletePolicy(record.id);
|
||||
} catch {
|
||||
toasts.error("Не удалось удалить оверрайд");
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function load(_account: number | null) {
|
||||
loading = true;
|
||||
chats.load();
|
||||
try {
|
||||
await reload();
|
||||
} catch {
|
||||
toasts.error("Не удалось загрузить политики");
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(accounts.selectedId);
|
||||
function toggle(rule: PolicyRule) {
|
||||
const key = keyOf(rule.scope_type, rule.scope_id);
|
||||
expanded = expanded === key ? null : key;
|
||||
}
|
||||
|
||||
async function drop(rule: PolicyRule) {
|
||||
problem = null;
|
||||
try {
|
||||
await deletePolicy(rule.id);
|
||||
await reload();
|
||||
} catch {
|
||||
problem = "Не удалось убрать правило";
|
||||
}
|
||||
}
|
||||
|
||||
function start(scopeType: "folder" | "chat", scopeId: number, name: string) {
|
||||
draft = { scopeId, scopeType, title: name };
|
||||
picking = null;
|
||||
chatFilter = "";
|
||||
expanded = null;
|
||||
}
|
||||
|
||||
function togglePicker(kind: "folder" | "chat") {
|
||||
picking = picking === kind ? null : kind;
|
||||
if (picking === "chat") {
|
||||
picker.search(chatFilter);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
chats.load();
|
||||
reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet card(
|
||||
record: PolicyRecord,
|
||||
title: string,
|
||||
onremove?: () => void
|
||||
{#snippet lead(
|
||||
rule: PolicyRule
|
||||
)}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">{title}</span>
|
||||
{#if record.account_id === null}
|
||||
<span class="shared">для всех аккаунтов</span>
|
||||
{#if rule.scope_type === "chat" && rule.scope_id !== null}
|
||||
<Avatar
|
||||
avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
|
||||
colorKey={rule.scope_id}
|
||||
hasAvatar={chats.byId(rule.scope_id)?.has_avatar ?? false}
|
||||
name={title(rule)}
|
||||
size={2.25}
|
||||
/>
|
||||
{:else}
|
||||
<span class="glyph">
|
||||
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
|
||||
</span>
|
||||
{/if}
|
||||
{#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
|
||||
aria-label="Удалить оверрайд"
|
||||
class="remove"
|
||||
onclick={onremove}
|
||||
aria-expanded={open}
|
||||
class="main"
|
||||
onclick={() => toggle(rule)}
|
||||
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>
|
||||
<Icon name={open ? "up" : "down"} size="1.125rem" />
|
||||
</button>
|
||||
{#if extra}
|
||||
<Button onclick={() => drop(rule)} smaller variant="text">
|
||||
Убрать
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CaptureToggleList
|
||||
onchange={(key, value) => toggle(record, key, value)}
|
||||
toggles={record}
|
||||
{#if open}
|
||||
<div class="editor" {@attach reveal}>
|
||||
<PolicyCard
|
||||
onchange={reload}
|
||||
parent={rule.parent}
|
||||
{rule}
|
||||
scopeId={rule.scope_id}
|
||||
scopeType={rule.scope_type}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet pending(
|
||||
item: Draft
|
||||
)}
|
||||
<div class="rule open">
|
||||
<div class="line">
|
||||
<div class="main static">
|
||||
{#if item.scopeType === "chat"}
|
||||
<Avatar
|
||||
avatar={{
|
||||
id: item.scopeId,
|
||||
kind: item.scopeId > 0 ? "peer" : "chat",
|
||||
}}
|
||||
colorKey={item.scopeId}
|
||||
hasAvatar={chats.byId(item.scopeId)?.has_avatar ?? false}
|
||||
name={item.title}
|
||||
size={2.25}
|
||||
/>
|
||||
{:else}
|
||||
<span class="glyph"><Icon name="folder" size="1.5rem" /></span>
|
||||
{/if}
|
||||
<span class="text">
|
||||
<span class="name">{item.title}</span>
|
||||
<span class="sub">Новое правило</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => {
|
||||
draft = null;
|
||||
}}
|
||||
smaller
|
||||
variant="text"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
<div class="editor" {@attach reveal}>
|
||||
<PolicyCard
|
||||
onchange={reload}
|
||||
scopeId={item.scopeId}
|
||||
scopeType={item.scopeType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="state"><Spinner /></div>
|
||||
{:else if failed}
|
||||
<div class="state">
|
||||
<p>Не удалось загрузить политику.</p>
|
||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="editor">
|
||||
<div class="policy">
|
||||
{#if problem}
|
||||
<p class="problem">{problem}</p>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<div class="section-title">По умолчанию</div>
|
||||
{#each defaults as item (item.record.id)}
|
||||
{@render card(item.record, item.label)}
|
||||
<div class="section-title"><h3>По умолчанию</h3></div>
|
||||
{#each defaults as rule (rule.id)}
|
||||
{@render row(rule, true)}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды папок</div>
|
||||
{#each folderPolicies as record (record.id)}
|
||||
{@render card(record, folderTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
<div class="section-title">
|
||||
<h3>Папки</h3>
|
||||
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
|
||||
</div>
|
||||
{#each folderRules as rule (rule.id)}
|
||||
{#if draft && draftBefore(rule)}
|
||||
{@render pending(draft)}
|
||||
{/if}
|
||||
{@render row(rule, false)}
|
||||
{/each}
|
||||
{#if availableFolders.length > 0}
|
||||
{#if draft && draftLast}
|
||||
{@render pending(draft)}
|
||||
{/if}
|
||||
{#if freeFolders.length > 0}
|
||||
<button
|
||||
aria-expanded={picking === "folder"}
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickFolder = !pickFolder;
|
||||
}}
|
||||
onclick={() => togglePicker("folder")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить папку</span>
|
||||
<span>Правило для папки</span>
|
||||
</button>
|
||||
{#if pickFolder}
|
||||
{#if picking === "folder"}
|
||||
<div class="picker">
|
||||
{#each availableFolders as folder (folder.folder_id)}
|
||||
{#each freeFolders as folder (folder.folder_id)}
|
||||
<button
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("folder", folder.folder_id)}
|
||||
class="pick"
|
||||
onclick={() => start("folder", folder.folder_id, folder.title)}
|
||||
type="button"
|
||||
>
|
||||
{folder.title}
|
||||
@@ -247,38 +365,50 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Оверрайды чатов</div>
|
||||
{#each chatPolicies as record (record.id)}
|
||||
{@render card(record, chatTitle(record.scope_id), () =>
|
||||
removeOverride(record)
|
||||
)}
|
||||
<div class="section-title">
|
||||
<h3>Чаты</h3>
|
||||
<span>Что меняют против папки или умолчания. Важнее папок.</span>
|
||||
</div>
|
||||
{#if draft?.scopeType === "chat"}
|
||||
{@render pending(draft)}
|
||||
{/if}
|
||||
{#each chatRules as rule (rule.id)}
|
||||
{@render row(rule, false)}
|
||||
{/each}
|
||||
<button
|
||||
aria-expanded={picking === "chat"}
|
||||
class="add"
|
||||
onclick={() => {
|
||||
pickChat = !pickChat;
|
||||
}}
|
||||
onclick={() => togglePicker("chat")}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="1.25rem" />
|
||||
<span>Добавить чат</span>
|
||||
<span>Правило для чата</span>
|
||||
</button>
|
||||
{#if pickChat}
|
||||
{#if picking === "chat"}
|
||||
<div class="picker">
|
||||
<input
|
||||
aria-label="Поиск чата"
|
||||
class="filter"
|
||||
oninput={() => picker.search(chatFilter)}
|
||||
placeholder="Поиск чата"
|
||||
type="text"
|
||||
bind:value={chatFilter}
|
||||
>
|
||||
{#each availableChats as chat (chat.chat_id)}
|
||||
{#each freeChats as chat (chat.chat_id)}
|
||||
<button
|
||||
class="pick-row"
|
||||
onclick={() => addOverride("chat", chat.chat_id)}
|
||||
class="pick"
|
||||
onclick={() =>
|
||||
start(
|
||||
"chat",
|
||||
chat.chat_id,
|
||||
chat.title ?? `Чат ${chat.chat_id}`
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="nothing">Ничего не найдено.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -289,81 +419,195 @@
|
||||
<style lang="scss">
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
padding: 2rem 1rem;
|
||||
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
.policy {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
.problem {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
|
||||
padding: 0.875rem 1rem 0.375rem;
|
||||
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
.rule.open {
|
||||
border-bottom: 1px solid var(--color-borders);
|
||||
|
||||
.card-title {
|
||||
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 {
|
||||
.line {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.1875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
height: 1.375rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
|
||||
&.plus {
|
||||
color: var(--color-policy-plus);
|
||||
background-color: var(--color-policy-plus-fill);
|
||||
}
|
||||
|
||||
&.minus {
|
||||
color: var(--color-policy-minus);
|
||||
background-color: var(--color-policy-minus-fill);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.add {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
height: 2.75rem;
|
||||
padding: 0 1rem 0 1.5rem;
|
||||
border: 0;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-primary);
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
@@ -375,10 +619,12 @@
|
||||
margin: 0.25rem 0.5rem 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: var(--border-radius-default);
|
||||
border-radius: var(--border-radius-default-small);
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--color-background);
|
||||
|
||||
&:focus {
|
||||
@@ -387,8 +633,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
.pick {
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 100%;
|
||||
@@ -396,15 +643,35 @@
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text);
|
||||
background-color: transparent;
|
||||
|
||||
background: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-chat-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nothing {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -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")];
|
||||
}
|
||||
@@ -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();
|
||||
@@ -7,6 +7,10 @@ html.theme-dark {
|
||||
--color-storage-warning: #fb8c00;
|
||||
--color-storage-trash: #6c6c6c;
|
||||
--storage-tile-mix: 90%;
|
||||
--color-policy-plus: #7fd6a4;
|
||||
--color-policy-plus-fill: #199e7029;
|
||||
--color-policy-minus: #ff8a80;
|
||||
--color-policy-minus-fill: #e5393524;
|
||||
--color-primary: #8774e1;
|
||||
--color-primary-opacity: #8378db1e;
|
||||
--color-primary-opacity-hover: #8378db40;
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
--color-storage-trash: #939799;
|
||||
--storage-tile-mix: 100%;
|
||||
|
||||
--color-policy-plus: #0d6b49;
|
||||
--color-policy-plus-fill: #12805a24;
|
||||
--color-policy-minus: #b3261e;
|
||||
--color-policy-minus-fill: #e5393524;
|
||||
|
||||
--color-yellow: #{$color-yellow};
|
||||
|
||||
--color-orange: #{$color-orange};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import ChatList from "#lib/components/ChatList.svelte";
|
||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
||||
import SearchResults from "#lib/components/search/SearchResults.svelte";
|
||||
@@ -101,6 +102,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PolicyDialog />
|
||||
|
||||
<ShareDialog
|
||||
label={shareUi.label}
|
||||
onchange={() => shareUi.touch()}
|
||||
|
||||
Reference in new issue
Block a user