Compare commits

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

No files matched your search

@@ -24,8 +24,9 @@ _MODULES = (
read_receipts, read_receipts,
transcribed, transcribed,
) )
_REGISTRY: dict[type, RawHandler] = { _REGISTRY: dict[type, list[RawHandler]] = {
update_type: module.handle for module in _MODULES for update_type in module.HANDLES update_type: [module.handle for module in _MODULES if update_type in module.HANDLES]
for update_type in {kind for module in _MODULES for kind in module.HANDLES}
} }
@@ -33,8 +34,7 @@ _REGISTRY: dict[type, RawHandler] = {
async def dispatch( async def dispatch(
client: PyroClient, update: raw.base.Update, users: dict, chats: dict client: PyroClient, update: raw.base.Update, users: dict, chats: dict
) -> None: ) -> None:
handler = _REGISTRY.get(type(update)) for handler in _REGISTRY.get(type(update), ()):
if handler is not None:
await handler(client, update, users, chats) await handler(client, update, users, chats)
@@ -1,6 +1,13 @@
from pyrogram import Client from pyrogram import Client
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid from pyrogram.errors import (
from pyrogram.types import Photo ChannelInvalid,
ChannelPrivate,
FileIdInvalid,
FileReferenceExpired,
FileReferenceInvalid,
PeerIdInvalid,
)
from pyrogram.types import ChatPhoto
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
from userbot.modules.download import download_bytes from userbot.modules.download import download_bytes
@@ -8,19 +15,20 @@ from userbot.modules.jobs.context import JobContext
from userbot.modules.jobs.registry import register from userbot.modules.jobs.registry import register
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid) STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
UNKNOWN_OWNER = (PeerIdInvalid, ChannelInvalid, ChannelPrivate)
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None: async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
chat = await client.get_chat(owner_id) try:
chat = await client.get_chat(owner_id)
except UNKNOWN_OWNER:
return None
photo = chat.photo if chat else None photo = chat.photo if chat else None
if photo and photo.big_photo_unique_id == unique_id: if photo and photo.big_photo_unique_id == unique_id:
return photo.big_file_id return photo.big_file_id
photos = client.get_chat_photos(owner_id) async for item in client.get_chat_photos(owner_id):
if photos is None: if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
return None return item.big_file_id
async for item in photos:
if isinstance(item, Photo) and item.file_unique_id == unique_id:
return item.file_id
return None return None
+17 -2
View File
@@ -1,8 +1,23 @@
import json
from typing import Any
from pyrogram.raw.core import TLObject from pyrogram.raw.core import TLObject
from pyrogram.types import Object from pyrogram.types import Contact, Object
EMPTY_JSON = "{}" EMPTY_JSON = "{}"
PHONE_KEY = "phone_number"
def _default(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, TLObject):
return TLObject.default(obj)
data = Object.default(obj)
if isinstance(obj, Contact) and isinstance(data, dict) and PHONE_KEY in data:
data[PHONE_KEY] = obj.phone_number
return data
def raw_json(obj: Object | TLObject | None) -> str: def raw_json(obj: Object | TLObject | None) -> str:
return EMPTY_JSON if obj is None else str(obj) if obj is None:
return EMPTY_JSON
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
+3
View File
@@ -22,6 +22,7 @@ from utils.read.models import (
StickerView, StickerView,
WebPageView, WebPageView,
) )
from utils.read.rich_text import rich_text
_MEDIA_KEYS = ( _MEDIA_KEYS = (
"photo", "photo",
@@ -382,6 +383,8 @@ def build_message_view(
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef] row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
) -> MessageView: ) -> MessageView:
base = _base_fields(row) base = _base_fields(row)
if base["text"] is None:
base["text"] = rich_text(raw.get("rich_message"))
via_bot = raw.get("via_bot") via_bot = raw.get("via_bot")
sticker = _sticker(raw) sticker = _sticker(raw)
try: try:
+61
View File
@@ -0,0 +1,61 @@
from typing import Any
CELL_GAP = " | "
_TEXT_KEYS = ("summary", "text", "expression", "credit")
_INLINE_KEYS = ("text", "alternative_text", "expression")
def _button(button: dict[str, Any]) -> str:
label = _inline(button.get("text"))
url = button.get("url")
return f"{label} ({url})" if isinstance(url, str) else label
def _inline(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "".join(_inline(item) for item in value)
if not isinstance(value, dict):
return ""
button = value.get("button")
if isinstance(button, dict):
return _button(button)
return next((_inline(value[key]) for key in _INLINE_KEYS if key in value), "")
def _children(block: dict[str, Any], key: str) -> list[Any]:
value = block.get(key)
return value if isinstance(value, list) else []
def _row(row: object) -> str:
cells = row if isinstance(row, list) else []
return CELL_GAP.join(
_inline(cell.get("text")) for cell in cells if isinstance(cell, dict)
)
def _block(block: object) -> list[str]:
if not isinstance(block, dict):
return []
lines = [_inline(block[key]) for key in _TEXT_KEYS if key in block]
lines += [_row(row) for row in _children(block, "cells")]
lines += [
_button(button)
for button in _children(block, "buttons")
if isinstance(button, dict)
]
for key in ("items", "blocks"):
for child in _children(block, key):
lines += _block(child)
lines += _block(block.get("caption"))
lines = [line for line in lines if line]
label = block.get("label")
if isinstance(label, str) and lines:
lines[0] = f"{label} {lines[0]}"
return lines
def rich_text(rich: object) -> str | None:
return "\n".join(_block(rich)) or None
+29 -18
View File
@@ -21,16 +21,16 @@ wheels = [
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.20.0" version = "1.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mako" }, { name = "mako" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, { url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
] ]
[[package]] [[package]]
@@ -226,6 +226,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
] ]
[[package]]
name = "certifi"
version = "2026.10.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -411,7 +420,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.143.0" version = "0.159.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -421,26 +430,26 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, { url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" },
] ]
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "4.1.0" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] }, { name = "fastmcp-slim", extra = ["client", "server"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" } sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" }, { url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
] ]
[[package]] [[package]]
name = "fastmcp-slim" name = "fastmcp-slim"
version = "4.1.0" version = "4.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mcp-types" }, { name = "mcp-types" },
@@ -451,9 +460,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" }, { url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -470,6 +479,7 @@ client = [
server = [ server = [
{ name = "authlib" }, { name = "authlib" },
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" }, { name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
{ name = "certifi" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
{ name = "griffelib" }, { name = "griffelib" },
@@ -912,14 +922,14 @@ wheels = [
[[package]] [[package]]
name = "openapi-pydantic" name = "openapi-pydantic"
version = "0.5.1" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, { url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
] ]
[[package]] [[package]]
@@ -1080,16 +1090,17 @@ wheels = [
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.15.0" version = "2.16.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, { url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
] ]
[[package]] [[package]]
@@ -80,6 +80,10 @@
} }
} }
export function refresh() {
return load();
}
onMount(load); onMount(load);
</script> </script>
@@ -89,7 +93,7 @@
<span>{filesLabel(moved.files)} в корзине</span> <span>{filesLabel(moved.files)} в корзине</span>
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small> <small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
{#if problem} {#if problem}
<small class="warn">{problem}</small> <small class="failed">{problem}</small>
{/if} {/if}
</div> </div>
<Button disabled={moving} onclick={undo} smaller variant="text"> <Button disabled={moving} onclick={undo} smaller variant="text">
@@ -102,8 +106,13 @@
{#if confirming} {#if confirming}
<div class="text"> <div class="text">
<span> <span>
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в корзину {filesLabel(files.files)}, {formatBytes(files.bytes)},
и перестанут скачиваться сами. {plural(
files.files,
"уйдёт в корзину и перестанет скачиваться сам",
"уйдут в корзину и перестанут скачиваться сами",
"уйдут в корзину и перестанут скачиваться сами"
)}.
</span> </span>
{#if files.gone > 0} {#if files.gone > 0}
<small class="warn"> <small class="warn">
@@ -112,7 +121,7 @@
</small> </small>
{/if} {/if}
{#if problem} {#if problem}
<small class="warn">{problem}</small> <small class="failed">{problem}</small>
{/if} {/if}
<div class="actions"> <div class="actions">
<Button <Button
@@ -175,6 +184,18 @@
</Button> </Button>
{/if} {/if}
</div> </div>
{:else if label && files && files.kept > 0}
<div class="files">
<div class="text">
<span>{label}</span>
<small>
{filesLabel(files.kept)}
есть и в других чатах, убрать
{plural(files.kept, "его", "их", "их")}
можно из списка ниже
</small>
</div>
</div>
{/if} {/if}
<style lang="scss"> <style lang="scss">
@@ -214,6 +235,10 @@
&.warn { &.warn {
color: var(--color-storage-warning); color: var(--color-storage-warning);
} }
&.failed {
color: var(--color-error);
}
} }
} }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { SvelteSet } from "svelte/reactivity"; import { SvelteSet } from "svelte/reactivity";
import { mediaUrl } from "#lib/api/client.js"; import { mediaUrl } from "#lib/api/client.js";
import { downloadMedia } from "#lib/api/download.js"; import { downloadMedia } from "#lib/api/download.js";
@@ -61,7 +61,7 @@
let summary = $state<StorageSummary | null>(null); let summary = $state<StorageSummary | null>(null);
let files = $state<StorageFile[]>([]); let files = $state<StorageFile[]>([]);
let heavyChats = $state<StorageChat[]>([]); let heavyChats = $state<StorageChat[]>([]);
let scopeRevision = $state(0); let scope = $state<ReturnType<typeof ScopeFiles>>();
let group = $state<StorageGroupName | null>(null); let group = $state<StorageGroupName | null>(null);
let loading = $state(false); let loading = $state(false);
let failed = $state(false); let failed = $state(false);
@@ -75,6 +75,7 @@
let viewerIndex = $state(0); let viewerIndex = $state(0);
const selected = new SvelteSet<number>(); const selected = new SvelteSet<number>();
let token = 0; let token = 0;
let scroller: HTMLElement | null = null;
let undoTimer: ReturnType<typeof setTimeout> | undefined; let undoTimer: ReturnType<typeof setTimeout> | undefined;
let undoShownAt = 0; let undoShownAt = 0;
@@ -130,10 +131,10 @@
} }
} }
async function loadPage(current: number): Promise<number> { async function loadPage(current: number, limit: number): Promise<number> {
if (listing === "chats") { if (listing === "chats") {
const batch = await getStorageChats(group, { const batch = await getStorageChats(group, {
limit: PAGE, limit,
offset: heavyChats.length, offset: heavyChats.length,
}); });
if (current === token) { if (current === token) {
@@ -144,7 +145,7 @@
} }
const batch = await getStorageFiles( const batch = await getStorageFiles(
group, group,
{ limit: PAGE, offset: files.length }, { limit, offset: files.length },
chatId chatId
); );
if (current === token) { if (current === token) {
@@ -154,7 +155,7 @@
return batch.length; return batch.length;
} }
async function loadMore() { async function loadMore(limit = PAGE) {
if (loading || done) { if (loading || done) {
return; return;
} }
@@ -162,9 +163,9 @@
loading = true; loading = true;
failed = false; failed = false;
try { try {
const loaded = await loadPage(current); const loaded = await loadPage(current, limit);
if (current === token) { if (current === token) {
done = loaded < PAGE; done = loaded < limit;
} }
} catch { } catch {
if (current === token) { if (current === token) {
@@ -177,15 +178,34 @@
} }
} }
function reload() { function reload(limit = PAGE) {
token += 1; token += 1;
files = []; files = [];
heavyChats = []; heavyChats = [];
done = false; done = false;
loading = false; loading = false;
selected.clear(); selected.clear();
loadSummary(); return Promise.all([loadSummary(), loadMore(limit)]);
loadMore(); }
async function resume() {
const from = chatId === null ? storageUi.takeResume() : null;
await reload(Math.ceil(Math.max(1, from?.count ?? 0) / PAGE) * PAGE);
if (from && scroller) {
await tick();
scroller.scrollTop = from.top;
}
}
function openChat(id: number) {
storageUi.openChat(id, {
count: heavyChats.length,
top: scroller?.scrollTop ?? 0,
});
}
function hold(node: HTMLElement) {
scroller = node.parentElement;
} }
function toggle(mediaId: number) { function toggle(mediaId: number) {
@@ -226,7 +246,7 @@
} }
files = files.filter((file) => !moved.has(file.media_id)); files = files.filter((file) => !moved.has(file.media_id));
selected.clear(); selected.clear();
scopeRevision += 1; scope?.refresh();
showUndo(outcome, trashed); showUndo(outcome, trashed);
if (outcome.blocked.length > 0) { if (outcome.blocked.length > 0) {
problem = "Файл занят в сторис или аватаре и остался на диске"; problem = "Файл занят в сторис или аватаре и остался на диске";
@@ -250,7 +270,7 @@
files = [...files, ...restored.files].sort( files = [...files, ...restored.files].sort(
(a, b) => b.file_size - a.file_size (a, b) => b.file_size - a.file_size
); );
scopeRevision += 1; scope?.refresh();
await loadSummary(); await loadSummary();
} catch { } catch {
problem = "Не удалось вернуть файлы"; problem = "Не удалось вернуть файлы";
@@ -301,16 +321,16 @@
} }
} }
onMount(reload); onMount(resume);
onDestroy(() => clearTimeout(undoTimer)); onDestroy(() => clearTimeout(undoTimer));
</script> </script>
<div class="storage"> <div class="storage" {@attach hold}>
{#if summary} {#if summary}
{#if summary.files > 0} {#if summary.files > 0 || chatId !== null}
<section class="summary"> <section class="summary">
<div class="hero"> <div class="hero">
<span class="total">{formatBytes(summary.bytes)}</span> <span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
<span class="count"> <span class="count">
{filesLabel(summary.files)} {filesLabel(summary.files)}
{#if chatId !== null} {#if chatId !== null}
@@ -322,7 +342,7 @@
{/if} {/if}
</span> </span>
</div> </div>
<div class="stack"> <div class="stack" class:bare={groups.length === 0}>
{#each groups as item (item.group)} {#each groups as item (item.group)}
<span <span
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}" title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
@@ -392,16 +412,15 @@
{/each} {/each}
</div> </div>
{:else} {:else}
{#key scopeRevision} <ScopeFiles
<ScopeFiles label="Все файлы чата"
label="Все файлы чата" onchange={() => {
onchange={() => { hideUndo();
hideUndo(); reload();
reload(); }}
}} target={{ chatId }}
target={{ chatId }} bind:this={scope}
/> />
{/key}
{/if} {/if}
{/if} {/if}
@@ -423,15 +442,12 @@
<StorageChatRow <StorageChatRow
{chat} {chat}
hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false} hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false}
onopen={() => storageUi.openChat(chat.chat_id)} onopen={() => openChat(chat.chat_id)}
title={chatTitle(chat.chat_id)} title={chatTitle(chat.chat_id)}
weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0} weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0}
> >
{#snippet menu()} {#snippet menu()}
<ContextMenuItem <ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
icon="data"
onselect={() => storageUi.openChat(chat.chat_id)}
>
Файлы чата Файлы чата
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem icon="reply" onselect={() => visit(chat)}> <ContextMenuItem icon="reply" onselect={() => visit(chat)}>
@@ -491,12 +507,14 @@
{:else if failed} {:else if failed}
<div class="state"> <div class="state">
<p>Не удалось загрузить хранилище.</p> <p>Не удалось загрузить хранилище.</p>
<Button onclick={reload} smaller variant="text">Повторить</Button> <Button onclick={() => reload()} smaller variant="text">
Повторить
</Button>
</div> </div>
{:else if shown === 0} {:else if shown === 0}
<div class="state"><p>{emptiness}</p></div> <div class="state"><p>{emptiness}</p></div>
{:else if !done} {:else if !done}
<button class="more" onclick={loadMore} type="button"> <button class="more" onclick={() => loadMore()} type="button">
Показать ещё Показать ещё
</button> </button>
{/if} {/if}
@@ -599,6 +617,11 @@
height: 0.625rem; height: 0.625rem;
margin-top: 0.875rem; margin-top: 0.875rem;
&.bare {
border-radius: 0.3125rem;
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
}
span { span {
min-width: 0.1875rem; min-width: 0.1875rem;
border-radius: 2px; border-radius: 2px;
+13 -1
View File
@@ -2,9 +2,15 @@ import { ui } from "#lib/stores/ui.svelte.js";
export type StorageListing = "files" | "chats"; export type StorageListing = "files" | "chats";
interface Resume {
count: number;
top: number;
}
function createStorageUi() { function createStorageUi() {
let listing = $state<StorageListing>("files"); let listing = $state<StorageListing>("files");
let chatId = $state<number | null>(null); let chatId = $state<number | null>(null);
let resume: Resume | null = null;
return { return {
get chatId() { get chatId() {
@@ -16,10 +22,16 @@ function createStorageUi() {
set listing(next: StorageListing) { set listing(next: StorageListing) {
listing = next; listing = next;
}, },
openChat(id: number) { openChat(id: number, from: Resume) {
chatId = id; chatId = id;
resume = from;
ui.openPanel("storage-chat"); ui.openPanel("storage-chat");
}, },
takeResume(): Resume | null {
const taken = resume;
resume = null;
return taken;
},
}; };
} }