Compare commits

..
3 Commits
25 changed files with 2038 additions and 373 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
+46 -3
View File
@@ -14,6 +14,7 @@ from utils.read.models import (
DEFAULT_LIMIT, DEFAULT_LIMIT,
Page, Page,
StorageFileView, StorageFileView,
StorageScopeView,
StorageSummaryView, StorageSummaryView,
TrashItemView, TrashItemView,
TrashOutcome, TrashOutcome,
@@ -30,7 +31,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):
@@ -57,11 +64,47 @@ async def files(
return await storage_read.list_files(pool, account_id, group, page) 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") @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")
+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:
+8
View File
@@ -287,6 +287,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
+88 -5
View File
@@ -4,10 +4,13 @@ from collections import defaultdict
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,
StorageFileView, StorageFileView,
StorageGroupView, StorageGroupView,
StorageScopeView,
StorageSummaryView, StorageSummaryView,
TrashItemView, TrashItemView,
) )
@@ -71,6 +74,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 = """
@@ -179,20 +222,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 +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: 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():
+41 -3
View File
@@ -26,13 +26,17 @@ import type {
PolicyChatKind, PolicyChatKind,
PolicyCreate, PolicyCreate,
PolicyRecord, PolicyRecord,
PolicyRule,
PolicyScope,
PresenceHourly, PresenceHourly,
PresenceSample, PresenceSample,
ReactionView, ReactionView,
ResponseStats, ResponseStats,
ScopeTarget,
SearchHit, SearchHit,
StorageFile, StorageFile,
StorageGroupName, StorageGroupName,
StorageScope,
StorageSummary, StorageSummary,
StoryView, StoryView,
TrashItem, TrashItem,
@@ -148,6 +152,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;
@@ -601,8 +622,25 @@ export function getStorageFiles(
}); });
} }
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 +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", { return request<TrashOutcome>("/storage/trash/restore", {
body: { account_id: accounts.selectedId, keys }, body: { account_id: accounts.selectedId, keys },
method: "POST", method: "POST",
+28
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 {
@@ -613,3 +631,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
@@ -19,6 +19,7 @@
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 { 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";
const titles: Record<RightPanel, string> = { const titles: Record<RightPanel, string> = {
@@ -78,7 +79,9 @@
{#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"}
@@ -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,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"> <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) {
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() { 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>
<Icon name={open ? "up" : "down"} size="1.125rem" />
</button> </button>
{#if extra}
<Button onclick={() => drop(rule)} smaller variant="text">
Убрать
</Button>
{/if} {/if}
</div> </div>
<CaptureToggleList {#if open}
onchange={(key, value) => toggle(record, key, value)} <div class="editor" {@attach reveal}>
toggles={record} <PolicyCard
onchange={reload}
parent={rule.parent}
{rule}
scopeId={rule.scope_id}
scopeType={rule.scope_type}
/> />
</div> </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} {/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 +365,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 +419,195 @@
<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.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 { .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;
@@ -375,10 +619,12 @@
margin: 0.25rem 0.5rem 0.5rem; margin: 0.25rem 0.5rem 0.5rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-borders); 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; 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 +633,9 @@
} }
} }
.pick-row { .pick {
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
@@ -396,15 +643,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();
+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()}