diff --git a/TODO.md b/TODO.md index 9e789e20..ad907ca0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,5 @@ - Media + - SVG uploads - Uploading progress bar (+local echo) - Directly create cache files for our uploads before actually uploading - Downloading diff --git a/src/python/backend.py b/src/python/backend.py index 7ed4b776..284db60a 100644 --- a/src/python/backend.py +++ b/src/python/backend.py @@ -9,7 +9,7 @@ import nio from .app import App from .matrix_client import MatrixClient -from .models.items import Account, Device, Event, Member, Room +from .models.items import Account, Device, Event, Member, Room, Upload from .models.model_store import ModelStore ProfileResponse = Union[nio.ProfileGetResponse, nio.ProfileGetError] @@ -29,6 +29,7 @@ class Backend: (Device, str), # Devices of user_id (Room, str), # Rooms for user_id (Member, str), # Members in room_id + (Upload, str), # Uploads running in room_id (Event, str, str), # Events for account user_id for room_id }) diff --git a/src/python/matrix_client.py b/src/python/matrix_client.py index db88f28d..90b2251e 100644 --- a/src/python/matrix_client.py +++ b/src/python/matrix_client.py @@ -25,7 +25,9 @@ import nio from . import __about__, utils from .html_filter import HTML_FILTER -from .models.items import Account, Event, Member, Room, TypeSpecifier +from .models.items import ( + Account, Event, Member, Room, TypeSpecifier, Upload, UploadStatus, +) from .models.model_store import ModelStore from .pyotherside_events import AlertRequested @@ -224,9 +226,15 @@ class MatrixClient(nio.AsyncClient): async def send_file(self, room_id: str, path: Union[Path, str]) -> None: path = Path(path) + size = path.resolve().stat().st_size encrypt = room_id in self.encrypted_rooms - url, mime, crypt_dict = await self.upload_file(path, encrypt=encrypt) + upload_item = Upload(str(path), total_size=size) + self.models[Upload, room_id][upload_item.uuid] = upload_item + + url, mime, crypt_dict = await self.upload_file( + path, upload_item, encrypt=encrypt, + ) kind = (mime or "").split("/")[0] @@ -234,7 +242,7 @@ class MatrixClient(nio.AsyncClient): "body": path.name, "info": { "mimetype": mime, - "size": path.resolve().stat().st_size, + "size": size, }, } @@ -254,7 +262,9 @@ class MatrixClient(nio.AsyncClient): try: thumb_url, thumb_info, thumb_crypt_dict = \ - await self.upload_thumbnail(path, encrypt=encrypt) + await self.upload_thumbnail( + path, upload_item, encrypt=encrypt, + ) except (UneededThumbnail, UnthumbnailableError): pass else: @@ -302,6 +312,9 @@ class MatrixClient(nio.AsyncClient): content["msgtype"] = "m.file" content["filename"] = path.name + upload_item.status = UploadStatus.Success + del self.models[Upload, room_id] + uuid = str(uuid4()) await self._local_echo( @@ -435,7 +448,10 @@ class MatrixClient(nio.AsyncClient): async def upload_thumbnail( - self, path: Union[Path, str], encrypt: bool = False, + self, + path: Union[Path, str], + item: Optional[Upload] = None, + encrypt: bool = False, ) -> Tuple[str, Dict[str, Any], CryptDict]: png_modes = ("1", "L", "P", "RGBA") @@ -450,6 +466,9 @@ class MatrixClient(nio.AsyncClient): if small and is_jpg_png and not jpgable_png: raise UneededThumbnail() + if item: + item.status = UploadStatus.CreatingThumbnail + if not small: thumb.thumbnail((800, 600), PILImage.LANCZOS) @@ -464,11 +483,17 @@ class MatrixClient(nio.AsyncClient): data = out.getvalue() if encrypt: + if item: + item.status = UploadStatus.EncryptingThumbnail + data, crypt_dict = await self.encrypt_attachment(data) upload_mime = "application/octet-stream" else: crypt_dict, upload_mime = {}, mime + if item: + item.status = UploadStatus.UploadingThumbnail + return ( await self.upload(data, upload_mime, Path(path).name), { @@ -485,8 +510,13 @@ class MatrixClient(nio.AsyncClient): raise UnthumbnailableError(err) - async def upload_file(self, path: Union[Path, str], encrypt: bool = False, - ) -> Tuple[str, str, CryptDict]: + async def upload_file( + self, + path: Union[Path, str], + item: Optional[Upload] = None, + encrypt: bool = False, + ) -> Tuple[str, str, CryptDict]: + with open(path, "rb") as file: mime = utils.guess_mime(file) file.seek(0, 0) @@ -494,11 +524,17 @@ class MatrixClient(nio.AsyncClient): data: Union[BinaryIO, bytes] if encrypt: + if item: + item.status = UploadStatus.Encrypting + data, crypt_dict = await self.encrypt_attachment(file.read()) upload_mime = "application/octet-stream" else: data, crypt_dict, upload_mime = file, {}, mime + if item: + item.status = UploadStatus.Uploading + return ( await self.upload(data, upload_mime, Path(path).name), mime, diff --git a/src/python/models/items.py b/src/python/models/items.py index 3a299b6a..58545170 100644 --- a/src/python/models/items.py +++ b/src/python/models/items.py @@ -1,7 +1,9 @@ import re from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from uuid import uuid4 import lxml # nosec @@ -101,6 +103,38 @@ class Member(ModelItem): return self.display_name +class UploadStatus(AutoStrEnum): + Starting = auto() + Encrypting = auto() + Uploading = auto() + CreatingThumbnail = auto() + EncryptingThumbnail = auto() + UploadingThumbnail = auto() + Success = auto() + Failure = auto() # TODO + + +@dataclass +class Upload(ModelItem): + filepath: str = field() + status: UploadStatus = UploadStatus.Starting + total_size: int = 0 + uploaded: int = 0 + + uuid: str = field(init=False, default_factory=lambda: str(uuid4())) + start_date: datetime = field(init=False, default_factory=datetime.now) + + + def __post_init__(self) -> None: + if not self.total_size: + self.total_size = Path(self.filepath).resolve().stat().st_size + + + def __lt__(self, other: "Upload") -> bool: + # TODO + return self.start_date > other.start_date + + class TypeSpecifier(AutoStrEnum): none = auto() profile_change = auto() diff --git a/src/qml/Chat/ChatSplitView.qml b/src/qml/Chat/ChatSplitView.qml index 68bb9f61..be73537e 100644 --- a/src/qml/Chat/ChatSplitView.qml +++ b/src/qml/Chat/ChatSplitView.qml @@ -25,6 +25,10 @@ HSplitView { Layout.fillWidth: true } + UploadsBar { + Layout.fillWidth: true + } + InviteBanner { id: inviteBanner visible: ! chatPage.roomInfo.left && inviterId diff --git a/src/qml/Chat/UploadsBar.qml b/src/qml/Chat/UploadsBar.qml new file mode 100644 index 00000000..85770d4f --- /dev/null +++ b/src/qml/Chat/UploadsBar.qml @@ -0,0 +1,105 @@ +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import "../Base" +import "../utils.js" as Utils + +Rectangle { + id: uploadsBar + implicitWidth: 800 + implicitHeight: firstDelegate ? firstDelegate.height : 0 + color: theme.chat.typingMembers.background + opacity: implicitHeight ? 1 : 0 + + + property int delegateHeight: 0 + property int maxShownDelegates: 1 + + readonly property var firstDelegate: + uploadsList.contentItem.visibleChildren[0] + + + Behavior on implicitHeight { HNumberAnimation {} } + + HListView { + id: uploadsList + enableFlicking: false + width: parent.width + + model: HListModel { + keyField: "uuid" + source: modelSources[["Upload", chatPage.roomId]] || [] + } + + delegate: HColumnLayout { + id: delegate + width: uploadsList.width + Component.onCompleted: Utils.debug(delegate) + + HRowLayout { + HLabel { + id: filenameLabel + elide: Text.ElideRight + text: + model.status === "Starting" ? + qsTr("Preparing %1...").arg(fileName) : + + model.status === "Encrypting" ? + qsTr("Encrypting %1...").arg(fileName) : + + model.status === "Uploading" ? + qsTr("Uploading %1...").arg(fileName) : + + model.status === "CreatingThumbnail" ? + qsTr("Generating thumbnail for %1...").arg(fileName) : + + model.status === "EncryptingThumbnail" ? + qsTr("Encrypting thumbnail for %1...").arg(fileName) : + + model.status === "UploadingThumbnail" ? + qsTr("Uploading thumbnail for %1...").arg(fileName) : + + model.status === "Failure" ? + qsTr("Failed uploading %1.").arg(fileName) : + + qsTr("Invalid status for %1: %2") + .arg(fileName, model.status) + + topPadding: theme.spacing / 2 + bottomPadding: topPadding + leftPadding: theme.spacing / 1.5 + rightPadding: leftPadding + + Layout.fillWidth: true + + readonly property string fileName: + model.filepath.split("/").slice(-1)[0] + } + + HSpacer {} + + HLabel { + id: uploadCountLabel + visible: Layout.preferredWidth > 0 + text: qsTr("%1/%2") + .arg(model.index + 1).arg(uploadsList.model.count) + + topPadding: theme.spacing / 2 + bottomPadding: topPadding + leftPadding: theme.spacing / 1.5 + rightPadding: leftPadding + + Layout.preferredWidth: + uploadsList.model.count < 2 ? 0 : implicitWidth + + Behavior on Layout.preferredWidth { HNumberAnimation {} } + } + } + + HProgressBar { + id: progressBar + + Layout.fillWidth: true + } + } + } +}