From 9990fecc742875432a6e5c89c73cf4e083a4d7a8 Mon Sep 17 00:00:00 2001 From: miruka Date: Mon, 2 Dec 2019 16:29:29 -0400 Subject: [PATCH] Begin yet another model refactor Use native ListModel which require a lot of changes, but should be much faster than the old way which exponentially slowed down to a crawl. Also fix some popup bugs (leave/forget). Not working yet: side pane keyboard controls, proper highlight, room & member filtering, local echo replacement --- .gitmodules | 3 + TODO.md | 6 + harmonyqml.pro | 1 + src/backend/backend.py | 55 ++++--- src/backend/matrix_client.py | 130 +++++++--------- src/backend/models/__init__.py | 7 +- src/backend/models/items.py | 140 ++++++++--------- src/backend/models/model.py | 107 +++++-------- src/backend/models/model_item.py | 24 ++- src/backend/nio_callbacks.py | 21 ++- src/backend/pyotherside_events.py | 46 ++++-- src/backend/qml_bridge.py | 2 - src/backend/utils.py | 13 +- src/gui/Base/HAccordionView.qml | 81 ++++++++++ src/gui/Base/HListModel.qml | 30 ---- src/gui/Base/HListView.qml | 11 +- src/gui/Base/HSortFilterProxy.qml | 10 ++ src/gui/Base/HTileDelegate.qml | 15 -- .../{AccountDelegate.qml => Account.qml} | 93 +++++------- src/gui/MainPane/AccountRoomList.qml | 143 ------------------ src/gui/MainPane/AccountRoomsDelegate.qml | 66 ++++++++ src/gui/MainPane/AccountRoomsList.qml | 88 +++++++++++ src/gui/MainPane/MainPane.qml | 2 +- src/gui/MainPane/MainPaneToolBar.qml | 2 +- .../MainPane/{RoomDelegate.qml => Room.qml} | 122 +++++++-------- src/gui/ModelStore.qml | 37 +++++ .../Pages/AccountSettings/AccountSettings.qml | 18 +-- src/gui/Pages/AddChat/AddChat.qml | 4 +- src/gui/Pages/Chat/Chat.qml | 14 +- src/gui/Pages/Chat/Composer.qml | 31 ++-- .../Pages/Chat/FileTransfer/TransferList.qml | 7 +- .../Pages/Chat/RoomPane/MemberDelegate.qml | 10 +- src/gui/Pages/Chat/RoomPane/MemberView.qml | 47 +++--- src/gui/Pages/Chat/Timeline/EventContent.qml | 2 +- src/gui/Pages/Chat/Timeline/EventDelegate.qml | 10 +- src/gui/Pages/Chat/Timeline/EventList.qml | 13 +- src/gui/Pages/Chat/TypingMembersBar.qml | 2 +- src/gui/Popups/ForgetRoomPopup.qml | 4 +- src/gui/Popups/SignOutPopup.qml | 10 +- .../{ => Privates}/EventHandlers.qml | 39 +++-- src/gui/PythonBridge/Privates/Globals.qml | 8 + src/gui/PythonBridge/Privates/qmldir | 1 + src/gui/PythonBridge/PythonBridge.qml | 38 +---- src/gui/PythonBridge/PythonRootBridge.qml | 33 ++++ src/gui/UI.qml | 3 +- src/gui/Utils.qml | 51 +++---- src/gui/Window.qml | 5 +- src/gui/qmldir | 1 + submodules/gel | 1 + 49 files changed, 826 insertions(+), 781 deletions(-) create mode 100644 src/gui/Base/HAccordionView.qml delete mode 100644 src/gui/Base/HListModel.qml create mode 100644 src/gui/Base/HSortFilterProxy.qml rename src/gui/MainPane/{AccountDelegate.qml => Account.qml} (62%) delete mode 100644 src/gui/MainPane/AccountRoomList.qml create mode 100644 src/gui/MainPane/AccountRoomsDelegate.qml create mode 100644 src/gui/MainPane/AccountRoomsList.qml rename src/gui/MainPane/{RoomDelegate.qml => Room.qml} (54%) create mode 100644 src/gui/ModelStore.qml rename src/gui/PythonBridge/{ => Privates}/EventHandlers.qml (66%) create mode 100644 src/gui/PythonBridge/Privates/Globals.qml create mode 100644 src/gui/PythonBridge/Privates/qmldir create mode 100644 src/gui/PythonBridge/PythonRootBridge.qml create mode 100644 src/gui/qmldir create mode 160000 submodules/gel diff --git a/.gitmodules b/.gitmodules index 1ebe1231..26774e64 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "submodules/hsluv-c"] path = submodules/hsluv-c url = https://github.com/hsluv/hsluv-c +[submodule "submodules/gel"] + path = submodules/gel + url = https://github.com/Cutehacks/gel diff --git a/TODO.md b/TODO.md index b27d1630..64c7f7c2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,15 @@ # TODO +- room last event date previous year show month if <3 month +- when inviting members, prevent if user id is on another server and room + doesn't allow that +- "exception during sync" aren't caught + ## Media - nio ClientTimeout +- no thumb if bigger than original - upload delay at the end? - Handle upload file size limit - Handle set avatar upload errors diff --git a/harmonyqml.pro b/harmonyqml.pro index afd51bae..9eee5841 100644 --- a/harmonyqml.pro +++ b/harmonyqml.pro @@ -44,6 +44,7 @@ executables.files = $$TARGET # Libraries includes include(submodules/qsyncable/qsyncable.pri) +include(submodules/gel/com_cutehacks_gel.pri) # Custom functions diff --git a/src/backend/backend.py b/src/backend/backend.py index 94e81a87..d881e51e 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -5,16 +5,16 @@ import logging as log import sys import traceback from pathlib import Path -from typing import Any, DefaultDict, Dict, List, Optional, Tuple - -from appdirs import AppDirs +from typing import Any, DefaultDict, Dict, List, Optional import nio +from appdirs import AppDirs from . import __app_name__ from .errors import MatrixError from .matrix_client import MatrixClient -from .models.items import Account, Room +from .models import SyncId +from .models.items import Account from .models.model_store import ModelStore # Logging configuration @@ -116,8 +116,8 @@ class Backend: await client.close() raise - self.clients[client.user_id] = client - self.models[Account][client.user_id] = Account(client.user_id) + self.clients[client.user_id] = client + self.models["accounts"][client.user_id] = Account(client.user_id) return client.user_id @@ -133,13 +133,13 @@ class Backend: user=user_id, homeserver=homeserver, device_id=device_id, ) - self.clients[user_id] = client - self.models[Account][user_id] = Account(user_id) + self.clients[user_id] = client + self.models["accounts"][user_id] = Account(user_id) await client.resume(user_id=user_id, token=token, device_id=device_id) - async def load_saved_accounts(self) -> Tuple[str, ...]: + async def load_saved_accounts(self) -> List[str]: """Call `resume_client` for all saved accounts in user config.""" async def resume(user_id: str, info: Dict[str, str]) -> str: @@ -162,7 +162,7 @@ class Backend: client = self.clients.pop(user_id, None) if client: - self.models[Account].pop(user_id, None) + self.models["accounts"].pop(user_id, None) await client.logout() await self.saved_accounts.delete(user_id) @@ -256,25 +256,24 @@ class Backend: return (settings, ui_state, history, theme) - async def get_flat_mainpane_data(self) -> List[Dict[str, Any]]: - """Return a flat list of accounts and their joined rooms for QML.""" + async def await_model_item( + self, model_id: SyncId, item_id: Any, + ) -> Dict[str, Any]: - data = [] + if isinstance(model_id, list): # when called from QML + model_id = tuple(model_id) - for account in sorted(self.models[Account].values()): - data.append({ - "type": "Account", - "id": account.user_id, - "user_id": account.user_id, - "data": account.serialized, - }) + failures = 0 - for room in sorted(self.models[Room, account.user_id].values()): - data.append({ - "type": "Room", - "id": "/".join((account.user_id, room.room_id)), - "user_id": account.user_id, - "data": room.serialized, - }) + while True: + try: + return self.models[model_id][item_id].serialized + except KeyError: + if failures and failures % 300 == 0: + log.warn( + "Item %r not found in model %r after %ds", + item_id, model_id, failures / 10, + ) - return data + await asyncio.sleep(0.1) + failures += 1 diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index aa865091..345e7146 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -35,7 +35,7 @@ from .errors import ( ) from .html_markdown import HTML_PROCESSOR as HTML from .models.items import ( - Account, Event, Member, Room, TypeSpecifier, Upload, UploadStatus, + Event, Member, Room, TypeSpecifier, Upload, UploadStatus, ) from .models.model_store import ModelStore from .pyotherside_events import AlertRequested @@ -211,7 +211,7 @@ class MatrixClient(nio.AsyncClient): return resp = future.result() - account = self.models[Account][self.user_id] + account = self.models["accounts"][self.user_id] account.profile_updated = datetime.now() account.display_name = resp.displayname or "" account.avatar_url = resp.avatar_url or "" @@ -284,7 +284,7 @@ class MatrixClient(nio.AsyncClient): await self._send_file(item_uuid, room_id, path) except (nio.TransferCancelledError, asyncio.CancelledError): log.info("Deleting item for cancelled upload %s", item_uuid) - del self.models[Upload, room_id][str(item_uuid)] + del self.models[room_id, "uploads"][str(item_uuid)] async def _send_file( @@ -310,7 +310,7 @@ class MatrixClient(nio.AsyncClient): task = asyncio.Task.current_task() monitor = nio.TransferMonitor(size) upload_item = Upload(item_uuid, task, monitor, path, total_size=size) - self.models[Upload, room_id][str(item_uuid)] = upload_item + self.models[room_id, "uploads"][str(item_uuid)] = upload_item def on_transferred(transferred: int) -> None: upload_item.uploaded = transferred @@ -452,7 +452,7 @@ class MatrixClient(nio.AsyncClient): content["msgtype"] = "m.file" content["filename"] = path.name - del self.models[Upload, room_id][str(upload_item.uuid)] + del self.models[room_id, "uploads"][str(upload_item.id)] await self._local_echo( room_id, @@ -499,12 +499,12 @@ class MatrixClient(nio.AsyncClient): replace the local one we registered. """ - our_info = self.models[Member, self.user_id, room_id][self.user_id] + our_info = self.models[self.user_id, room_id, "members"][self.user_id] event = Event( - source = None, - client_id = f"echo-{transaction_id}", + id = f"echo-{transaction_id}", event_id = "", + source = None, date = datetime.now(), sender_id = self.user_id, sender_name = our_info.display_name, @@ -514,13 +514,10 @@ class MatrixClient(nio.AsyncClient): **event_fields, ) - for user_id in self.models[Account]: - if user_id in self.models[Member, self.user_id, room_id]: + for user_id in self.models["accounts"]: + if user_id in self.models[self.user_id, room_id, "members"]: key = f"echo-{transaction_id}" - self.models[Event, user_id, room_id][key] = event - - if user_id == self.user_id: - self.models[Event, user_id, room_id].sync_now() + self.models[user_id, room_id, "events"][key] = event await self.set_room_last_event(room_id, event) @@ -583,7 +580,7 @@ class MatrixClient(nio.AsyncClient): async def load_rooms_without_visible_events(self) -> None: """Call `_load_room_without_visible_events` for all joined rooms.""" - for room_id in self.models[Room, self.user_id]: + for room_id in self.models[self.user_id, "rooms"]: asyncio.ensure_future( self._load_room_without_visible_events(room_id), ) @@ -604,7 +601,7 @@ class MatrixClient(nio.AsyncClient): to show or there is nothing left to load. """ - events = self.models[Event, self.user_id, room_id] + events = self.models[self.user_id, room_id, "events"] more = True while self.skipped_events[room_id] and not events and more: @@ -685,11 +682,16 @@ class MatrixClient(nio.AsyncClient): will be marked as suitable for destruction by the server. """ - await super().room_leave(room_id) + self.models[self.user_id, "rooms"].pop(room_id, None) + self.models.pop((self.user_id, room_id, "events"), None) + self.models.pop((self.user_id, room_id, "members"), None) + + try: + await super().room_leave(room_id) + except MatrixNotFound: # already left + pass + await super().room_forget(room_id) - self.models[Room, self.user_id].pop(room_id, None) - self.models.pop((Event, self.user_id, room_id), None) - self.models.pop((Member, self.user_id, room_id), None) async def room_mass_invite( @@ -843,7 +845,8 @@ class MatrixClient(nio.AsyncClient): for sync_id, model in self.models.items(): if not (isinstance(sync_id, tuple) and - sync_id[0:2] == (Event, self.user_id)): + sync_id[0] == self.user_id and + sync_id[2] == "events"): continue _, _, room_id = sync_id @@ -873,10 +876,9 @@ class MatrixClient(nio.AsyncClient): """ self.cleared_events_rooms.add(room_id) - model = self.models[Event, self.user_id, room_id] + model = self.models[self.user_id, room_id, "events"] if model: model.clear() - model.sync_now() # Functions to register data into models @@ -900,37 +902,10 @@ class MatrixClient(nio.AsyncClient): The `last_event` is notably displayed in the UI room subtitles. """ - model = self.models[Room, self.user_id] - room = model[room_id] + room = self.models[self.user_id, "rooms"][room_id] - if room.last_event is None: - room.last_event = item - - if item.is_local_echo: - model.sync_now() - - return - - is_profile_ev = item.type_specifier == TypeSpecifier.profile_change - - # If there were no better events available to show previously - prev_is_profile_ev = \ - room.last_event.type_specifier == TypeSpecifier.profile_change - - # If this is a profile event, only replace the currently shown one if - # it was also a profile event (we had nothing better to show). - if is_profile_ev and not prev_is_profile_ev: - return - - # If this event is older than the currently shown one, only replace - # it if the previous was a profile event. - if item.date < room.last_event.date and not prev_is_profile_ev: - return - - room.last_event = item - - if item.is_local_echo: - model.sync_now() + if item.date > room.last_event_date: + room.last_event_date = item.date async def register_nio_room( @@ -939,18 +914,21 @@ class MatrixClient(nio.AsyncClient): """Register a `nio.MatrixRoom` as a `Room` object in our model.""" # Add room - try: - last_ev = self.models[Room, self.user_id][room.room_id].last_event - except KeyError: - last_ev = None - inviter = getattr(room, "inviter", "") or "" levels = room.power_levels can_send_state = partial(levels.can_user_send_state, self.user_id) can_send_msg = partial(levels.can_user_send_message, self.user_id) - self.models[Room, self.user_id][room.room_id] = Room( - room_id = room.room_id, + try: + registered = self.models[self.user_id, "rooms"][room.room_id] + last_event_date = registered.last_event_date + typing_members = registered.typing_members + except KeyError: + last_event_date = datetime.fromtimestamp(0) + typing_members = [] + + self.models[self.user_id, "rooms"][room.room_id] = Room( + id = room.room_id, given_name = room.name or "", display_name = room.display_name or "", avatar_url = room.gen_avatar_url or "", @@ -962,6 +940,8 @@ class MatrixClient(nio.AsyncClient): (room.avatar_url(inviter) or "") if inviter else "", left = left, + typing_members = typing_members, + encrypted = room.encrypted, invite_required = room.join_rule == "invite", guests_allowed = room.guest_access == "can_join", @@ -975,23 +955,24 @@ class MatrixClient(nio.AsyncClient): can_set_join_rules = can_send_state("m.room.join_rules"), can_set_guest_access = can_send_state("m.room.guest_access"), - last_event = last_ev, + last_event_date = last_event_date, + ) # List members that left the room, then remove them from our model left_the_room = [ user_id - for user_id in self.models[Member, self.user_id, room.room_id] + for user_id in self.models[self.user_id, room.room_id, "members"] if user_id not in room.users ] for user_id in left_the_room: - del self.models[Member, self.user_id, room.room_id][user_id] + del self.models[self.user_id, room.room_id, "members"][user_id] # Add the room members to the added room new_dict = { user_id: Member( - user_id = user_id, + id = user_id, display_name = room.user_name(user_id) # disambiguated if member.display_name else "", avatar_url = member.avatar_url or "", @@ -1000,7 +981,7 @@ class MatrixClient(nio.AsyncClient): invited = member.invited, ) for user_id, member in room.users.items() } - self.models[Member, self.user_id, room.room_id].update(new_dict) + self.models[self.user_id, room.room_id, "members"].update(new_dict) async def get_member_name_avatar( @@ -1013,7 +994,7 @@ class MatrixClient(nio.AsyncClient): """ try: - item = self.models[Member, self.user_id, room_id][user_id] + item = self.models[self.user_id, room_id, "members"][user_id] except KeyError: # e.g. user is not anymore in the room try: @@ -1030,11 +1011,7 @@ class MatrixClient(nio.AsyncClient): async def register_nio_event( self, room: nio.MatrixRoom, ev: nio.Event, **fields, ) -> None: - """Register a `nio.Event` as a `Event` object in our model. - - `MatrixClient.register_nio_room` is called for the passed `room` - if neccessary before. - """ + """Register a `nio.Event` as a `Event` object in our model.""" await self.register_nio_room(room) @@ -1049,9 +1026,9 @@ class MatrixClient(nio.AsyncClient): # Create Event ModelItem item = Event( - source = ev, - client_id = ev.event_id, + id = ev.event_id, event_id = ev.event_id, + source = ev, date = datetime.fromtimestamp(ev.server_timestamp / 1000), sender_id = ev.sender, sender_name = sender_name, @@ -1069,14 +1046,11 @@ class MatrixClient(nio.AsyncClient): local_sender = ev.sender in self.backend.clients if local_sender and tx_id: - item.client_id = f"echo-{tx_id}" + item.id = f"echo-{tx_id}" if not local_sender and not await self.event_is_past(ev): AlertRequested() - self.models[Event, self.user_id, room.room_id][item.client_id] = item + self.models[self.user_id, room.room_id, "events"][item.id] = item await self.set_room_last_event(room.room_id, item) - - if item.sender_id == self.user_id: - self.models[Event, self.user_id, room.room_id].sync_now() diff --git a/src/backend/models/__init__.py b/src/backend/models/__init__.py index c27f14bb..a80de955 100644 --- a/src/backend/models/__init__.py +++ b/src/backend/models/__init__.py @@ -2,9 +2,6 @@ """Provide classes related to data models shared between Python and QML.""" -from typing import Tuple, Type, Union +from typing import Tuple, Union -from .model_item import ModelItem - -# last one: Tuple[Union[Type[ModelItem], Tuple[Type[ModelItem]]], str...] -SyncId = Union[Type[ModelItem], Tuple[Type[ModelItem]], tuple] +SyncId = Union[str, Tuple[str]] diff --git a/src/backend/models/items.py b/src/backend/models/items.py index a9684016..5ed999ec 100644 --- a/src/backend/models/items.py +++ b/src/backend/models/items.py @@ -11,31 +11,39 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union from uuid import UUID import lxml # nosec - import nio from ..html_markdown import HTML_PROCESSOR from ..utils import AutoStrEnum, auto from .model_item import ModelItem +ZeroDate = datetime.fromtimestamp(0) OptionalExceptionType = Union[Type[None], Type[Exception]] +class TypeSpecifier(AutoStrEnum): + """Enum providing clarification of purpose for some matrix events.""" + + Unset = auto() + ProfileChange = auto() + MembershipChange = auto() + + @dataclass class Account(ModelItem): """A logged in matrix account.""" - user_id: str = field() - display_name: str = "" - avatar_url: str = "" - first_sync_done: bool = False - profile_updated: Optional[datetime] = None + id: str = field() + display_name: str = "" + avatar_url: str = "" + first_sync_done: bool = False + profile_updated: datetime = ZeroDate def __lt__(self, other: "Account") -> bool: """Sort by display name or user ID.""" - name = self.display_name or self.user_id[1:] - other_name = other.display_name or other.user_id[1:] - return name < other_name + name = self.display_name or self.id[1:] + other_name = other.display_name or other.id[1:] + return name.lower() < other_name.lower() @property def filter_string(self) -> str: @@ -47,18 +55,21 @@ class Account(ModelItem): class Room(ModelItem): """A matrix room we are invited to, are or were member of.""" - room_id: str = field() - given_name: str = "" - display_name: str = "" - avatar_url: str = "" - plain_topic: str = "" - topic: str = "" - inviter_id: str = "" - inviter_name: str = "" - inviter_avatar: str = "" - left: bool = False + id: str = field() + given_name: str = "" + display_name: str = "" + main_alias: str = "" + avatar_url: str = "" + plain_topic: str = "" + topic: str = "" + inviter_id: str = "" + inviter_name: str = "" + inviter_avatar: str = "" + left: bool = False + typing_members: List[str] = field(default_factory=list) + federated: bool = True encrypted: bool = False invite_required: bool = True guests_allowed: bool = True @@ -72,7 +83,7 @@ class Room(ModelItem): can_set_join_rules: bool = False can_set_guest_access: bool = False - last_event: Optional["Event"] = field(default=None, repr=False) + last_event_date: datetime = ZeroDate def __lt__(self, other: "Room") -> bool: """Sort by join state, then descending last event date, then name. @@ -85,68 +96,45 @@ class Room(ModelItem): # Left rooms may still have an inviter_id, so check left first. return ( self.left, - other.inviter_id, - - other.last_event.date if other.last_event else - datetime.fromtimestamp(0), - - self.display_name.lower() or self.room_id, + other.last_event_date, + (self.display_name or self.id).lower(), ) < ( other.left, - self.inviter_id, - - self.last_event.date if self.last_event else - datetime.fromtimestamp(0), - - other.display_name.lower() or other.room_id, + self.last_event_date, + (other.display_name or other.id).lower(), ) @property def filter_string(self) -> str: """Filter based on room display name, topic, and last event content.""" - return " ".join(( - self.display_name, - self.topic, - re.sub(r"<.*?>", "", self.last_event.inline_content) - if self.last_event else "", - )) - - - @property - def serialized(self) -> Dict[str, Any]: - dct = super().serialized - - if self.last_event is not None: - dct["last_event"] = self.last_event.serialized - - return dct - + return " ".join((self.display_name, self.topic)) @dataclass class Member(ModelItem): """A member in a matrix room.""" - user_id: str = field() - display_name: str = "" - avatar_url: str = "" - typing: bool = False - power_level: int = 0 - invited: bool = False + id: str = field() + display_name: str = "" + avatar_url: str = "" + typing: bool = False + power_level: int = 0 + invited: bool = False + profile_updated: datetime = ZeroDate def __lt__(self, other: "Member") -> bool: """Sort by power level, then by display name/user ID.""" - name = (self.display_name or self.user_id[1:]).lower() - other_name = (other.display_name or other.user_id[1:]).lower() + name = self.display_name or self.id[1:] + other_name = other.display_name or other.id[1:] return ( - self.invited, other.power_level, name, + self.invited, other.power_level, name.lower(), ) < ( - other.invited, self.power_level, other_name, + other.invited, self.power_level, other_name.lower(), ) @@ -168,15 +156,15 @@ class UploadStatus(AutoStrEnum): class Upload(ModelItem): """Represent a running or failed file upload operation.""" - uuid: UUID = field() + id: UUID = field() task: asyncio.Task = field() monitor: nio.TransferMonitor = field() filepath: Path = field() - total_size: int = 0 - uploaded: int = 0 - speed: float = 0 - time_left: Optional[timedelta] = None + total_size: int = 0 + uploaded: int = 0 + speed: float = 0 + time_left: timedelta = timedelta(0) status: UploadStatus = UploadStatus.Uploading error: OptionalExceptionType = type(None) @@ -191,21 +179,13 @@ class Upload(ModelItem): return self.start_date > other.start_date -class TypeSpecifier(AutoStrEnum): - """Enum providing clarification of purpose for some matrix events.""" - - none = auto() - profile_change = auto() - membership_change = auto() - - @dataclass class Event(ModelItem): """A matrix state event or message.""" - source: Optional[nio.Event] = field() - client_id: str = field() + id: str = field() event_id: str = field() + source: Optional[nio.Event] = field() date: datetime = field() sender_id: str = field() sender_name: str = field() @@ -213,8 +193,9 @@ class Event(ModelItem): content: str = "" inline_content: str = "" + reason: str = "" - type_specifier: TypeSpecifier = TypeSpecifier.none + type_specifier: TypeSpecifier = TypeSpecifier.Unset target_id: str = "" target_name: str = "" @@ -271,12 +252,19 @@ class Event(ModelItem): return urls + @property + def serialized(self) -> Dict[str, Any]: + dct = super().serialized + del dct["source"] + del dct["local_event_type"] + return dct + @dataclass class Device(ModelItem): """A matrix user's device. This class is currently unused.""" - device_id: str = field() + id: str = field() ed25519_key: str = field() trusted: bool = False blacklisted: bool = False diff --git a/src/backend/models/model.py b/src/backend/models/model.py index 0d950576..f5bd45ae 100644 --- a/src/backend/models/model.py +++ b/src/backend/models/model.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: LGPL-3.0-or-later -import logging as log -import time -from threading import Lock, Thread +from threading import Lock from typing import Any, Dict, Iterator, List, MutableMapping +from ..pyotherside_events import ( + ModelCleared, ModelItemDeleted, ModelItemInserted, +) from . import SyncId -from ..pyotherside_events import ModelUpdated from .model_item import ModelItem @@ -30,13 +30,10 @@ class Model(MutableMapping): """ def __init__(self, sync_id: SyncId) -> None: - self.sync_id: SyncId = sync_id - self._data: Dict[Any, ModelItem] = {} - - self._changed: bool = False - self._sync_lock: Lock = Lock() - self._sync_thread: Thread = Thread(target=self._sync_loop, daemon=True) - self._sync_thread.start() + self.sync_id: SyncId = sync_id + self._data: Dict[Any, ModelItem] = {} + self._sorted_data: List[ModelItem] = [] + self._write_lock: Lock = Lock() def __repr__(self) -> str: @@ -47,27 +44,14 @@ class Model(MutableMapping): except ImportError: from pprint import pformat # type: ignore - if isinstance(self.sync_id, tuple): - sid = (self.sync_id[0].__name__, *self.sync_id[1:]) - else: - sid = self.sync_id.__name__ # type: ignore - return "%s(sync_id=%s, %s)" % ( - type(self).__name__, sid, pformat(self._data), + type(self).__name__, self.sync_id, pformat(self._data), ) def __str__(self) -> str: """Provide a short ": items" representation.""" - - if isinstance(self.sync_id, tuple): - reprs = tuple(repr(s) for s in self.sync_id[1:]) - sid = ", ".join((self.sync_id[0].__name__, *reprs)) - sid = f"({sid})" - else: - sid = self.sync_id.__name__ - - return f"{sid!s}: {len(self)} items" + return f"{self.sync_id}: {len(self)} items" def __getitem__(self, key): @@ -81,37 +65,36 @@ class Model(MutableMapping): updated with the passed `ModelItem`'s fields. In other cases, the item is simply added to the model. - This also sets the `ModelItem.parent_model` hidden attribute on the - passed item. + This also sets the `ModelItem.parent_model` hidden attributes on + the passed item. """ - new = value + with self._write_lock: + existing = self._data.get(key) + new = value - if key in self: - existing = dict(self[key].serialized) # copy to not alter with pop - merged = {**existing, **value.serialized} - - existing.pop("parent_model", None) - merged.pop("parent_model", None) - - if merged == existing: + if existing: + for field in new.__dataclass_fields__: # type: ignore + # The same shared item is in _sorted_data, no need to find + # and modify it explicitely. + setattr(existing, field, getattr(new, field)) return - merged_init_kwargs = {**vars(self[key]), **vars(value)} - merged_init_kwargs.pop("parent_model", None) - new = type(value)(**merged_init_kwargs) + new.parent_model = self - new.parent_model = self - - with self._sync_lock: self._data[key] = new - self._changed = True + self._sorted_data.append(new) + self._sorted_data.sort() + + ModelItemInserted(self.sync_id, self._sorted_data.index(new), new) def __delitem__(self, key) -> None: - with self._sync_lock: - del self._data[key] - self._changed = True + with self._write_lock: + item = self._data.pop(key) + index = self._sorted_data.index(item) + del self._sorted_data[index] + ModelItemDeleted(self.sync_id, index) def __iter__(self) -> Iterator: @@ -122,31 +105,11 @@ class Model(MutableMapping): return len(self._data) - def _sync_loop(self) -> None: - """Loop to synchronize model when needed with a cooldown of 0.25s.""" - - while True: - time.sleep(0.25) - - if self._changed: - with self._sync_lock: - log.debug("Syncing %s", self) - self.sync_now() - - - def sync_now(self) -> None: - """Trigger a model synchronization right now. Use with precaution.""" - - ModelUpdated(self.sync_id, self.serialized()) - self._changed = False - - - def serialized(self) -> List[Dict[str, Any]]: - """Return serialized model content as a list of dict for QML.""" - - return [item.serialized for item in sorted(self._data.values())] - - def __lt__(self, other: "Model") -> bool: """Sort `Model` objects lexically by `sync_id`.""" return str(self.sync_id) < str(other.sync_id) + + + def clear(self) -> None: + super().clear() + ModelCleared(self.sync_id) diff --git a/src/backend/models/model_item.py b/src/backend/models/model_item.py index 3e1724d7..017fad0f 100644 --- a/src/backend/models/model_item.py +++ b/src/backend/models/model_item.py @@ -2,8 +2,6 @@ from typing import Any, Dict, Optional -from ..utils import serialize_value_for_qml - class ModelItem: """Base class for items stored inside a `Model`. @@ -28,11 +26,23 @@ class ModelItem: def __setattr__(self, name: str, value) -> None: """If this item is in a `Model`, alert it of attribute changes.""" + if name == "parent_model" or self.parent_model is None: + super().__setattr__(name, value) + return + + if getattr(self, name) == value: + return + super().__setattr__(name, value) - if name != "parent_model" and self.parent_model is not None: - with self.parent_model._sync_lock: - self.parent_model._changed = True + old_index = self.parent_model._sorted_data.index(self) + self.parent_model._sorted_data.sort() + new_index = self.parent_model._sorted_data.index(self) + + from ..pyotherside_events import ModelItemFieldChanged + ModelItemFieldChanged( + self.parent_model.sync_id, old_index, new_index, name, value, + ) def __delattr__(self, name: str) -> None: @@ -43,8 +53,10 @@ class ModelItem: def serialized(self) -> Dict[str, Any]: """Return this item as a dict ready to be passed to QML.""" + from ..utils import serialize_value_for_qml + return { - name: serialize_value_for_qml(getattr(self, name)) + name: serialize_value_for_qml(getattr(self, name), json_lists=True) for name in dir(self) if not ( name.startswith("_") or name in ("parent_model", "serialized") diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index c50d256c..325820e4 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -14,7 +14,7 @@ import nio from . import utils from .html_markdown import HTML_PROCESSOR from .matrix_client import MatrixClient -from .models.items import Account, Room, TypeSpecifier +from .models.items import TypeSpecifier @dataclass @@ -63,7 +63,6 @@ class NioCallbacks: # TODO: handle in nio, these are rooms that were left before # starting the client. if room_id not in self.client.all_rooms: - log.warning("Left room not in MatrixClient.rooms: %r", room_id) continue # TODO: handle left events in nio async client @@ -85,7 +84,7 @@ class NioCallbacks: self.client.first_sync_done.set() self.client.first_sync_date = datetime.now() - account = self.client.models[Account][self.client.user_id] + account = self.client.models["accounts"][self.client.user_id] account.first_sync_done = True @@ -207,7 +206,7 @@ class NioCallbacks: prev_membership = ev.prev_membership ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) - member_change = TypeSpecifier.membership_change + member_change = TypeSpecifier.MembershipChange # Membership changes if not prev or membership != prev_membership: @@ -263,10 +262,9 @@ class NioCallbacks: if changed: # Update our account profile if the event is newer than last update if ev.state_key == self.client.user_id: - account = self.client.models[Account][self.client.user_id] - updated = account.profile_updated + account = self.client.models["accounts"][self.client.user_id] - if not updated or updated < ev_date: + if account.profile_updated < ev_date: account.profile_updated = ev_date account.display_name = now["displayname"] or "" account.avatar_url = now["avatar_url"] or "" @@ -276,7 +274,7 @@ class NioCallbacks: return None return ( - TypeSpecifier.profile_change, + TypeSpecifier.ProfileChange, "%1 changed their {}".format(" and ".join(changed)), ) @@ -383,10 +381,11 @@ class NioCallbacks: if not self.client.first_sync_done.is_set(): return - if room.room_id not in self.client.models[Room, self.client.user_id]: - return + await self.client.register_nio_room(room) - room_item = self.client.models[Room, self.client.user_id][room.room_id] + room_id = room.room_id + + room_item = self.client.models[self.client.user_id, "rooms"][room_id] room_item.typing_members = sorted( room.user_name(user_id) for user_id in ev.users diff --git a/src/backend/pyotherside_events.py b/src/backend/pyotherside_events.py index b2679dac..8b8f66e3 100644 --- a/src/backend/pyotherside_events.py +++ b/src/backend/pyotherside_events.py @@ -1,11 +1,13 @@ # SPDX-License-Identifier: LGPL-3.0-or-later +from abc import ABC from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union +from typing import Any, Optional import pyotherside from .models import SyncId +from .models.model_item import ModelItem from .utils import serialize_value_for_qml @@ -13,11 +15,13 @@ from .utils import serialize_value_for_qml class PyOtherSideEvent: """Event that will be sent on instanciation to QML by PyOtherSide.""" + json_lists = False + def __post_init__(self) -> None: # CPython 3.6 or any Python implemention >= 3.7 is required for correct # __dataclass_fields__ dict order. args = [ - serialize_value_for_qml(getattr(self, field)) + serialize_value_for_qml(getattr(self, field), self.json_lists) for field in self.__dataclass_fields__ # type: ignore ] pyotherside.send(type(self).__name__, *args) @@ -59,20 +63,32 @@ class LoopException(PyOtherSideEvent): @dataclass -class ModelUpdated(PyOtherSideEvent): - """Indicate that a backend `Model`'s data changed.""" +class ModelEvent(ABC, PyOtherSideEvent): + json_lists = True - sync_id: SyncId = field() - data: List[Dict[str, Any]] = field() - serialized_sync_id: Union[str, List[str]] = field(init=False) +@dataclass +class ModelItemInserted(ModelEvent): + sync_id: SyncId = field() + index: int = field() + item: ModelItem = field() - def __post_init__(self) -> None: - if isinstance(self.sync_id, tuple): - self.serialized_sync_id = [ - e.__name__ if isinstance(e, type) else e for e in self.sync_id - ] - else: - self.serialized_sync_id = self.sync_id.__name__ - super().__post_init__() +@dataclass +class ModelItemFieldChanged(ModelEvent): + sync_id: SyncId = field() + item_index_then: int = field() + item_index_now: int = field() + changed_field: str = field() + field_value: Any = field() + + +@dataclass +class ModelItemDeleted(ModelEvent): + sync_id: SyncId = field() + index: int = field() + + +@dataclass +class ModelCleared(ModelEvent): + sync_id: SyncId = field() diff --git a/src/backend/qml_bridge.py b/src/backend/qml_bridge.py index 2a32fdbd..163c3ee4 100644 --- a/src/backend/qml_bridge.py +++ b/src/backend/qml_bridge.py @@ -110,8 +110,6 @@ class QMLBridge: rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa - from .models.items import Account, Room, Member, Event, Device # noqa - p = print # pdb's `p` doesn't print a class's __str__ # noqa try: from pprintpp import pprint as pp # noqa diff --git a/src/backend/utils.py b/src/backend/utils.py index c6d91a56..48b061cd 100644 --- a/src/backend/utils.py +++ b/src/backend/utils.py @@ -6,6 +6,7 @@ import collections import html import inspect import io +import json import xml.etree.cElementTree as xml_etree # FIXME: bandit warning from datetime import timedelta from enum import Enum @@ -17,10 +18,11 @@ from uuid import UUID import filetype from aiofiles.threadpool.binary import AsyncBufferedReader - from nio.crypto import AsyncDataT as File from nio.crypto import async_generator_from_data +from .models.model_item import ModelItem + Size = Tuple[int, int] auto = autostr @@ -125,7 +127,7 @@ def plain2html(text: str) -> str: .replace("\t", " " * 4) -def serialize_value_for_qml(value: Any) -> Any: +def serialize_value_for_qml(value: Any, json_lists: bool = False) -> Any: """Convert a value to make it easier to use from QML. Returns: @@ -135,11 +137,18 @@ def serialize_value_for_qml(value: Any) -> Any: - Strings for `UUID` objects - A number of milliseconds for `datetime.timedelta` objects - The class `__name__` for class types. + - `ModelItem.serialized` for `ModelItem`s """ + if json_lists and isinstance(value, list): + return json.dumps(value) + if hasattr(value, "__class__") and issubclass(value.__class__, Enum): return value.value + if isinstance(value, ModelItem): + return value.serialized + if isinstance(value, Path): return f"file://{value!s}" diff --git a/src/gui/Base/HAccordionView.qml b/src/gui/Base/HAccordionView.qml new file mode 100644 index 00000000..960374a1 --- /dev/null +++ b/src/gui/Base/HAccordionView.qml @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 + +HListView { + id: accordion + + + property Component category + property Component content + property Component expander: HButton { + id: expanderItem + iconItem.small: true + icon.name: "expand" + backgroundColor: "transparent" + toolTip.text: expand ? qsTr("Collapse") : qsTr("Expand") + onClicked: expand = ! expand + + leftPadding: theme.spacing / 2 + rightPadding: leftPadding + + iconItem.transform: Rotation { + origin.x: expanderItem.iconItem.width / 2 + origin.y: expanderItem.iconItem.height / 2 + angle: expanderItem.loading ? 0 : expand ? 90 : 180 + + Behavior on angle { HNumberAnimation {} } + } + + Behavior on opacity { HNumberAnimation {} } + } + + + delegate: HColumnLayout { + id: categoryContentColumn + width: accordion.width + + property bool expand: true + readonly property QtObject categoryModel: model + + HRowLayout { + Layout.fillWidth: true + + HLoader { + id: categoryLoader + sourceComponent: category + + Layout.fillWidth: true + + readonly property QtObject model: categoryModel + } + HLoader { + sourceComponent: expander + + readonly property QtObject model: categoryModel + property alias expand: categoryContentColumn.expand + } + } + + Item { + opacity: expand ? 1 : 0 + visible: opacity > 0 + + Layout.fillWidth: true + Layout.preferredHeight: contentLoader.implicitHeight * opacity + + Behavior on opacity { HNumberAnimation {} } + + HLoader { + id: contentLoader + width: parent.width + active: categoryLoader.status === Loader.Ready + sourceComponent: content + + readonly property QtObject xcategoryModel: categoryModel + } + } + } +} diff --git a/src/gui/Base/HListModel.qml b/src/gui/Base/HListModel.qml deleted file mode 100644 index 709821b4..00000000 --- a/src/gui/Base/HListModel.qml +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -import QtQuick 2.12 -import QSyncable 1.0 - -JsonListModel { - id: model - source: [] - Component.onCompleted: if (! keyField) { throw "keyField not set" } - - function toObject(itemList=listModel) { - let objList = [] - - for (let item of itemList) { - let obj = JSON.parse(JSON.stringify(item)) - - for (let role in obj) { - if (obj[role]["objectName"] !== undefined) { - obj[role] = toObject(item[role]) - } - } - objList.push(obj) - } - return objList - } - - function toJson() { - return JSON.stringify(toObject(), null, 4) - } -} diff --git a/src/gui/Base/HListView.qml b/src/gui/Base/HListView.qml index 53e46d76..7b4c4fd9 100644 --- a/src/gui/Base/HListView.qml +++ b/src/gui/Base/HListView.qml @@ -26,10 +26,12 @@ ListView { visible: listView.interactive || ! listView.allowDragging } + // property bool debug: false + // Make sure to handle when a previous transition gets interrupted add: Transition { ParallelAnimation { - // ScriptAction { script: print("add") } + // ScriptAction { script: if (listView.debug) print("add") } HNumberAnimation { property: "opacity"; from: 0; to: 1 } HNumberAnimation { property: "scale"; from: 0; to: 1 } } @@ -37,7 +39,7 @@ ListView { move: Transition { ParallelAnimation { - // ScriptAction { script: print("move") } + // ScriptAction { script: if (listView.debug) print("move") } HNumberAnimation { property: "opacity"; to: 1 } HNumberAnimation { property: "scale"; to: 1 } HNumberAnimation { properties: "x,y" } @@ -46,16 +48,15 @@ ListView { remove: Transition { ParallelAnimation { - // ScriptAction { script: print("remove") } + // ScriptAction { script: if (listView.debug) print("remove") } HNumberAnimation { property: "opacity"; to: 0 } HNumberAnimation { property: "scale"; to: 0 } } } - // displaced: move displaced: Transition { ParallelAnimation { - // ScriptAction { script: print("displaced") } + // ScriptAction { script: if (listView.debug) print("displaced") } HNumberAnimation { property: "opacity"; to: 1 } HNumberAnimation { property: "scale"; to: 1 } HNumberAnimation { properties: "x,y" } diff --git a/src/gui/Base/HSortFilterProxy.qml b/src/gui/Base/HSortFilterProxy.qml new file mode 100644 index 00000000..1a49421d --- /dev/null +++ b/src/gui/Base/HSortFilterProxy.qml @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import com.cutehacks.gel 1.0 + +Collection { + caseSensitiveSort: false + localeAwareSort: true + Component.onCompleted: reSort() +} diff --git a/src/gui/Base/HTileDelegate.qml b/src/gui/Base/HTileDelegate.qml index 92ef5b26..7fa51ac3 100644 --- a/src/gui/Base/HTileDelegate.qml +++ b/src/gui/Base/HTileDelegate.qml @@ -18,19 +18,4 @@ HTile { signal activated() property HListView view: ListView.view - property bool shouldBeCurrent: false - - readonly property QtObject delegateModel: model - - readonly property alias setCurrentTimer: setCurrentTimer - - - Timer { - id: setCurrentTimer - interval: 100 - repeat: true - running: true - // Component.onCompleted won't work for this - onTriggered: if (shouldBeCurrent) view.currentIndex = model.index - } } diff --git a/src/gui/MainPane/AccountDelegate.qml b/src/gui/MainPane/Account.qml similarity index 62% rename from src/gui/MainPane/AccountDelegate.qml rename to src/gui/MainPane/Account.qml index 409135e5..1984d4b7 100644 --- a/src/gui/MainPane/AccountDelegate.qml +++ b/src/gui/MainPane/Account.qml @@ -6,54 +6,61 @@ import Clipboard 0.1 import "../Base" HTileDelegate { - id: accountDelegate + id: account spacing: 0 topPadding: model.index > 0 ? theme.spacing / 2 : 0 bottomPadding: topPadding + backgroundColor: theme.mainPane.account.background - opacity: collapsed && ! forceExpand ? + opacity: collapsed && ! anyFilter ? theme.mainPane.account.collapsedOpacity : 1 - shouldBeCurrent: - window.uiState.page === "Pages/AccountSettings/AccountSettings.qml" && - window.uiState.pageProperties.userId === model.data.user_id + title.color: theme.mainPane.account.name + title.text: model.display_name || model.id + title.font.pixelSize: theme.fontSize.big + title.leftPadding: theme.spacing - setCurrentTimer.running: - ! mainPaneList.activateLimiter.running && ! mainPane.hasFocus + image: HUserAvatar { + userId: model.id + displayName: model.display_name + mxc: model.avatar_url + } + contextMenu: HMenu { + HMenuItem { + icon.name: "copy-user-id" + text: qsTr("Copy user ID") + onTriggered: Clipboard.text = model.id + } - Behavior on opacity { HNumberAnimation {} } - - - readonly property bool forceExpand: Boolean(mainPaneList.filter) - - // Hide harmless error when a filter matches nothing - readonly property bool collapsed: try { - return mainPaneList.collapseAccounts[model.data.user_id] || false - } catch (err) {} + HMenuItemPopupSpawner { + icon.name: "sign-out" + icon.color: theme.colors.negativeBackground + text: qsTr("Sign out") + popup: "Popups/SignOutPopup.qml" + properties: { "userId": model.id } + } + } onActivated: pageLoader.showPage( - "AccountSettings/AccountSettings", { "userId": model.data.user_id } + "AccountSettings/AccountSettings", { "userId": model.id } ) + readonly property bool collapsed: + window.uiState.collapseAccounts[model.id] || false + + readonly property bool anyFilter: Boolean(mainPaneList.filter) + + function toggleCollapse() { - window.uiState.collapseAccounts[model.data.user_id] = ! collapsed + window.uiState.collapseAccounts[model.id] = ! collapsed window.uiStateChanged() } - image: HUserAvatar { - userId: model.data.user_id - displayName: model.data.display_name - mxc: model.data.avatar_url - } - - title.color: theme.mainPane.account.name - title.text: model.data.display_name || model.data.user_id - title.font.pixelSize: theme.fontSize.big - title.leftPadding: theme.spacing + Behavior on opacity { HNumberAnimation {} } HButton { id: addChat @@ -62,7 +69,7 @@ HTileDelegate { backgroundColor: "transparent" toolTip.text: qsTr("Add new chat") onClicked: pageLoader.showPage( - "AddChat/AddChat", {userId: model.data.user_id}, + "AddChat/AddChat", {userId: model.id}, ) leftPadding: theme.spacing / 2 @@ -73,7 +80,7 @@ HTileDelegate { Layout.fillHeight: true Layout.maximumWidth: - accountDelegate.width >= 100 * theme.uiScale ? implicitWidth : 0 + account.width >= 100 * theme.uiScale ? implicitWidth : 0 Behavior on Layout.maximumWidth { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} } @@ -81,22 +88,23 @@ HTileDelegate { HButton { id: expand - loading: ! model.data.first_sync_done || ! model.data.profile_updated + loading: + ! model.first_sync_done || model.profile_updated < new Date(1) iconItem.small: true icon.name: "expand" backgroundColor: "transparent" toolTip.text: collapsed ? qsTr("Expand") : qsTr("Collapse") - onClicked: accountDelegate.toggleCollapse() + onClicked: account.toggleCollapse() leftPadding: theme.spacing / 2 rightPadding: leftPadding - opacity: ! loading && accountDelegate.forceExpand ? 0 : 1 + opacity: ! loading && account.anyFilter ? 0 : 1 visible: opacity > 0 && Layout.maximumWidth > 0 Layout.fillHeight: true Layout.maximumWidth: - accountDelegate.width >= 120 * theme.uiScale ? implicitWidth : 0 + account.width >= 120 * theme.uiScale ? implicitWidth : 0 iconItem.transform: Rotation { @@ -110,21 +118,4 @@ HTileDelegate { Behavior on Layout.maximumWidth { HNumberAnimation {} } Behavior on opacity { HNumberAnimation {} } } - - contextMenu: HMenu { - HMenuItem { - icon.name: "copy-user-id" - text: qsTr("Copy user ID") - onTriggered: Clipboard.text = model.data.user_id - } - - HMenuItemPopupSpawner { - icon.name: "sign-out" - icon.color: theme.colors.negativeBackground - text: qsTr("Sign out") - - popup: "Popups/SignOutPopup.qml" - properties: { "userId": model.data.user_id } - } - } } diff --git a/src/gui/MainPane/AccountRoomList.qml b/src/gui/MainPane/AccountRoomList.qml deleted file mode 100644 index 08de8e4d..00000000 --- a/src/gui/MainPane/AccountRoomList.qml +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -import QtQuick 2.12 -import QtQuick.Layouts 1.12 -import "../Base" - -HListView { - id: mainPaneList - - - readonly property var originSource: window.mainPaneModelSource - readonly property var collapseAccounts: window.uiState.collapseAccounts - readonly property string filter: toolBar.roomFilter - readonly property alias activateLimiter: activateLimiter - - onOriginSourceChanged: filterLimiter.restart() - onFilterChanged: filterLimiter.restart() - onCollapseAccountsChanged: filterLimiter.restart() - - - function filterSource() { - let show = [] - - // Hide a harmless error when activating a RoomDelegate - try { window.mainPaneModelSource } catch (err) { return } - - for (let i = 0; i < window.mainPaneModelSource.length; i++) { - let item = window.mainPaneModelSource[i] - - if (item.type === "Account" || - (filter ? - utils.filterMatches(filter, item.data.filter_string) : - ! window.uiState.collapseAccounts[item.user_id])) - { - if (filter && show.length && item.type === "Account" && - show[show.length - 1].type === "Account" && - ! utils.filterMatches( - filter, show[show.length - 1].data.filter_string) - ) { - // If filter active, current and previous items are - // both accounts and previous account doesn't match filter, - // that means the previous account had no matching rooms. - show.pop() - } - - show.push(item) - } - } - - let last = show[show.length - 1] - if (show.length && filter && last.type === "Account" && - ! utils.filterMatches(filter, last.data.filter_string)) - { - // If filter active, last item is an account and last item - // doesn't match filter, that account had no matching rooms. - show.pop() - } - - model.source = show - } - - function previous(activate=true) { - decrementCurrentIndex() - if (activate) activateLimiter.restart() - } - - function next(activate=true) { - incrementCurrentIndex() - if (activate) activateLimiter.restart() - } - - function activate() { - currentItem.item.activated() - } - - function accountSettings() { - if (! currentItem) incrementCurrentIndex() - - pageLoader.showPage( - "AccountSettings/AccountSettings", - {userId: currentItem.item.delegateModel.user_id}, - ) - } - - function addNewChat() { - if (! currentItem) incrementCurrentIndex() - - pageLoader.showPage( - "AddChat/AddChat", - {userId: currentItem.item.delegateModel.user_id}, - ) - } - - function toggleCollapseAccount() { - if (filter) return - - if (! currentItem) incrementCurrentIndex() - - if (currentItem.item.delegateModel.type === "Account") { - currentItem.item.toggleCollapse() - return - } - - for (let i = 0; i < model.source.length; i++) { - let item = model.source[i] - - if (item.type === "Account" && item.user_id == - currentItem.item.delegateModel.user_id) - { - currentIndex = i - currentItem.item.toggleCollapse() - } - } - } - - - model: HListModel { - keyField: "id" - source: originSource - } - - delegate: Loader { - width: mainPaneList.width - Component.onCompleted: setSource( - model.type === "Account" ? - "AccountDelegate.qml" : "RoomDelegate.qml", - {view: mainPaneList} - ) - } - - - Timer { - id: filterLimiter - interval: 16 - onTriggered: filterSource() - } - - Timer { - id: activateLimiter - interval: 300 - onTriggered: activate() - } -} diff --git a/src/gui/MainPane/AccountRoomsDelegate.qml b/src/gui/MainPane/AccountRoomsDelegate.qml new file mode 100644 index 00000000..13d9a101 --- /dev/null +++ b/src/gui/MainPane/AccountRoomsDelegate.qml @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import ".." +import "../Base" + +Column { + id: delegate + + + property string userId: model.id + readonly property HListView view: ListView.view + + + Account { + id: account + width: parent.width + view: delegate.view + } + + HListView { + id: roomList + width: parent.width + height: contentHeight * opacity + opacity: account.collapsed ? 0 : 1 + visible: opacity > 0 + interactive: false + + model: ModelStore.get(delegate.userId, "rooms") + // model: HSortFilterProxy { + // model: ModelStore.get(delegate.userId, "rooms") + // comparator: (a, b) => + // // Sort by membership, then last event date (most recent first) + // // then room display name or ID. + // // Invited rooms are first, then joined rooms, then left rooms. + + // // Left rooms may still have an inviter_id, so check left first + // [ + // a.left, + // b.inviter_id, + + // b.last_event && b.last_event.date ? + // b.last_event.date.getTime() : 0, + + // (a.display_name || a.id).toLocaleLowerCase(), + // ] < [ + // b.left, + // a.inviter_id, + + // a.last_event && a.last_event.date ? + // a.last_event.date.getTime() : 0, + + // (b.display_name || b.id).toLocaleLowerCase(), + // ] + // } + + delegate: Room { + width: roomList.width + userId: delegate.userId + } + + Behavior on opacity { + HNumberAnimation { easing.type: Easing.InOutCirc } + } + } +} diff --git a/src/gui/MainPane/AccountRoomsList.qml b/src/gui/MainPane/AccountRoomsList.qml new file mode 100644 index 00000000..b929bda4 --- /dev/null +++ b/src/gui/MainPane/AccountRoomsList.qml @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import ".." +import "../Base" + +HListView { + id: mainPaneList + + model: ModelStore.get("accounts") + // model: HSortFilterProxy { + // model: ModelStore.get("accounts") + // comparator: (a, b) => + // // Sort by display name or user ID + // (a.display_name || a.id).toLocaleLowerCase() < + // (b.display_name || b.id).toLocaleLowerCase() + // } + + delegate: AccountRoomsDelegate { + width: mainPaneList.width + height: childrenRect.height + } + + + readonly property string filter: toolBar.roomFilter + + + function previous(activate=true) { + decrementCurrentIndex() + if (activate) activateLimiter.restart() + } + + function next(activate=true) { + incrementCurrentIndex() + if (activate) activateLimiter.restart() + } + + function activate() { + currentItem.item.activated() + } + + function accountSettings() { + if (! currentItem) incrementCurrentIndex() + + pageLoader.showPage( + "AccountSettings/AccountSettings", + {userId: currentItem.item.delegateModel.user_id}, + ) + } + + function addNewChat() { + if (! currentItem) incrementCurrentIndex() + + pageLoader.showPage( + "AddChat/AddChat", + {userId: currentItem.item.delegateModel.user_id}, + ) + } + + function toggleCollapseAccount() { + if (filter) return + + if (! currentItem) incrementCurrentIndex() + + if (currentItem.item.delegateModel.type === "Account") { + currentItem.item.toggleCollapse() + return + } + + for (let i = 0; i < model.source.length; i++) { + let item = model.source[i] + + if (item.type === "Account" && item.user_id == + currentItem.item.delegateModel.user_id) + { + currentIndex = i + currentItem.item.toggleCollapse() + } + } + } + + + Timer { + id: activateLimiter + interval: 300 + onTriggered: activate() + } +} diff --git a/src/gui/MainPane/MainPane.qml b/src/gui/MainPane/MainPane.qml index 4617fadf..6f030f3b 100644 --- a/src/gui/MainPane/MainPane.qml +++ b/src/gui/MainPane/MainPane.qml @@ -37,7 +37,7 @@ HDrawer { HColumnLayout { anchors.fill: parent - AccountRoomList { + AccountRoomsList { id: mainPaneList clip: true diff --git a/src/gui/MainPane/MainPaneToolBar.qml b/src/gui/MainPane/MainPaneToolBar.qml index f9bc93fb..2226540a 100644 --- a/src/gui/MainPane/MainPaneToolBar.qml +++ b/src/gui/MainPane/MainPaneToolBar.qml @@ -9,7 +9,7 @@ HRowLayout { // Hide filter field overflowing for a sec on size changes clip: true - property AccountRoomList mainPaneList + property AccountRoomsList mainPaneList readonly property alias addAccountButton: addAccountButton readonly property alias filterField: filterField property alias roomFilter: filterField.text diff --git a/src/gui/MainPane/RoomDelegate.qml b/src/gui/MainPane/Room.qml similarity index 54% rename from src/gui/MainPane/RoomDelegate.qml rename to src/gui/MainPane/Room.qml index 3899485c..6dd3b181 100644 --- a/src/gui/MainPane/RoomDelegate.qml +++ b/src/gui/MainPane/Room.qml @@ -3,85 +3,48 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import Clipboard 0.1 +import ".." import "../Base" HTileDelegate { - id: roomDelegate spacing: theme.spacing backgroundColor: theme.mainPane.room.background - opacity: model.data.left ? theme.mainPane.room.leftRoomOpacity : 1 - - shouldBeCurrent: - window.uiState.page === "Pages/Chat/Chat.qml" && - window.uiState.pageProperties.userId === model.user_id && - window.uiState.pageProperties.roomId === model.data.room_id - - setCurrentTimer.running: - ! mainPaneList.activateLimiter.running && ! mainPane.hasFocus - - - Behavior on opacity { HNumberAnimation {} } - - - readonly property bool joined: ! invited && ! parted - readonly property bool invited: model.data.inviter_id && ! parted - readonly property bool parted: model.data.left - readonly property var lastEvent: model.data.last_event - - - onActivated: pageLoader.showRoom(model.user_id, model.data.room_id) - + opacity: model.left ? theme.mainPane.room.leftRoomOpacity : 1 image: HRoomAvatar { - displayName: model.data.display_name - mxc: model.data.avatar_url + displayName: model.display_name + mxc: model.avatar_url } title.color: theme.mainPane.room.name - title.text: model.data.display_name || qsTr("Empty room") + title.text: model.display_name || qsTr("Empty room") additionalInfo.children: HIcon { svgName: "invite-received" colorize: theme.colors.alertBackground - visible: invited Layout.maximumWidth: invited ? implicitWidth : 0 Behavior on Layout.maximumWidth { HNumberAnimation {} } } - rightInfo.color: theme.mainPane.room.lastEventDate - rightInfo.text: { - ! lastEvent || ! lastEvent.date ? - "" : - - utils.dateIsToday(lastEvent.date) ? - utils.formatTime(lastEvent.date, false) : // no seconds - - lastEvent.date.getFullYear() === new Date().getFullYear() ? - Qt.formatDate(lastEvent.date, "d MMM") : // e.g. "5 Dec" - - lastEvent.date.getFullYear() - } - subtitle.color: theme.mainPane.room.subtitle - subtitle.font.italic: - Boolean(lastEvent && lastEvent.event_type === "RoomMessageEmote") subtitle.textFormat: Text.StyledText + subtitle.font.italic: + lastEvent && lastEvent.event_type === "RoomMessageEmote" subtitle.text: { if (! lastEvent) return "" - let isEmote = lastEvent.event_type === "RoomMessageEmote" - let isMsg = lastEvent.event_type.startsWith("RoomMessage") - let isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown" - let isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted") + const isEmote = lastEvent.event_type === "RoomMessageEmote" + const isMsg = lastEvent.event_type.startsWith("RoomMessage") + const isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown" + const isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted") // If it's a general event - if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia)) { + if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia)) return utils.processedEventText(lastEvent) - } - let text = utils.coloredNameHtml( + const text = utils.coloredNameHtml( lastEvent.sender_name, lastEvent.sender_id ) + ": " + lastEvent.inline_content @@ -91,26 +54,40 @@ HTileDelegate { ) } + rightInfo.color: theme.mainPane.room.lastEventDate + rightInfo.text: { + model.last_event_date < new Date(1) ? + "" : + + utils.dateIsToday(model.last_event_date) ? + utils.formatTime(model.last_event_date, false) : // no seconds + + model.last_event_date.getFullYear() === new Date().getFullYear() ? + Qt.formatDate(model.last_event_date, "d MMM") : // e.g. "5 Dec" + + model.last_event_date.getFullYear() + } + contextMenu: HMenu { HMenuItemPopupSpawner { visible: joined - enabled: model.data.can_invite + enabled: model.can_invite icon.name: "room-send-invite" text: qsTr("Invite members") popup: "Popups/InviteToRoomPopup.qml" properties: ({ - userId: model.user_id, - roomId: model.data.room_id, - roomName: model.data.display_name, - invitingAllowed: Qt.binding(() => model.data.can_invite) + userId: userId, + roomId: model.id, + roomName: model.display_name, + invitingAllowed: Qt.binding(() => model.can_invite) }) } HMenuItem { icon.name: "copy-room-id" text: qsTr("Copy room ID") - onTriggered: Clipboard.text = model.data.room_id + onTriggered: Clipboard.text = model.id } HMenuItem { @@ -118,12 +95,12 @@ HTileDelegate { icon.name: "invite-accept" icon.color: theme.colors.positiveBackground text: qsTr("Accept %1's invite").arg(utils.coloredNameHtml( - model.data.inviter_name, model.data.inviter_id + model.inviter_name, model.inviter_id )) label.textFormat: Text.StyledText onTriggered: py.callClientCoro( - model.user_id, "join", [model.data.room_id] + userId, "join", [model.id] ) } @@ -135,9 +112,9 @@ HTileDelegate { popup: "Popups/LeaveRoomPopup.qml" properties: ({ - userId: model.user_id, - roomId: model.data.room_id, - roomName: model.data.display_name, + userId: userId, + roomId: model.id, + roomName: model.display_name, }) } @@ -149,10 +126,27 @@ HTileDelegate { popup: "Popups/ForgetRoomPopup.qml" autoDestruct: false properties: ({ - userId: model.user_id, - roomId: model.data.room_id, - roomName: model.data.display_name, + userId: userId, + roomId: model.id, + roomName: model.display_name, }) } } + + onActivated: pageLoader.showRoom(userId, model.id) + + + property string userId + readonly property bool joined: ! invited && ! parted + readonly property bool invited: model.inviter_id && ! parted + readonly property bool parted: model.left + + readonly property ListModel eventModel: + ModelStore.get(userId, model.id, "events") + + readonly property QtObject lastEvent: + eventModel.count > 0 ? eventModel.get(0) : null + + + Behavior on opacity { HNumberAnimation {} } } diff --git a/src/gui/ModelStore.qml b/src/gui/ModelStore.qml new file mode 100644 index 00000000..3f6e2387 --- /dev/null +++ b/src/gui/ModelStore.qml @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +pragma Singleton +import QtQuick 2.12 +import "PythonBridge" + +QtObject { + property QtObject privates: QtObject { + readonly property var store: ({}) + + readonly property PythonBridge py: PythonBridge {} + + readonly property Component model: Component { + ListModel { + property var modelId + + function find(id) { + for (let i = 0; i < count; i++) + if (get(i).id === id) return get(i) + + return null + } + } + } + } + + + function get(...modelId) { + if (modelId.length === 1) modelId = modelId[0] + + if (! privates.store[modelId]) + privates.store[modelId] = + privates.model.createObject(this, {modelId}) + + return privates.store[modelId] + } +} diff --git a/src/gui/Pages/AccountSettings/AccountSettings.qml b/src/gui/Pages/AccountSettings/AccountSettings.qml index cb6ca037..acae66e7 100644 --- a/src/gui/Pages/AccountSettings/AccountSettings.qml +++ b/src/gui/Pages/AccountSettings/AccountSettings.qml @@ -3,29 +3,29 @@ import QtQuick 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 +import "../.." import "../../Base" HPage { id: accountSettings + hideHeaderUnderHeight: avatarPreferredSize + headerLabel.text: qsTr("Account settings for %1").arg( + utils.coloredNameHtml(headerName, userId) + ) + property int avatarPreferredSize: 256 * theme.uiScale property string userId: "" readonly property bool ready: - accountInfo !== "waiting" && Boolean(accountInfo.profile_updated) + accountInfo !== null && accountInfo.profile_updated > new Date(1) - readonly property var accountInfo: utils.getItem( - modelSources["Account"] || [], "user_id", userId - ) || "waiting" + readonly property QtObject accountInfo: + ModelStore.get("accounts").find(userId) property string headerName: ready ? accountInfo.display_name : userId - hideHeaderUnderHeight: avatarPreferredSize - headerLabel.text: qsTr("Account settings for %1").arg( - utils.coloredNameHtml(headerName, userId) - ) - HSpacer {} diff --git a/src/gui/Pages/AddChat/AddChat.qml b/src/gui/Pages/AddChat/AddChat.qml index 3f5edde1..84cccb80 100644 --- a/src/gui/Pages/AddChat/AddChat.qml +++ b/src/gui/Pages/AddChat/AddChat.qml @@ -2,6 +2,7 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 +import "../.." import "../../Base" HPage { @@ -10,8 +11,7 @@ HPage { property string userId - readonly property var account: - utils.getItem(modelSources["Account"] || [], "user_id", userId) + readonly property QtObject account: ModelStore.get("accounts").find(userId) HTabContainer { diff --git a/src/gui/Pages/Chat/Chat.qml b/src/gui/Pages/Chat/Chat.qml index c61a3c1b..328e8627 100644 --- a/src/gui/Pages/Chat/Chat.qml +++ b/src/gui/Pages/Chat/Chat.qml @@ -2,6 +2,7 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 +import "../.." import "../../Base" import "RoomPane" @@ -13,16 +14,11 @@ Item { property string userId: "" property string roomId: "" + property QtObject userInfo: ModelStore.get("accounts").find(userId) + property QtObject roomInfo: ModelStore.get(userId, "rooms").find(roomId) + property bool loadingMessages: false - property bool ready: userInfo !== "waiting" && roomInfo !== "waiting" - - readonly property var userInfo: - utils.getItem(modelSources["Account"] || [], "user_id", userId) || - "waiting" - - readonly property var roomInfo: utils.getItem( - modelSources[["Room", userId]] || [], "room_id", roomId - ) || "waiting" + property bool ready: Boolean(userInfo && roomInfo) readonly property alias loader: loader readonly property alias roomPane: roomPaneLoader.item diff --git a/src/gui/Pages/Chat/Composer.qml b/src/gui/Pages/Chat/Composer.qml index b2979a78..df9f8437 100644 --- a/src/gui/Pages/Chat/Composer.qml +++ b/src/gui/Pages/Chat/Composer.qml @@ -3,18 +3,28 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import Clipboard 0.1 +import "../.." import "../../Base" import "../../Dialogs" Rectangle { + id: composer + color: theme.chat.composer.background + + Layout.fillWidth: true + Layout.minimumHeight: theme.baseElementsHeight + Layout.preferredHeight: areaScrollView.implicitHeight + Layout.maximumHeight: pageLoader.height / 2 + + property string indent: " " property var aliases: window.settings.writeAliases property string toSend: "" property string writingUserId: chat.userId - readonly property var writingUserInfo: - utils.getItem(modelSources["Account"] || [], "user_id", writingUserId) + property QtObject writingUserInfo: + ModelStore.get("accounts").find(writingUserId) property bool textChangedSinceLostFocus: false @@ -40,20 +50,9 @@ Rectangle { lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length : 1 + function takeFocus() { areaScrollView.forceActiveFocus() } - // property var pr: lineTextUntilCursor - // onPrChanged: print( - // "y", cursorY, "x", cursorX, - // "ltuc <" + lineTextUntilCursor + ">", "dob", - // deleteCharsOnBackspace, "m", lineTextUntilCursor.match(/^ +$/)) - - id: composer - Layout.fillWidth: true - Layout.minimumHeight: theme.baseElementsHeight - Layout.preferredHeight: areaScrollView.implicitHeight - Layout.maximumHeight: pageLoader.height / 2 - color: theme.chat.composer.background HRowLayout { anchors.fill: parent @@ -61,8 +60,8 @@ Rectangle { HUserAvatar { id: avatar userId: writingUserId - displayName: writingUserInfo.display_name - mxc: writingUserInfo.avatar_url + displayName: writingUserInfo ? writingUserInfo.display_name : "" + mxc: writingUserInfo ? writingUserInfo.avatar_url : "" } HScrollableTextArea { diff --git a/src/gui/Pages/Chat/FileTransfer/TransferList.qml b/src/gui/Pages/Chat/FileTransfer/TransferList.qml index ad2dde0c..ec3116ad 100644 --- a/src/gui/Pages/Chat/FileTransfer/TransferList.qml +++ b/src/gui/Pages/Chat/FileTransfer/TransferList.qml @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 +import "../../.." import "../../../Base" Rectangle { @@ -25,11 +26,7 @@ Rectangle { id: transferList anchors.fill: parent - model: HListModel { - keyField: "uuid" - source: modelSources[["Upload", chat.roomId]] || [] - } - + model: ModelStore.get(chat.roomId, "uploads") delegate: Transfer { width: transferList.width } } } diff --git a/src/gui/Pages/Chat/RoomPane/MemberDelegate.qml b/src/gui/Pages/Chat/RoomPane/MemberDelegate.qml index 6b9d93fd..4104a58b 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberDelegate.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberDelegate.qml @@ -11,7 +11,7 @@ HTileDelegate { model.invited ? theme.chat.roomPane.member.invitedOpacity : 1 image: HUserAvatar { - userId: model.user_id + userId: model.id displayName: model.display_name mxc: model.avatar_url powerLevel: model.power_level @@ -19,20 +19,20 @@ HTileDelegate { invited: model.invited } - title.text: model.display_name || model.user_id + title.text: model.display_name || model.id title.color: memberDelegate.hovered ? - utils.nameColor(model.display_name || model.user_id.substring(1)) : + utils.nameColor(model.display_name || model.id.substring(1)) : theme.chat.roomPane.member.name - subtitle.text: model.display_name ? model.user_id : "" + subtitle.text: model.display_name ? model.id : "" subtitle.color: theme.chat.roomPane.member.subtitle contextMenu: HMenu { HMenuItem { icon.name: "copy-user-id" text: qsTr("Copy user ID") - onTriggered: Clipboard.text = model.user_id + onTriggered: Clipboard.text = model.id } } diff --git a/src/gui/Pages/Chat/RoomPane/MemberView.qml b/src/gui/Pages/Chat/RoomPane/MemberView.qml index c0255e65..82b7c624 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberView.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberView.qml @@ -2,6 +2,7 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 +import "../../.." import "../../../Base" HColumnLayout { @@ -9,37 +10,33 @@ HColumnLayout { id: memberList clip: true - Layout.fillWidth: true - Layout.fillHeight: true + model: ModelStore.get(chat.userId, chat.roomId, "members") + // model: HSortFilterProxy { + // model: ModelStore.get(chat.userId, chat.roomId, "members") + // comparator: (a, b) => + // // Sort by power level, then by display name or user ID (no @) + // [ + // a.invited, + // b.power_level, + // (a.display_name || a.id.substring(1)).toLocaleLowerCase(), + // ] < [ + // b.invited, + // a.power_level, + // (b.display_name || b.id.substring(1)).toLocaleLowerCase(), + // ] - readonly property var originSource: - modelSources[["Member", chat.userId, chat.roomId]] || [] - - - onOriginSourceChanged: filterLimiter.restart() - - - function filterSource() { - model.source = - utils.filterModelSource(originSource, filterField.text) - } - - - model: HListModel { - keyField: "user_id" - source: memberList.originSource - } + // filter: (item, index) => utils.filterMatchesAny( + // filterField.text, item.display_name, item.id, + // ) + // } delegate: MemberDelegate { width: memberList.width } - Timer { - id: filterLimiter - interval: 16 - onTriggered: memberList.filterSource() - } + Layout.fillWidth: true + Layout.fillHeight: true } HRowLayout { @@ -56,7 +53,7 @@ HColumnLayout { bordered: false opacity: width >= 16 * theme.uiScale ? 1 : 0 - onTextChanged: filterLimiter.restart() + onTextChanged: memberList.model.reFilter() Layout.fillWidth: true Layout.fillHeight: true diff --git a/src/gui/Pages/Chat/Timeline/EventContent.qml b/src/gui/Pages/Chat/Timeline/EventContent.qml index 5a16563f..08eb6c40 100644 --- a/src/gui/Pages/Chat/Timeline/EventContent.qml +++ b/src/gui/Pages/Chat/Timeline/EventContent.qml @@ -172,7 +172,7 @@ HRowLayout { HRepeater { id: linksRepeater - model: eventDelegate.currentModel.links + model: JSON.parse(eventDelegate.currentModel.links) EventMediaLoader { singleMediaInfo: eventDelegate.currentModel diff --git a/src/gui/Pages/Chat/Timeline/EventDelegate.qml b/src/gui/Pages/Chat/Timeline/EventDelegate.qml index dba68ec4..0c2ce111 100644 --- a/src/gui/Pages/Chat/Timeline/EventDelegate.qml +++ b/src/gui/Pages/Chat/Timeline/EventDelegate.qml @@ -3,6 +3,7 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import Clipboard 0.1 +import "../../.." import "../../../Base" HColumnLayout { @@ -65,13 +66,8 @@ HColumnLayout { function json() { return JSON.stringify( { - "model": utils.getItem( - modelSources[[ - "Event", chat.userId, chat.roomId - ]], - "client_id", - model.client_id - ), + "model": ModelStore.get(chat.userId, chat.roomId, "events") + .get(model.id), "source": py.getattr(model.source, "__dict__"), }, null, 4) diff --git a/src/gui/Pages/Chat/Timeline/EventList.qml b/src/gui/Pages/Chat/Timeline/EventList.qml index 8c21270a..08c7add1 100644 --- a/src/gui/Pages/Chat/Timeline/EventList.qml +++ b/src/gui/Pages/Chat/Timeline/EventList.qml @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 +import "../../.." import "../../../Base" Rectangle { @@ -157,12 +158,12 @@ Rectangle { } - model: HListModel { - keyField: "client_id" - source: modelSources[[ - "Event", chat.userId, chat.roomId - ]] || [] - } + model: ModelStore.get(chat.userId, chat.roomId, "events") + // model: HSortFilterProxy { + // model: ModelStore.get(chat.userId, chat.roomId, "events") + // comparator: "date" + // descendingSort: true + // } delegate: EventDelegate {} } diff --git a/src/gui/Pages/Chat/TypingMembersBar.qml b/src/gui/Pages/Chat/TypingMembersBar.qml index 9749a45c..5b9d2b23 100644 --- a/src/gui/Pages/Chat/TypingMembersBar.qml +++ b/src/gui/Pages/Chat/TypingMembersBar.qml @@ -32,7 +32,7 @@ Rectangle { textFormat: Text.StyledText elide: Text.ElideRight text: { - let tm = chat.roomInfo.typing_members + const tm = JSON.parse(chat.roomInfo.typing_members) if (tm.length === 0) return "" if (tm.length === 1) return qsTr("%1 is typing...").arg(tm[0]) diff --git a/src/gui/Popups/ForgetRoomPopup.qml b/src/gui/Popups/ForgetRoomPopup.qml index 064fc6f6..8b3feb9a 100644 --- a/src/gui/Popups/ForgetRoomPopup.qml +++ b/src/gui/Popups/ForgetRoomPopup.qml @@ -21,7 +21,9 @@ BoxPopup { window.uiState.pageProperties.userId === userId && window.uiState.pageProperties.roomId === roomId) { - pageLoader.showPage("Default") + window.mainUI.pageLoader.showPrevious() || + window.mainUI.pageLoader.showPage("Default") + Qt.callLater(popup.destroy) } }) diff --git a/src/gui/Popups/SignOutPopup.qml b/src/gui/Popups/SignOutPopup.qml index a5df4972..8b512a4a 100644 --- a/src/gui/Popups/SignOutPopup.qml +++ b/src/gui/Popups/SignOutPopup.qml @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 +import ".." BoxPopup { id: popup @@ -28,7 +29,7 @@ BoxPopup { ok: button => { utils.makeObject( "Dialogs/ExportKeys.qml", - mainUI, + window.mainUI, { userId }, obj => { button.loading = Qt.binding(() => obj.exporting) @@ -44,10 +45,9 @@ BoxPopup { okClicked = true popup.ok() - if ((modelSources["Account"] || []).length < 2) { - pageLoader.showPage("AddAccount/AddAccount") - } else if (window.uiState.pageProperties.userId === userId) { - pageLoader.showPage("Default") + if (ModelStore.get("accounts").count < 2 || + window.uiState.pageProperties.userId === userId) { + window.mainUI.pageLoader.showPage("AddAccount/AddAccount") } py.callCoro("logout_client", [userId]) diff --git a/src/gui/PythonBridge/EventHandlers.qml b/src/gui/PythonBridge/Privates/EventHandlers.qml similarity index 66% rename from src/gui/PythonBridge/EventHandlers.qml rename to src/gui/PythonBridge/Privates/EventHandlers.qml index 18b78d09..3bc041a1 100644 --- a/src/gui/PythonBridge/EventHandlers.qml +++ b/src/gui/PythonBridge/Privates/EventHandlers.qml @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-or-later import QtQuick 2.12 +import ".." +import "../.." QtObject { function onExitRequested(exitCode) { @@ -16,10 +18,10 @@ QtObject { function onCoroutineDone(uuid, result, error, traceback) { - let onSuccess = py.privates.pendingCoroutines[uuid].onSuccess - let onError = py.privates.pendingCoroutines[uuid].onError + let onSuccess = Globals.pendingCoroutines[uuid].onSuccess + let onError = Globals.pendingCoroutines[uuid].onError - delete py.privates.pendingCoroutines[uuid] + delete Globals.pendingCoroutines[uuid] if (error) { const type = py.getattr(py.getattr(error, "__class__"), "__name__") @@ -74,14 +76,29 @@ QtObject { } - function onModelUpdated(syncId, data, serializedSyncId) { - if (serializedSyncId === "Account" || serializedSyncId[0] === "Room") { - py.callCoro("get_flat_mainpane_data", [], data => { - window.mainPaneModelSource = data - }) - } + function onModelItemInserted(syncId, index, item) { + // print("insert", syncId, index, item) + ModelStore.get(syncId).insert(index, item) + } - window.modelSources[serializedSyncId] = data - window.modelSourcesChanged() + + function onModelItemFieldChanged(syncId, oldIndex, newIndex, field, value){ + // print("change", syncId, oldIndex, newIndex, field, value) + const model = ModelStore.get(syncId) + model.setProperty(oldIndex, field, value) + + if (oldIndex !== newIndex) model.move(oldIndex, newIndex, 1) + } + + + function onModelItemDeleted(syncId, index) { + // print("del", syncId, index) + ModelStore.get(syncId).remove(index) + } + + + function onModelCleared(syncId) { + // print("clear", syncId) + ModelStore.get(syncId).clear() } } diff --git a/src/gui/PythonBridge/Privates/Globals.qml b/src/gui/PythonBridge/Privates/Globals.qml new file mode 100644 index 00000000..480872a3 --- /dev/null +++ b/src/gui/PythonBridge/Privates/Globals.qml @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +pragma Singleton +import QtQuick 2.12 + +QtObject { + readonly property var pendingCoroutines: ({}) +} diff --git a/src/gui/PythonBridge/Privates/qmldir b/src/gui/PythonBridge/Privates/qmldir new file mode 100644 index 00000000..135e5130 --- /dev/null +++ b/src/gui/PythonBridge/Privates/qmldir @@ -0,0 +1 @@ +singleton Globals 0.1 Globals.qml diff --git a/src/gui/PythonBridge/PythonBridge.qml b/src/gui/PythonBridge/PythonBridge.qml index 565d28bd..2d0d5f47 100644 --- a/src/gui/PythonBridge/PythonBridge.qml +++ b/src/gui/PythonBridge/PythonBridge.qml @@ -3,41 +3,16 @@ import QtQuick 2.12 import io.thp.pyotherside 1.5 import CppUtils 0.1 +import "Privates" Python { id: py - Component.onCompleted: { - for (var func in privates.eventHandlers) { - if (! privates.eventHandlers.hasOwnProperty(func)) continue - setHandler(func.replace(/^on/, ""), privates.eventHandlers[func]) - } - addImportPath("src") - addImportPath("qrc:/src") - - importNames("backend.qml_bridge", ["BRIDGE"], () => { - loadSettings(() => { - callCoro("saved_accounts.any_saved", [], any => { - if (any) { py.callCoro("load_saved_accounts", []) } - - py.startupAnyAccountsSaved = any - py.ready = true - }) - }) - }) - } - - - property bool ready: false - property bool startupAnyAccountsSaved: false readonly property QtObject privates: QtObject { - readonly property var pendingCoroutines: ({}) - readonly property EventHandlers eventHandlers: EventHandlers {} - function makeFuture(callback) { return Qt.createComponent("Future.qml") - .createObject(py, {bridge: py}) + .createObject(py, { bridge: py }) } } @@ -47,15 +22,10 @@ Python { } - function callSync(name, args=[]) { - return call_sync("BRIDGE.backend." + name, args) - } - - function callCoro(name, args=[], onSuccess=null, onError=null) { let uuid = name + "." + CppUtils.uuid() - privates.pendingCoroutines[uuid] = {onSuccess, onError} + Globals.pendingCoroutines[uuid] = {onSuccess, onError} let future = privates.makeFuture() @@ -75,7 +45,7 @@ Python { callCoro("get_client", [accountId], () => { let uuid = accountId + "." + name + "." + CppUtils.uuid() - privates.pendingCoroutines[uuid] = {onSuccess, onError} + Globals.pendingCoroutines[uuid] = {onSuccess, onError} let call_args = [accountId, name, uuid, args] diff --git a/src/gui/PythonBridge/PythonRootBridge.qml b/src/gui/PythonBridge/PythonRootBridge.qml new file mode 100644 index 00000000..5e6eb189 --- /dev/null +++ b/src/gui/PythonBridge/PythonRootBridge.qml @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import "Privates" + +PythonBridge { + Component.onCompleted: { + for (var func in eventHandlers) { + if (! eventHandlers.hasOwnProperty(func)) continue + setHandler(func.replace(/^on/, ""), eventHandlers[func]) + } + + addImportPath("src") + addImportPath("qrc:/src") + + importNames("backend.qml_bridge", ["BRIDGE"], () => { + loadSettings(() => { + callCoro("saved_accounts.any_saved", [], any => { + if (any) { callCoro("load_saved_accounts", []) } + + startupAnyAccountsSaved = any + ready = true + }) + }) + }) + } + + + property bool ready: false + property bool startupAnyAccountsSaved: false + + readonly property EventHandlers eventHandlers: EventHandlers {} +} diff --git a/src/gui/UI.qml b/src/gui/UI.qml index fc143db3..ca4734df 100644 --- a/src/gui/UI.qml +++ b/src/gui/UI.qml @@ -16,8 +16,7 @@ Item { property bool accountsPresent: - (modelSources["Account"] || []).length > 0 || - py.startupAnyAccountsSaved + ModelStore.get("accounts").count > 0 || py.startupAnyAccountsSaved readonly property alias shortcuts: shortcuts readonly property alias mainPane: mainPane diff --git a/src/gui/Utils.qml b/src/gui/Utils.qml index 1750ed7f..274cf110 100644 --- a/src/gui/Utils.qml +++ b/src/gui/Utils.qml @@ -135,30 +135,29 @@ QtObject { function processedEventText(ev) { - if (ev.event_type === "RoomMessageEmote") - return coloredNameHtml(ev.sender_name, ev.sender_id) + " " + - ev.content + const type = ev.event_type + const unknownMsg = type === "RoomMessageUnknown" + const sender = coloredNameHtml(ev.sender_name, ev.sender_id) - let unknown = ev.event_type === "RoomMessageUnknown" + if (type === "RoomMessageEmote") + return qsTr("%1 %2").arg(sender).arg(ev.content) - if (ev.event_type.startsWith("RoomMessage") && ! unknown) + if (type.startsWith("RoomMessage") && ! unknownMsg) return ev.content - if (ev.event_type.startsWith("RoomEncrypted")) return ev.content + if (type.startsWith("RoomEncrypted")) + return ev.content - let text = qsTr(ev.content).arg( - coloredNameHtml(ev.sender_name, ev.sender_id) - ) + if (ev.content.includes("%2")) { + const target = coloredNameHtml(ev.target_name, ev.target_id) + return qsTr(ev.content).arg(sender).arg(target) + } - if (text.includes("%2") && ev.target_id) - text = text.arg(coloredNameHtml(ev.target_name, ev.target_id)) - - return text + return qsTr(ev.content).arg(sender) } - function filterMatches(filter, text) { - let filter_lower = filter.toLowerCase() + const filter_lower = filter.toLowerCase() if (filter_lower === filter) { // Consider case only if filter isn't all lowercase (smart case) @@ -175,17 +174,11 @@ QtObject { } - function filterModelSource(source, filter_text, property="filter_string") { - if (! filter_text) return source - let results = [] - - for (let i = 0; i < source.length; i++) { - if (filterMatches(filter_text, source[i][property])) { - results.push(source[i]) - } + function filterMatchesAny(filter, ...texts) { + for (let text of texts) { + if (filterMatches(filter, text)) return true } - - return results + return false } @@ -257,14 +250,6 @@ QtObject { } - function getItem(array, mainKey, value) { - for (let i = 0; i < array.length; i++) { - if (array[i][mainKey] === value) { return array[i] } - } - return undefined - } - - function flickPages(flickable, pages) { // Adapt velocity and deceleration for the number of pages to flick. // If this is a repeated flicking, flick faster than a single flick. diff --git a/src/gui/Window.qml b/src/gui/Window.qml index c8ecab44..e9eb2918 100644 --- a/src/gui/Window.qml +++ b/src/gui/Window.qml @@ -28,7 +28,6 @@ ApplicationWindow { // NOTE: For JS object variables, the corresponding method to notify // key/value changes must be called manually, e.g. settingsChanged(). - property var modelSources: ({}) property var mainPaneModelSource: [] property var mainUI: null @@ -46,8 +45,6 @@ ApplicationWindow { property var hideErrorTypes: new Set() - readonly property alias py: py - function saveState(obj) { if (! obj.saveName || ! obj.saveProperties || @@ -75,7 +72,7 @@ ApplicationWindow { } - PythonBridge { id: py } + PythonRootBridge { id: py } Utils { id: utils } diff --git a/src/gui/qmldir b/src/gui/qmldir new file mode 100644 index 00000000..c46000b8 --- /dev/null +++ b/src/gui/qmldir @@ -0,0 +1 @@ +singleton ModelStore 0.1 ModelStore.qml diff --git a/submodules/gel b/submodules/gel new file mode 160000 index 00000000..0e796aac --- /dev/null +++ b/submodules/gel @@ -0,0 +1 @@ +Subproject commit 0e796aacc16388a164bab0bb0ce9dabc885ed7fa