From 67dde68126fe7f7bd032d73e1a9fcef5fec43090 Mon Sep 17 00:00:00 2001 From: miruka Date: Sun, 11 Aug 2019 08:01:22 -0400 Subject: [PATCH] Big performance refactoring & various improvements Instead of passing all sorts of events for the JS to handle and manually add to different data models, we now handle everything we can in Python. For any change, the python models send a sync event with their contents (no more than 4 times per second) to JS, and the QSyncable library's JsonListModel takes care of converting it to a QML ListModel and sending the appropriate signals. The SortFilterProxyModel library is not used anymore, the only case where we need to filter/sort something now is when the user interacts with the "Filter rooms" or "Filter members" fields. These cases are handled by a simple JS function. We now keep separated room and timeline models for different accounts, the previous approach of sharing all the data we could between accounts created a lot of complications (local echoes, decrypted messages replacing others, etc). The users's own account profile changes are now hidden in the timeline. On startup, if all events for a room were only own profile changes, more events will be loaded. Any kind of image format supported by Qt is now handled by the pyotherside image provider, instead of just PNG/JPG. SVGs which previously caused errors are supported as well. The typing members bar paddings/margins are fixed. The behavior of the avatar/"upload a profile picture" overlay is fixed. Config files read from disk are now cached (TODO: make them reloadable again). Pylint is not used anymore because of all its annoying false warnings and lack of understanding for dataclasses, it is replaced by flake8 with a custom config and various plugins. Debug mode is now considered on if the program was compiled with the right option, instead of taking an argument from CLI. When on, C++ will set a flag in the Window QML component. The loading screen is now unloaded after the UI is ready, where previously it just stayed in the background invisible and wasted CPU. The overall refactoring and improvements make us now able to handle rooms with thousand of members and no lazy-loading, where previously everything would freeze and simply scrolling up to load past events in any room would block the UI for a few seconds. --- .gitignore | 1 + .gitmodules | 6 +- TODO.md | 32 +- harmonyqml.pro | 4 +- live-reload.sh | 7 +- src/main.cpp | 11 + src/python/__init__.py | 2 +- .../{events/__init__.py => __main__.py} | 4 + src/python/app.py | 60 ++- src/python/backend.py | 70 ++- src/python/config_files.py | 23 +- src/python/events/app.py | 19 - src/python/events/event.py | 29 -- src/python/events/rooms.py | 108 ---- src/python/events/users.py | 64 --- src/python/html_filter.py | 33 +- src/python/image_provider.py | 97 ++-- src/python/matrix_client.py | 474 ++++++++++++------ src/python/models/__init__.py | 9 + src/python/models/items.py | 119 +++++ src/python/models/model.py | 116 +++++ src/python/models/model_item.py | 31 ++ src/python/models/model_store.py | 57 +++ src/python/pyotherside.py | 30 ++ src/python/pyotherside_events.py | 57 +++ src/python/utils.py | 24 +- src/qml/Base/HAvatar.qml | 2 +- src/qml/Base/HListModel.qml | 122 +---- src/qml/Base/HPage.qml | 2 +- src/qml/Base/HRoomAvatar.qml | 17 +- src/qml/Base/HTextField.qml | 5 +- src/qml/Base/HUserAvatar.qml | 17 +- src/qml/Chat/Banners/LeftBanner.qml | 1 - src/qml/Chat/Chat.qml | 34 +- src/qml/Chat/ChatSplitView.qml | 23 +- src/qml/Chat/RoomHeader.qml | 11 +- src/qml/Chat/RoomSidePane/MemberDelegate.qml | 9 +- src/qml/Chat/RoomSidePane/MembersView.qml | 23 +- src/qml/Chat/SendBox.qml | 15 +- src/qml/Chat/Timeline/EventContent.qml | 10 +- src/qml/Chat/Timeline/EventDelegate.qml | 14 +- src/qml/Chat/Timeline/EventList.qml | 35 +- src/qml/Chat/TypingMembersBar.qml | 27 +- src/qml/EventHandlers/includes.js | 9 - src/qml/EventHandlers/rooms.js | 113 ----- src/qml/EventHandlers/users.js | 23 - src/qml/Models/Accounts.qml | 13 - src/qml/Models/Devices.qml | 8 - src/qml/Models/RoomCategories.qml | 14 - src/qml/Models/Rooms.qml | 32 -- src/qml/Models/Timelines.qml | 24 - src/qml/Models/Users.qml | 26 - src/qml/Pages/EditAccount/EditAccount.qml | 14 +- src/qml/Pages/EditAccount/Profile.qml | 30 +- src/qml/Python.qml | 26 +- src/qml/Shortcuts.qml | 2 +- src/qml/SidePane/AccountDelegate.qml | 64 +-- src/qml/SidePane/AccountList.qml | 6 +- src/qml/SidePane/ExpandButton.qml | 1 - src/qml/SidePane/RoomCategoriesList.qml | 23 - src/qml/SidePane/RoomCategoryDelegate.qml | 73 --- src/qml/SidePane/RoomDelegate.qml | 49 +- src/qml/SidePane/RoomList.qml | 31 +- src/qml/UI.qml | 13 +- src/qml/Window.qml | 35 +- .../app.js => event_handlers.js} | 8 + src/qml/utils.js | 54 +- src/themes/Default.qpl | 2 +- submodules/SortFilterProxyModel | 1 - submodules/qsyncable | 1 + 70 files changed, 1261 insertions(+), 1288 deletions(-) rename src/python/{events/__init__.py => __main__.py} (68%) delete mode 100644 src/python/events/app.py delete mode 100644 src/python/events/event.py delete mode 100644 src/python/events/rooms.py delete mode 100644 src/python/events/users.py create mode 100644 src/python/models/__init__.py create mode 100644 src/python/models/items.py create mode 100644 src/python/models/model.py create mode 100644 src/python/models/model_item.py create mode 100644 src/python/models/model_store.py create mode 100644 src/python/pyotherside.py create mode 100644 src/python/pyotherside_events.py delete mode 100644 src/qml/EventHandlers/includes.js delete mode 100644 src/qml/EventHandlers/rooms.js delete mode 100644 src/qml/EventHandlers/users.js delete mode 100644 src/qml/Models/Accounts.qml delete mode 100644 src/qml/Models/Devices.qml delete mode 100644 src/qml/Models/RoomCategories.qml delete mode 100644 src/qml/Models/Rooms.qml delete mode 100644 src/qml/Models/Timelines.qml delete mode 100644 src/qml/Models/Users.qml delete mode 100644 src/qml/SidePane/RoomCategoriesList.qml delete mode 100644 src/qml/SidePane/RoomCategoryDelegate.qml rename src/qml/{EventHandlers/app.js => event_handlers.js} (65%) delete mode 160000 submodules/SortFilterProxyModel create mode 160000 submodules/qsyncable diff --git a/.gitignore b/.gitignore index b359ce8c..80fc9c07 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist .qmake.stash Makefile harmonyqml +harmonyqml.pro.user diff --git a/.gitmodules b/.gitmodules index be99fc84..0d0751c3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "submodules/SortFilterProxyModel"] - path = submodules/SortFilterProxyModel - url = https://github.com/oKcerG/SortFilterProxyModel +[submodule "submodules/qsyncable"] + path = submodules/qsyncable + url = https://github.com/benlau/qsyncable diff --git a/TODO.md b/TODO.md index 92480a2a..33838bea 100644 --- a/TODO.md +++ b/TODO.md @@ -2,20 +2,26 @@ - `QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling)` - Refactoring + - Remove copyrights + - Remove clip props when possible + - `property list` + - See Loader.enabled and async - Use [Animators](https://doc.qt.io/qt-5/qml-qtquick-animator.html) - Sendbox - - Use .mjs modules - SignIn/RememberAccount screens - SignIn must be in a flickable - - Don't bake in size properties for components - Unfinished work in button-refactor branch - Button can get "hoverEnabled: false" to let HoverHandlers work - Room Sidepane - Hide when window too small - Also save/load its size - - When qml syntax highlighting supports string interpolation, use them + - When qml syntax highlighting supports ES6 string interpolation, use them - Fixes + - Terrible performance using `QT_QPA_PLATFORM=wayland-egl`, must use `xcb` + - Reloading config files (cache) + - Ignore @ when filtering members + - Tiny invisible scrollbar - Update state.json page when accepting an invite - Run import in thread and AsyncClient.olm functions, they block async loop - Handle import keys errors @@ -28,10 +34,6 @@ - Message position after daybreak delegate - Keyboard flicking against top/bottom edge - Don't strip user spacing in html - - Past events loading (limit 100) freezes the GUI - need to move upsert func - to a WorkerScript - - `MessageDelegate.qml:63: TypeError: 'reloadPreviousItem' not a function` - - Horrible performance for big rooms - [hr not working](https://bugreports.qt.io/browse/QTBUG-74342) - UI @@ -40,6 +42,9 @@ - Accept/cancel buttons - Transitions + - Combine events so they take less space + - After combining is implemented, no need to hide our own profile changes. + - Room last activity time in RoomDelegate - When starting a long task, e.g. importing keys, quitting the page, and coming back, show the buttons as still loading until operation is done - Make invite/left banners look better in column mode @@ -59,12 +64,12 @@ - Support \ escaping - Improve avatar tooltips position, add stuff to room tooltips (last msg?) - Accept drag and dropping a picture in account settings to set avatar - - When all the events loaded on beginning in a room are name/avatar changes, - no last event room text is displayed (use sync filter?) - Show something when connection is lost or 429s happen - "Rejoin" LeftBanner button if room is public - Daybreak color + - Conversation breaks: show time of first new msg after break instead of big + blank space - Replies - `pyotherside.atexit()` - Sidepane @@ -82,7 +87,9 @@ - Leave room - Forget room warning popup - Prevent using the SendBox if no permission (power levels) - - Spinner when loading past room events, images or clicking buttons + - Prevent using an alias if that user is not in the room or no permission + - Spinner when loading account, past room events, images or clicking buttons + - Show account page as loading until profile initially retrieved - Theming - Don't create additional lines in theme conversion (braces) - Recursively merge default and user theme @@ -114,20 +121,17 @@ - Links preview - Client improvements + - Image provider: on failed conversion, way to show a "broken image" thumb? - Config file format - - Set Qt.application.* stuff from C++ - - [debug mode](https://docs.python.org/3/library/asyncio-dev.html) - Initial sync filter and lazy load, see weechat-matrix `_handle_login()` - See also `handle_response()`'s `keys_query` request - Direct chats category - - On sync, check messages API, if a limited sync timeline was received - Markdown: don't turn #things (no space) and `thing\n---` into title, disable `__` syntax for bold/italic - Push instead of replacing in stack view (remove getMemberFilter when done) - `
` scrollbar on overflow
   - When inviting someone to direct chat, room is "Empty room" until accepted,
     it should be the peer's display name instead.
-  - See `Qt.callLater()` potential usages
   - Animate RoomEventDelegate DayBreak apparition
   - Room subtitle: show things like "*Image*" instead of blank, etc
 
diff --git a/harmonyqml.pro b/harmonyqml.pro
index 1d40708d..b4e082b4 100644
--- a/harmonyqml.pro
+++ b/harmonyqml.pro
@@ -5,7 +5,7 @@ DEFINES += QT_DEPRECATED_WARNINGS
 CONFIG += warn_off c++11 release
 dev {
     CONFIG -= warn_off release
-    CONFIG += debug
+    CONFIG += debug qml_debug declarative_debug
 }
 
 BUILD_DIR = build
@@ -24,7 +24,7 @@ TARGET = harmonyqml
 
 # Libraries includes
 
-include(submodules/SortFilterProxyModel/SortFilterProxyModel.pri)
+include(submodules/qsyncable/qsyncable.pri)
 
 
 # Custom functions
diff --git a/live-reload.sh b/live-reload.sh
index 20b9bd70..db236d49 100755
--- a/live-reload.sh
+++ b/live-reload.sh
@@ -5,9 +5,14 @@
 
 # no_embedded (resources) is used to speed up the compilation
 
+export DISPLAY=${1:-:0}
+export QT_QPA_PLATFORM=xcb
+
+CFG='dev no_embedded'
+
 while true; do
     find src harmonyqml.pro -type f |
     entr -cdnr sh -c \
-        'qmake CONFIG+="dev no_embedded" && make && ./harmonyqml --debug'
+        "qmake harmonyqml.pro CONFIG+='$CFG' && make && ./harmonyqml"
     sleep 0.2
 done
diff --git a/src/main.cpp b/src/main.cpp
index 846389a0..ee7da12c 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -12,9 +12,20 @@
 int main(int argc, char *argv[]) {
     QApplication app(argc, argv);
 
+    QApplication::setOrganizationName("harmonyqml");
+    QApplication::setApplicationName("harmonyqml");
+    QApplication::setApplicationDisplayName("HarmonyQML");
+    QApplication::setApplicationVersion("0.1.0");
+
     QQmlEngine engine;
     QQmlContext *objectContext = new QQmlContext(engine.rootContext());
 
+#ifdef QT_DEBUG
+    objectContext->setContextProperty("debugMode", true);
+#else
+    objectContext->setContextProperty("debugMode", false);
+#endif
+
     QQmlComponent component(
         &engine,
         QFileInfo::exists("qrc:/qml/Window.qml") ?
diff --git a/src/python/__init__.py b/src/python/__init__.py
index a3e33ed5..ba8256a1 100644
--- a/src/python/__init__.py
+++ b/src/python/__init__.py
@@ -1,4 +1,4 @@
 # Copyright 2019 miruka
 # This file is part of harmonyqml, licensed under LGPLv3.
 
-from .app import APP
+from .app import APP  # noqa
diff --git a/src/python/events/__init__.py b/src/python/__main__.py
similarity index 68%
rename from src/python/events/__init__.py
rename to src/python/__main__.py
index fb5aa144..841bf636 100644
--- a/src/python/events/__init__.py
+++ b/src/python/__main__.py
@@ -1,2 +1,6 @@
 # Copyright 2019 miruka
 # This file is part of harmonyqml, licensed under LGPLv3.
+
+from .app import APP
+
+APP.test_run()
diff --git a/src/python/app.py b/src/python/app.py
index 82f445ae..1fd3e170 100644
--- a/src/python/app.py
+++ b/src/python/app.py
@@ -2,20 +2,20 @@
 # This file is part of harmonyqml, licensed under LGPLv3.
 
 import asyncio
+import logging as log
 import signal
 from concurrent.futures import Future
 from operator import attrgetter
-from pathlib import Path
 from threading import Thread
-from typing import Any, Coroutine, Dict, List, Optional, Sequence
+from typing import Coroutine, Sequence
 
 import uvloop
 from appdirs import AppDirs
 
-import pyotherside
+from . import __about__, pyotherside
+from .pyotherside_events import CoroutineDone
 
-from . import __about__
-from .events.app import CoroutineDone, ExitRequested
+log.getLogger().setLevel(log.INFO)
 
 
 class App:
@@ -30,15 +30,26 @@ class App:
         self.image_provider = ImageProvider(self)
         pyotherside.set_image_provider(self.image_provider.get)
 
-        self.loop        = asyncio.get_event_loop()
+        self.loop = asyncio.get_event_loop()
+
+        if not pyotherside.AVAILABLE:
+            self.set_debug(True, verbose=True)
+
         self.loop_thread = Thread(target=self._loop_starter)
         self.loop_thread.start()
 
 
-    def is_debug_on(self, cli_flags: Sequence[str] = ()) -> bool:
-        debug      = "-d" in cli_flags or "--debug" in cli_flags
-        self.debug = debug
-        return debug
+    def set_debug(self, enable: bool, verbose: bool = False) -> None:
+        if verbose:
+            log.getLogger().setLevel(log.DEBUG)
+
+        if enable:
+            log.info("Debug mode enabled.")
+            self.loop.set_debug(True)
+            self.debug = True
+        else:
+            self.loop.set_debug(False)
+            self.debug = False
 
 
     def _loop_starter(self) -> None:
@@ -53,11 +64,11 @@ class App:
 
     def _call_coro(self, coro: Coroutine, uuid: str) -> None:
         self.run_in_loop(coro).add_done_callback(
-            lambda future: CoroutineDone(uuid=uuid, result=future.result())
+            lambda future: CoroutineDone(uuid=uuid, result=future.result()),
         )
 
 
-    def call_backend_coro(self, name: str, uuid: str, args: Sequence[str] = ()
+    def call_backend_coro(self, name: str, uuid: str, args: Sequence[str] = (),
                          ) -> None:
         self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
 
@@ -72,22 +83,31 @@ class App:
 
 
     def pdb(self, additional_data: Sequence = ()) -> None:
-        # pylint: disable=all
-        ad = additional_data
-        rl = self.run_in_loop
-        ba = self.backend
+        ad = additional_data  # noqa
+        rl = self.run_in_loop  # noqa
+        ba = self.backend  # noqa
+        mo = self.backend.models # noqa
         cl = self.backend.clients
-        tcl = lambda user: cl[f"@test_{user}:matrix.org"]
+        tcl = lambda user: cl[f"@test_{user}:matrix.org"]  # noqa
+
+        from .models.items import Account, Room, Member, Event, Device  # noqa
 
         import json
-        jd = lambda obj: print(json.dumps(obj, indent=4, ensure_ascii=False))
+        jd = lambda obj: print(  # noqa
+            json.dumps(obj, indent=4, ensure_ascii=False),
+        )
 
-        print("\n=> Run `socat readline tcp:127.0.0.1:4444` in a terminal "
-              "to connect to pdb.")
+        log.info("\n=> Run `socat readline tcp:127.0.0.1:4444` in a terminal "
+                 "to connect to pdb.")
         import remote_pdb
         remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace()
 
 
+    def test_run(self) -> None:
+        self.call_backend_coro("load_settings", "")
+        self.call_backend_coro("load_saved_accounts", "")
+
+
 # Make CTRL-C work again
 signal.signal(signal.SIGINT, signal.SIG_DFL)
 
diff --git a/src/python/backend.py b/src/python/backend.py
index 1c0f37b7..b7839aca 100644
--- a/src/python/backend.py
+++ b/src/python/backend.py
@@ -2,15 +2,20 @@
 # This file is part of harmonyqml, licensed under LGPLv3.
 
 import asyncio
+import logging as log
 import random
-from typing import Dict, List, Optional, Set, Tuple
+from typing import DefaultDict, Dict, List, Optional, Tuple, Union
 
 import hsluv
 
+import nio
+
 from .app import App
-from .events import users
-from .html_filter import HTML_FILTER
 from .matrix_client import MatrixClient
+from .models.items import Account, Device, Event, Member, Room
+from .models.model_store import ModelStore
+
+ProfileResponse = Union[nio.ProfileGetResponse, nio.ProfileGetError]
 
 
 class Backend:
@@ -22,12 +27,19 @@ class Backend:
         self.ui_settings    = config_files.UISettings(self)
         self.ui_state       = config_files.UIState(self)
 
+        self.models = ModelStore(allowed_key_types={
+            Account,             # Logged-in accounts
+            (Device, str),       # Devices of user_id
+            (Room,   str),       # Rooms for user_id
+            (Member, str),       # Members in room_id
+            (Event,  str, str),  # Events for account user_id for room_id
+        })
+
         self.clients: Dict[str, MatrixClient] = {}
 
-        self.past_tokens:        Dict[str, str] =  {}    # {room_id: token}
-        self.fully_loaded_rooms: Set[str]       = set()  # {room_id}
-
-        self.pending_profile_requests: Set[str] = set()
+        self.profile_cache: Dict[str, nio.ProfileGetResponse] = {}
+        self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \
+                DefaultDict(asyncio.Lock)  # {user_id: lock}
 
 
     def __repr__(self) -> str:
@@ -42,11 +54,11 @@ class Backend:
                            device_id:  Optional[str] = None,
                            homeserver: str = "https://matrix.org") -> str:
         client = MatrixClient(
-            backend=self, user=user, homeserver=homeserver, device_id=device_id
+            self, user=user, homeserver=homeserver, device_id=device_id,
         )
         await client.login(password)
-        self.clients[client.user_id] = client
-        users.AccountUpdated(client.user_id)
+        self.clients[client.user_id]         = client
+        self.models[Account][client.user_id] = Account(client.user_id)
         return client.user_id
 
 
@@ -55,13 +67,15 @@ class Backend:
                             token:      str,
                             device_id:  str,
                             homeserver: str = "https://matrix.org") -> None:
+
         client = MatrixClient(
             backend=self,
-            user=user_id, homeserver=homeserver, device_id=device_id
+            user=user_id, homeserver=homeserver, device_id=device_id,
         )
         await client.resume(user_id=user_id, token=token, device_id=device_id)
-        self.clients[client.user_id] = client
-        users.AccountUpdated(client.user_id)
+
+        self.clients[client.user_id]         = client
+        self.models[Account][client.user_id] = Account(client.user_id)
 
 
     async def load_saved_accounts(self) -> Tuple[str, ...]:
@@ -83,8 +97,8 @@ class Backend:
     async def logout_client(self, user_id: str) -> None:
         client = self.clients.pop(user_id, None)
         if client:
+            self.models[Account].pop(client.user_id, None)
             await client.logout()
-            users.AccountDeleted(user_id)
 
 
     async def logout_all_clients(self) -> None:
@@ -115,20 +129,26 @@ class Backend:
         return (settings, ui_state, theme)
 
 
-    async def request_user_update_event(self, user_id: str) -> None:
-        if not self.clients:
-            await self.wait_until_client_exists()
+    async def get_profile(self, user_id: str) -> ProfileResponse:
+        if user_id in self.profile_cache:
+            return self.profile_cache[user_id]
 
-        client = self.clients.get(
-            user_id,
-            random.choice(tuple(self.clients.values()))
-        )
-        await client.request_user_update_event(user_id)
+        async with self.get_profile_locks[user_id]:
+            if not self.clients:
+                await self.wait_until_client_exists()
 
+            client = self.clients.get(
+                user_id,
+                random.choice(tuple(self.clients.values())),
+            )
 
-    @staticmethod
-    def inlinify(html: str) -> str:
-        return HTML_FILTER.filter_inline(html)
+            response = await client.get_profile(user_id)
+
+            if isinstance(response, nio.ProfileGetError):
+                log.warning("%s: %s", user_id, response)
+
+            self.profile_cache[user_id] = response
+            return response
 
 
     @staticmethod
diff --git a/src/python/config_files.py b/src/python/config_files.py
index bc86a43e..5c275b61 100644
--- a/src/python/config_files.py
+++ b/src/python/config_files.py
@@ -3,12 +3,14 @@
 
 import asyncio
 import json
+import logging as log
 from pathlib import Path
 from typing import Any, Dict
 
 import aiofiles
 from dataclasses import dataclass, field
 
+from . import pyotherside
 from .backend import Backend
 from .theme_parser import convert_to_qml
 from .utils import dict_update_recursive
@@ -23,9 +25,10 @@ class ConfigFile:
     backend:  Backend = field(repr=False)
     filename: str     = field()
 
+    _cached_read: str = field(default="", init=False, compare=False)
+
     @property
     def path(self) -> Path:
-        # pylint: disable=no-member
         return Path(self.backend.app.appdirs.user_config_dir) / self.filename
 
 
@@ -34,7 +37,13 @@ class ConfigFile:
 
 
     async def read(self):
-        return self.path.read_text()
+        if self._cached_read:
+            log.debug("Returning cached config %s", type(self).__name__)
+            return self._cached_read
+
+        log.debug("Reading config %s at %s", type(self).__name__, self.path)
+        self._cached_read = self.path.read_text()
+        return self._cached_read
 
 
     async def write(self, data) -> None:
@@ -76,7 +85,6 @@ class Accounts(JSONConfigFile):
 
 
     async def add(self, user_id: str) -> None:
-        # pylint: disable=no-member
         client = self.backend.clients[user_id]
 
         await self.write({
@@ -85,7 +93,7 @@ class Accounts(JSONConfigFile):
                 "homeserver": client.homeserver,
                 "token":      client.access_token,
                 "device_id":  client.device_id,
-            }
+            },
         })
 
 
@@ -119,14 +127,12 @@ class UIState(JSONConfigFile):
 
     @property
     def path(self) -> Path:
-        # pylint: disable=no-member
         return Path(self.backend.app.appdirs.user_data_dir) / self.filename
 
 
     async def default_data(self) -> JsonData:
         return {
             "collapseAccounts":    {},
-            "collapseCategories":  {},
             "page":                "Pages/Default.qml",
             "pageProperties":      {},
             "sidePaneManualWidth": None,
@@ -137,7 +143,6 @@ class UIState(JSONConfigFile):
 class Theme(ConfigFile):
     @property
     def path(self) -> Path:
-        # pylint: disable=no-member
         data_dir = Path(self.backend.app.appdirs.user_data_dir)
         return data_dir / "themes" / self.filename
 
@@ -148,7 +153,9 @@ class Theme(ConfigFile):
 
 
     async def read(self) -> str:
-        # pylint: disable=no-member
+        if not pyotherside.AVAILABLE:
+            return ""
+
         if self.backend.app.debug:
             return convert_to_qml(await self.default_data())
 
diff --git a/src/python/events/app.py b/src/python/events/app.py
deleted file mode 100644
index 298349a4..00000000
--- a/src/python/events/app.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Copyright 2019 miruka
-# This file is part of harmonyqml, licensed under LGPLv3.
-
-from typing import Any
-
-from dataclasses import dataclass, field
-
-from .event import Event
-
-
-@dataclass
-class ExitRequested(Event):
-    exit_code: int = 0
-
-
-@dataclass
-class CoroutineDone(Event):
-    uuid:   str = field()
-    result: Any = None
diff --git a/src/python/events/event.py b/src/python/events/event.py
deleted file mode 100644
index d06cabab..00000000
--- a/src/python/events/event.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# Copyright 2019 miruka
-# This file is part of harmonyqml, licensed under LGPLv3.
-
-from enum import Enum
-from typing import Any
-
-from dataclasses import dataclass
-
-import pyotherside
-
-
-@dataclass
-class Event:
-    def __post_init__(self) -> None:
-        # CPython >= 3.6 or any Python >= 3.7 needed for correct dict order
-        args = [
-            # pylint: disable=no-member
-            self._process_field(getattr(self, field))
-            for field in self.__dataclass_fields__  # type: ignore
-        ]
-        pyotherside.send(type(self).__name__, *args)
-
-
-    @staticmethod
-    def _process_field(value: Any) -> Any:
-        if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
-            return value.value
-
-        return value
diff --git a/src/python/events/rooms.py b/src/python/events/rooms.py
deleted file mode 100644
index 7d0b1fc0..00000000
--- a/src/python/events/rooms.py
+++ /dev/null
@@ -1,108 +0,0 @@
-# Copyright 2019 miruka
-# This file is part of harmonyqml, licensed under LGPLv3.
-
-from datetime import datetime
-from typing import Any, Dict, List, Sequence, Type
-
-from dataclasses import dataclass, field
-
-import nio
-from nio.rooms import MatrixRoom
-
-from .event import Event
-
-
-@dataclass
-class RoomUpdated(Event):
-    user_id:        str                      = field()
-    category:       str                      = field()
-    room_id:        str                      = field()
-    display_name:   str                      = ""
-    avatar_url:     str                      = ""
-    topic:          str                      = ""
-    members:        Sequence[Dict[str, Any]] = ()
-    typing_members: Sequence[str]            = ()
-    inviter_id:     str                      = ""
-
-
-    @classmethod
-    def from_nio(cls,
-                 user_id:  str,
-                 category: str,
-                 room:     MatrixRoom,
-                 info:     nio.RoomInfo) -> "RoomUpdated":
-
-        typing: List[str] = []
-
-        if hasattr(info, "ephemeral"):
-            for ev in info.ephemeral:
-                if isinstance(ev, nio.TypingNoticeEvent):
-                    typing = ev.users
-
-        name = room.name or room.canonical_alias
-
-        if not name:
-            name = room.group_name()
-            name = "" if name == "Empty room?" else name
-
-        members = [{"userId": m.user_id, "powerLevel": m.power_level}
-                   for m in room.users.values()]
-
-        return cls(
-            user_id        = user_id,
-            category       = category,
-            room_id        = room.room_id,
-            display_name   = name,
-            avatar_url     = room.gen_avatar_url or "",
-            topic          = room.topic or "",
-            inviter_id     = getattr(room, "inviter", "") or "",
-            members        = members,
-            typing_members = typing,
-        )
-
-
-@dataclass
-class RoomForgotten(Event):
-    user_id:  str = field()
-    room_id:  str = field()
-
-
-@dataclass
-class RoomMemberUpdated(Event):
-    room_id: str  = field()
-    user_id: str  = field()
-    typing:  bool = field()
-
-
-@dataclass
-class RoomMemberDeleted(Event):
-    room_id: str  = field()
-    user_id: str  = field()
-
-
-# Timeline
-
-@dataclass
-class TimelineEventReceived(Event):
-    event_type:    Type[nio.Event] = field()
-    room_id:       str             = field()
-    event_id:      str             = field()
-    sender_id:     str             = field()
-    date:          datetime        = field()
-    content:       str             = field()
-    is_local_echo: bool            = False
-
-    target_user_id: str = ""
-
-    @classmethod
-    def from_nio(cls, room: MatrixRoom, ev: nio.Event, **fields
-                ) -> "TimelineEventReceived":
-        return cls(
-            event_type = type(ev),
-            room_id    = room.room_id,
-            event_id   = ev.event_id,
-            sender_id  = ev.sender,
-            date       = datetime.fromtimestamp(ev.server_timestamp / 1000),
-            target_user_id = getattr(ev, "state_key", "") or "",
-            **fields
-        )
diff --git a/src/python/events/users.py b/src/python/events/users.py
deleted file mode 100644
index d5c7b2c9..00000000
--- a/src/python/events/users.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# Copyright 2019 miruka
-# This file is part of harmonyqml, licensed under LGPLv3.
-
-from datetime import datetime
-from enum import Enum
-
-from dataclasses import dataclass, field
-
-from nio.rooms import MatrixUser
-
-from .event import Event
-
-# Logged-in accounts
-
-@dataclass
-class AccountUpdated(Event):
-    user_id: str = field()
-
-
-@dataclass
-class AccountDeleted(Event):
-    user_id: str = field()
-
-
-# Accounts and room members details
-
-@dataclass
-class UserUpdated(Event):
-    user_id:      str  = field()
-    display_name: str  = ""
-    avatar_url:   str  = ""
-
-    @classmethod
-    def from_nio(cls, user: MatrixUser) -> "UserUpdated":
-        return cls(
-            user_id      = user.user_id,
-            display_name = user.display_name or "",
-            avatar_url   = user.avatar_url or "",
-        )
-
-
-# Devices
-
-class Trust(Enum):
-    blacklisted = -1
-    undecided   = 0
-    trusted     = 1
-
-
-@dataclass
-class DeviceUpdated(Event):
-    user_id:        str      = field()
-    device_id:      str      = field()
-    ed25519_key:    str      = field()
-    trust:          Trust    = Trust.undecided
-    display_name:   str      = ""
-    last_seen_ip:   str      = ""
-    last_seen_date: datetime = field(default_factory=lambda: datetime(1, 1, 1))
-
-
-@dataclass
-class DeviceDeleted(Event):
-    user_id:   str = field()
-    device_id: str = field()
diff --git a/src/python/html_filter.py b/src/python/html_filter.py
index b3676c7e..710c4ac6 100644
--- a/src/python/html_filter.py
+++ b/src/python/html_filter.py
@@ -34,7 +34,7 @@ class HtmlFilter:
 
         # hard_wrap: convert all \n to 
without required two spaces self._markdown_to_html = mistune.Markdown( - hard_wrap=True, renderer=MarkdownRenderer() + hard_wrap=True, renderer=MarkdownRenderer(), ) self._markdown_to_html.block.default_rules = [ @@ -56,7 +56,7 @@ class HtmlFilter: if not outgoing: text = re.sub( - r"(^\s*>.*)", r'\1', text + r"(^\s*>.*)", r'\1', text, ) return text @@ -84,15 +84,15 @@ class HtmlFilter: text = re.sub( r"<(p|br/?)>(\s*>.*)(!?)", r'<\1>\2\3', - text + text, ) return text def sanitize_settings(self, inline: bool = False) -> dict: - # https://matrix.org/docs/spec/client_server/latest.html#m-room-message-msgtypes - # TODO: mx-reply, audio, video + # https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes + # TODO: mx-reply, audio, video, the new hidden thing inline_tags = {"font", "a", "sup", "sub", "b", "i", "s", "u", "code"} tags = inline_tags | { @@ -103,8 +103,7 @@ class HtmlFilter: } inlines_attributes = { - # TODO: translate font attrs to qt html subset - "font": {"data-mx-bg-color", "data-mx-color"}, + "font": {"color"}, "a": {"href"}, "code": {"class"}, } @@ -119,9 +118,10 @@ class HtmlFilter: "attributes": inlines_attributes if inline else attributes, "empty": {} if inline else {"hr", "br", "img"}, "separate": {"a"} if inline else { - "a", "p", "li", "table", "tr", "th", "td", "br", "hr" + "a", "p", "li", "table", "tr", "th", "td", "br", "hr", }, "whitespace": {}, + "keep_typographic_whitespace": True, "add_nofollow": False, "autolink": { "link_regexes": self.link_regexes, @@ -135,25 +135,26 @@ class HtmlFilter: sanitizer.tag_replacer("em", "i"), sanitizer.tag_replacer("strike", "s"), sanitizer.tag_replacer("del", "s"), - sanitizer.tag_replacer("span", "font"), - self._remove_empty_font, sanitizer.tag_replacer("form", "p"), sanitizer.tag_replacer("div", "p"), sanitizer.tag_replacer("caption", "p"), sanitizer.target_blank_noopener, + self._process_span_font, ], "element_postprocessors": [], "is_mergeable": lambda e1, e2: e1.attrib == e2.attrib, } - def _remove_empty_font(self, el: HtmlElement) -> HtmlElement: - if el.tag != "font": + @staticmethod + def _process_span_font(el: HtmlElement) -> HtmlElement: + if el.tag not in ("span", "font"): return el - settings = self.sanitize_settings() - if not settings["attributes"]["font"] & set(el.keys()): - el.clear() + color = el.attrib.pop("data-mx-color", None) + if color: + el.tag = "font" + el.attrib["color"] = color return el @@ -191,7 +192,7 @@ class HtmlFilter: @staticmethod def _is_image_path(link: str) -> bool: return bool(re.match( - r".+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", link, re.IGNORECASE + r".+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", link, re.IGNORECASE, )) diff --git a/src/python/image_provider.py b/src/python/image_provider.py index 41335106..22633dcf 100644 --- a/src/python/image_provider.py +++ b/src/python/image_provider.py @@ -2,30 +2,35 @@ # This file is part of harmonyqml, licensed under LGPLv3. import asyncio +import logging as log import random import re +from dataclasses import dataclass, field from io import BytesIO from pathlib import Path -from typing import Tuple +from typing import Optional, Tuple from urllib.parse import urlparse import aiofiles -from dataclasses import dataclass, field from PIL import Image as PILImage import nio -import pyotherside from nio.api import ResizingMethod -Size = Tuple[int, int] -ImageData = Tuple[bytearray, Size, int] # last int: pyotherside format enum +from . import pyotherside, utils +from .pyotherside import ImageData, Size + +POSFormat = int CONCURRENT_DOWNLOADS_LIMIT = asyncio.BoundedSemaphore(8) +with BytesIO() as img_out: + PILImage.new("RGBA", (1, 1), (0, 0, 0, 0)).save(img_out, "PNG") + TRANSPARENT_1X1_PNG = (img_out.getvalue(), pyotherside.format_data) + @dataclass class Thumbnail: - # pylint: disable=no-member provider: "ImageProvider" = field() mxc: str = field() width: int = field() @@ -70,7 +75,6 @@ class Thumbnail: @property def local_path(self) -> Path: - # pylint: disable=bad-string-format-type parsed = urlparse(self.mxc) name = "%s.%03d.%03d.%s" % ( parsed.path.lstrip("/"), @@ -81,14 +85,41 @@ class Thumbnail: return self.provider.cache / parsed.netloc / name - async def download(self) -> bytes: + async def read_data(self, data: bytes, mime: Optional[str], + ) -> Tuple[bytes, POSFormat]: + if mime == "image/svg+xml": + return (data, pyotherside.format_svg_data) + + if mime in ("image/jpeg", "image/png"): + return (data, pyotherside.format_data) + + try: + with BytesIO(data) as img_in: + image = PILImage.open(img_in) + + if image.mode == "RGB": + return (data, pyotherside.format_rgb888) + + if image.mode == "RGBA": + return (data, pyotherside.format_argb32) + + with BytesIO() as img_out: + image.save(img_out, "PNG") + return (img_out.getvalue(), pyotherside.format_data) + + except OSError as err: + log.warning("Unable to process image: %s - %r", self.http, err) + return TRANSPARENT_1X1_PNG + + + async def download(self) -> Tuple[bytes, POSFormat]: client = random.choice( - tuple(self.provider.app.backend.clients.values()) + tuple(self.provider.app.backend.clients.values()), ) parsed = urlparse(self.mxc) async with CONCURRENT_DOWNLOADS_LIMIT: - response = await client.thumbnail( + resp = await client.thumbnail( server_name = parsed.netloc, media_id = parsed.path.lstrip("/"), width = self.server_size[0], @@ -96,37 +127,37 @@ class Thumbnail: method = self.resize_method, ) - if isinstance(response, nio.ThumbnailError): - # Return a transparent 1x1 PNG - with BytesIO() as img_out: - PILImage.new("RGBA", (1, 1), (0, 0, 0, 0)).save(img_out, "PNG") - return img_out.getvalue() + if isinstance(resp, nio.ThumbnailError): + log.warning("Downloading thumbnail failed - %s", resp) + return TRANSPARENT_1X1_PNG - body = response.body - - if response.content_type not in ("image/jpeg", "image/png"): - with BytesIO(body) as img_in, BytesIO() as img_out: - PILImage.open(img_in).save(img_out, "PNG") - body = img_out.getvalue() + body, pos_format = await self.read_data(resp.body, resp.content_type) self.local_path.parent.mkdir(parents=True, exist_ok=True) async with aiofiles.open(self.local_path, "wb") as file: - await file.write(body) + # body might have been converted, always save the original image. + await file.write(resp.body) - return body + return (body, pos_format) + + + async def local_read(self) -> Tuple[bytes, POSFormat]: + data = self.local_path.read_bytes() + with BytesIO(data) as data_io: + return await self.read_data(data, utils.guess_mime(data_io)) async def get_data(self) -> ImageData: try: - body = self.local_path.read_bytes() - except FileNotFoundError: - body = await self.download() + data, pos_format = await self.local_read() + except (OSError, IOError, FileNotFoundError): + data, pos_format = await self.download() - with BytesIO(body) as img_in: + with BytesIO(data) as img_in: real_size = PILImage.open(img_in).size - return (bytearray(body), real_size, pyotherside.format_data) + return (bytearray(data), real_size, pos_format) class ImageProvider: @@ -141,7 +172,13 @@ class ImageProvider: if requested_size[0] < 1 or requested_size[1] < 1: raise ValueError(f"width or height < 1: {requested_size!r}") + try: + thumb = Thumbnail(self, image_id, *requested_size) + except ValueError as err: + log.warning(err) + data, pos_format = TRANSPARENT_1X1_PNG + return (bytearray(data), (1, 1), pos_format) + return asyncio.run_coroutine_threadsafe( - Thumbnail(self, image_id, *requested_size).get_data(), - self.app.loop + thumb.get_data(), self.app.loop, ).result() diff --git a/src/python/matrix_client.py b/src/python/matrix_client.py index bf7ddff5..7f681ca4 100644 --- a/src/python/matrix_client.py +++ b/src/python/matrix_client.py @@ -12,18 +12,15 @@ from datetime import datetime from enum import Enum from pathlib import Path from types import ModuleType -from typing import DefaultDict, Dict, Optional, Type, Union +from typing import DefaultDict, Dict, Optional, Set, Tuple, Type, Union from uuid import uuid4 -import filetype - import nio -from nio.rooms import MatrixRoom -from . import __about__ -from .events import rooms, users -from .events.rooms import TimelineEventReceived +from . import __about__, utils from .html_filter import HTML_FILTER +from .models.items import Account, Event, Member, Room +from .models.model_store import ModelStore class UploadError(Enum): @@ -38,19 +35,11 @@ class MatrixClient(nio.AsyncClient): user: str, homeserver: str = "https://matrix.org", device_id: Optional[str] = None) -> None: - # TODO: ensure homeserver starts with a scheme:// - from .backend import Backend - self.backend: Backend = backend - - self.sync_task: Optional[asyncio.Future] = None - - self.send_locks: DefaultDict[str, asyncio.Lock] = \ - DefaultDict(asyncio.Lock) # {room_id: lock} - - store = Path(self.backend.app.appdirs.user_data_dir) / "encryption" + store = Path(backend.app.appdirs.user_data_dir) / "encryption" store.mkdir(parents=True, exist_ok=True) + # TODO: ensure homeserver starts by a scheme:// # TODO: pass a ClientConfig with a pickle key super().__init__( homeserver = homeserver, @@ -59,12 +48,31 @@ class MatrixClient(nio.AsyncClient): store_path = store, ) + from .backend import Backend + self.backend: Backend = backend + self.models: ModelStore = self.backend.models + + self.sync_task: Optional[asyncio.Future] = None + self.first_sync_happened: asyncio.Event = asyncio.Event() + + self.send_locks: DefaultDict[str, asyncio.Lock] = \ + DefaultDict(asyncio.Lock) # {room_id: lock} + + self.past_tokens: Dict[str, str] = {} # {room_id: token} + self.fully_loaded_rooms: Set[str] = set() # {room_id} + self.loaded_once_rooms: Set[str] = set() # {room_id} + + self.local_echoes_uuid: Set[str] = set() + self.resolved_echoes: Dict[str, str] = {} # {event_id: echo_uuid} + + self.skipped_events: DefaultDict[str, int] = DefaultDict(lambda: 0) + self.connect_callbacks() def __repr__(self) -> str: return "%s(user_id=%r, homeserver=%r, device_id=%r)" % ( - type(self).__name__, self.user_id, self.homeserver, self.device_id + type(self).__name__, self.user_id, self.homeserver, self.device_id, ) @@ -86,17 +94,10 @@ class MatrixClient(nio.AsyncClient): with suppress(AttributeError): self.add_event_callback(getattr(self, f"on{name}"), class_) - - async def start_syncing(self) -> None: - self.sync_task = asyncio.ensure_future( - self.sync_forever(timeout=10_000) + self.add_ephemeral_callback( + self.onTypingNoticeEvent, nio.events.TypingNoticeEvent, ) - def callback(task): - raise task.exception() - - self.sync_task.add_done_callback(callback) - @property def default_device_name(self) -> str: @@ -107,19 +108,19 @@ class MatrixClient(nio.AsyncClient): async def login(self, password: str, device_name: str = "") -> None: response = await super().login( - password, device_name or self.default_device_name + password, device_name or self.default_device_name, ) if isinstance(response, nio.LoginError): - print(response) + log.error(response) else: - await self.start_syncing() + await self.start() async def resume(self, user_id: str, token: str, device_id: str) -> None: response = nio.LoginResponse(user_id, device_id, token) await self.receive_response(response) - await self.start_syncing() + await self.start() async def logout(self) -> None: @@ -131,27 +132,29 @@ class MatrixClient(nio.AsyncClient): await self.close() - async def request_user_update_event(self, user_id: str) -> None: - if user_id in self.backend.pending_profile_requests: - return - self.backend.pending_profile_requests.add(user_id) + async def start(self) -> None: + def on_profile_response(future) -> None: + resp = future.result() + if isinstance(resp, nio.ProfileGetResponse): + account = self.models[Account][self.user_id] + account.profile_updated = datetime.now() + account.display_name = resp.displayname or "" + account.avatar_url = resp.avatar_url or "" - response = await self.get_profile(user_id) + ft = asyncio.ensure_future(self.backend.get_profile(self.user_id)) + ft.add_done_callback(on_profile_response) - if isinstance(response, nio.ProfileGetError): - log.warning("%s: %s", user_id, response) + def on_unexpected_sync_stop(future) -> None: + raise future.exception() - users.UserUpdated( - user_id = user_id, - display_name = getattr(response, "displayname", "") or "", - avatar_url = getattr(response, "avatar_url", "") or "", + self.sync_task = asyncio.ensure_future( + self.sync_forever(timeout=10_000), ) - - self.backend.pending_profile_requests.discard(user_id) + self.sync_task.add_done_callback(on_unexpected_sync_stop) @property - def all_rooms(self) -> Dict[str, MatrixRoom]: + def all_rooms(self) -> Dict[str, nio.MatrixRoom]: return {**self.invited_rooms, **self.rooms} @@ -162,36 +165,48 @@ class MatrixClient(nio.AsyncClient): text = text[1:] if text.startswith("/me ") and not escape: - event_type = nio.RoomMessageEmote + event_type = nio.RoomMessageEmote.__name__ text = text[len("/me "): ] content = {"body": text, "msgtype": "m.emote"} to_html = HTML_FILTER.from_markdown_inline(text, outgoing=True) - echo_html = HTML_FILTER.from_markdown_inline(text) else: - event_type = nio.RoomMessageText + event_type = nio.RoomMessageText.__name__ content = {"body": text, "msgtype": "m.text"} to_html = HTML_FILTER.from_markdown(text, outgoing=True) - echo_html = HTML_FILTER.from_markdown(text) if to_html not in (html.escape(text), f"

{html.escape(text)}

"): content["format"] = "org.matrix.custom.html" content["formatted_body"] = to_html - TimelineEventReceived( - event_type = event_type, - room_id = room_id, - event_id = f"local_echo.{uuid4()}", + uuid = str(uuid4()) + self.local_echoes_uuid.add(uuid) + + our_info = self.models[Member, room_id][self.user_id] + display_content = content.get("formatted_body") or content["body"] + + local = Event( + client_id = f"echo-{uuid}", + event_id = "", + event_type = event_type, + date = datetime.now(), + content = display_content, + inline_content = HTML_FILTER.filter_inline(display_content), + is_local_echo = True, + sender_id = self.user_id, - date = datetime.now(), - content = echo_html, - is_local_echo = True, + sender_name = our_info.display_name, + sender_avatar = our_info.avatar_url, ) + for user_id in self.models[Account]: + if user_id in self.models[Member, room_id]: + self.models[Event, user_id, room_id][f"echo-{uuid}"] = local async with self.send_locks[room_id]: response = await self.room_send( room_id = room_id, message_type = "m.room.message", content = content, + tx_id = uuid, ignore_unverified_devices = True, ) @@ -199,44 +214,62 @@ class MatrixClient(nio.AsyncClient): log.error("Failed to send message: %s", response) - async def load_past_events(self, room_id: str, limit: int = 25) -> bool: - if room_id in self.backend.fully_loaded_rooms: + async def load_past_events(self, room_id: str) -> bool: + if room_id in self.fully_loaded_rooms: return False + await self.first_sync_happened.wait() + response = await self.room_messages( room_id = room_id, - start = self.backend.past_tokens[room_id], - limit = limit, + start = self.past_tokens[room_id], + limit = 100 if room_id in self.loaded_once_rooms else 25, ) + self.loaded_once_rooms.add(room_id) more_to_load = True - if self.backend.past_tokens[room_id] == response.end: - self.backend.fully_loaded_rooms.add(room_id) + if self.past_tokens[room_id] == response.end: + self.fully_loaded_rooms.add(room_id) more_to_load = False - self.backend.past_tokens[room_id] = response.end + self.past_tokens[room_id] = response.end for event in response.chunk: for cb in self.event_callbacks: if (cb.filter is None or isinstance(event, cb.filter)): - await cb.func( - self.all_rooms[room_id], event, from_past=True - ) + await cb.func(self.all_rooms[room_id], event) return more_to_load + async def load_rooms_without_visible_events(self) -> None: + for room_id in self.models[Room, self.user_id]: + asyncio.ensure_future( + self._load_room_without_visible_events(room_id), + ) + + + async def _load_room_without_visible_events(self, room_id: str) -> None: + events = self.models[Event, self.user_id, room_id] + more = True + + while self.skipped_events[room_id] and not events and more: + more = await self.load_past_events(room_id) + + async def room_forget(self, room_id: str) -> None: await super().room_forget(room_id) - rooms.RoomForgotten(user_id=self.user_id, room_id=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, room_id], None) async def upload_file(self, path: Union[Path, str]) -> str: path = Path(path) with open(path, "rb") as file: - mime = filetype.guess_mime(file) + mime = utils.guess_mime(file) file.seek(0, 0) resp = await self.upload(file, mime, path.name) @@ -253,7 +286,7 @@ class MatrixClient(nio.AsyncClient): return UploadError.unknown.value - async def set_avatar_from_file(self, path: Union[Path, str] + async def set_avatar_from_file(self, path: Union[Path, str], ) -> Union[bool, str]: resp = await self.upload_file(path) @@ -264,30 +297,153 @@ class MatrixClient(nio.AsyncClient): return True + # Functions to register data into models + + async def set_room_last_event(self, room_id: str, item: Event) -> None: + room = self.models[Room, self.user_id][room_id] + + if room.last_event is None: + room.last_event = item.__dict__ + return + + for_us = item.target_id in self.backend.clients + is_member_ev = item.event_type == nio.RoomMemberEvent.__name__ + + # If there were no better events available to show previously + prev_is_member_ev = \ + room.last_event["event_type"] == nio.RoomMemberEvent.__name__ + + if is_member_ev and for_us and not prev_is_member_ev: + return + + if item.date < room.last_event["date"]: # If this is a past event + return + + room.last_event = item.__dict__ + + + async def register_nio_room(self, room: nio.MatrixRoom, left: bool = False, + ) -> None: + # Generate the room name + name = room.name or room.canonical_alias + if not name: + name = room.group_name() + name = "" if name == "Empty room?" else name + + # Add room + try: + last_ev = self.models[Room, self.user_id][room.room_id].last_event + except KeyError: + last_ev = None + + self.models[Room, self.user_id][room.room_id] = Room( + room_id = room.room_id, + display_name = name, + avatar_url = room.gen_avatar_url or "", + topic = room.topic or "", + inviter_id = getattr(room, "inviter", "") or "", + left = left, + filter_string = " ".join({name, room.topic or ""}).strip(), + last_event = last_ev, + ) + + # Add the room members to the added room + new_dict = { + user_id: Member( + user_id = user_id, + display_name = room.user_name(user_id) # disambiguated + if member.display_name else "", + avatar_url = member.avatar_url or "", + typing = user_id in room.typing_users, + power_level = member.power_level, + filter_string = " ".join({ + member.name, room.user_name(user_id), + }).strip(), + ) for user_id, member in room.users.items() + } + self.models[Member, room.room_id].update(new_dict) + + + async def get_member_name_avatar(self, room_id: str, user_id: str, + ) -> Tuple[str, str]: + try: + item = self.models[Member, room_id][user_id] + except KeyError: # e.g. user is not anymore in the room + info = await self.backend.get_profile(user_id) + + return (info.displayname or "", info.avatar_url or "") \ + if isinstance(info, nio.ProfileGetResponse) else \ + ("", "") + else: + return (item.display_name, item.avatar_url) + + + async def register_nio_event(self, + room: nio.MatrixRoom, + ev: nio.Event, + content: str) -> None: + + await self.register_nio_room(room) + + sender_name, sender_avatar = \ + await self.get_member_name_avatar(room.room_id, ev.sender) + + target_id = getattr(ev, "state_key", "") or "" + + target_name, target_avatar = \ + await self.get_member_name_avatar(room.room_id, target_id) \ + if target_id else ("", "") + + # Create Event ModelItem + item = Event( + client_id = ev.event_id, + event_id = ev.event_id, + event_type = type(ev).__name__, + content = content, + inline_content = HTML_FILTER.filter_inline(content), + date = datetime.fromtimestamp(ev.server_timestamp / 1000), + + sender_id = ev.sender, + sender_name = sender_name, + sender_avatar = sender_avatar, + + target_id = target_id, + target_name = target_name, + target_avatar = target_avatar, + ) + + # Add the Event to model + if ev.transaction_id in self.local_echoes_uuid: + self.resolved_echoes[ev.event_id] = ev.transaction_id + self.local_echoes_uuid.discard(ev.transaction_id) + item.client_id = f"echo-{ev.transaction_id}" + + elif ev.sender in self.backend.clients: + client = self.backend.clients[ev.sender] + + # Wait until our other account has no more pending local echoes, + # so that we can know if this event should replace an echo + # from that client by finding its ID in the resolved_echoes dict. + # Server only gives back the transaction ID to the original sender. + while client.local_echoes_uuid: # while there are pending echoes + await asyncio.sleep(0.1) + + with suppress(KeyError): + item.client_id = f"echo-{client.resolved_echoes[ev.event_id]}" + + self.models[Event, self.user_id, room.room_id][item.client_id] = item + + await self.set_room_last_event(room.room_id, item) + + # Callbacks for nio responses async def onSyncResponse(self, resp: nio.SyncResponse) -> None: - up = rooms.RoomUpdated.from_nio - - for room_id, info in resp.rooms.invite.items(): - room = self.invited_rooms[room_id] - - for member in room.users.values(): - users.UserUpdated.from_nio(member) - - up(self.user_id, "Invites", room, info) - for room_id, info in resp.rooms.join.items(): - room = self.rooms[room_id] - - for member in room.users.values(): - users.UserUpdated.from_nio(member) - - if room_id not in self.backend.past_tokens: - self.backend.past_tokens[room_id] = info.timeline.prev_batch - - up(self.user_id, "Rooms", room, info) + if room_id not in self.past_tokens: + self.past_tokens[room_id] = info.timeline.prev_batch + # TODO: way of knowing if a nio.MatrixRoom is left for room_id, info in resp.rooms.leave.items(): # TODO: handle in nio, these are rooms that were left before # starting the client. @@ -299,7 +455,12 @@ class MatrixClient(nio.AsyncClient): if isinstance(ev, nio.RoomMemberEvent): await self.onRoomMemberEvent(self.rooms[room_id], ev) - up(self.user_id, "Left", self.rooms[room_id], info) + await self.register_nio_room(self.rooms[room_id], left=True) + + if not self.first_sync_happened.is_set(): + asyncio.ensure_future(self.load_rooms_without_visible_events()) + + self.first_sync_happened.set() async def onErrorResponse(self, resp: nio.ErrorResponse) -> None: @@ -310,55 +471,45 @@ class MatrixClient(nio.AsyncClient): log.warning(repr(resp)) - # Callbacks for nio events - + # Callbacks for nio room events # Content: %1 is the sender, %2 the target (ev.state_key). - # pylint: disable=unused-argument - async def onRoomMessageText(self, room, ev, from_past=False) -> None: + async def onRoomMessageText(self, room, ev) -> None: co = HTML_FILTER.filter( ev.formatted_body - if ev.format == "org.matrix.custom.html" else html.escape(ev.body) + if ev.format == "org.matrix.custom.html" else html.escape(ev.body), ) - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomMessageEmote(self, room, ev, from_past=False) -> None: + async def onRoomMessageEmote(self, room, ev) -> None: co = HTML_FILTER.filter_inline( ev.formatted_body - if ev.format == "org.matrix.custom.html" else html.escape(ev.body) + if ev.format == "org.matrix.custom.html" else html.escape(ev.body), ) - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - # async def onRoomMessageImage(self, room, ev, from_past=False) -> None: - # import json; print("RMI", json.dumps( ev.__dict__ , indent=4)) - - # async def onRoomEncryptedImage(self, room, ev, from_past=False) -> None: - # import json; print("REI", json.dumps( ev.__dict__ , indent=4)) - - - async def onRoomCreateEvent(self, room, ev, from_past=False) -> None: + async def onRoomCreateEvent(self, room, ev) -> None: co = "%1 allowed users on other matrix servers to join this room." \ if ev.federate else \ "%1 blocked users on other matrix servers from joining this room." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomGuestAccessEvent(self, room, ev, from_past=False) -> None: + async def onRoomGuestAccessEvent(self, room, ev) -> None: allowed = "allowed" if ev.guest_access else "forbad" co = f"%1 {allowed} guests to join the room." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomJoinRulesEvent(self, room, ev, from_past=False) -> None: + async def onRoomJoinRulesEvent(self, room, ev) -> None: access = "public" if ev.join_rule == "public" else "invite-only" co = f"%1 made the room {access}." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomHistoryVisibilityEvent(self, room, ev, from_past=False - ) -> None: + async def onRoomHistoryVisibilityEvent(self, room, ev) -> None: if ev.history_visibility == "shared": to = "all room members" elif ev.history_visibility == "world_readable": @@ -373,20 +524,22 @@ class MatrixClient(nio.AsyncClient): json.dumps(ev.__dict__, indent=4)) co = f"%1 made future room history visible to {to}." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onPowerLevelsEvent(self, room, ev, from_past=False) -> None: + async def onPowerLevelsEvent(self, room, ev) -> None: co = "%1 changed the room's permissions." # TODO: improve - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def get_room_member_event_content(self, ev) -> Optional[str]: + async def process_room_member_event(self, room, ev) -> Optional[str]: prev = ev.prev_content now = ev.content membership = ev.membership prev_membership = ev.prev_membership + ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000) + # Membership changes if not prev or membership != prev_membership: reason = f" Reason: {now['reason']}" if now.get("reason") else "" @@ -421,17 +574,12 @@ class MatrixClient(nio.AsyncClient): if membership == "ban": return f"%1 banned %2 from the room.{reason}" - - if ev.sender in self.backend.clients: - # Don't put our own name/avatar changes in the timeline - return None - + # Profile changes changed = [] if prev and now["avatar_url"] != prev["avatar_url"]: changed.append("profile picture") # TODO: s - if prev and now["displayname"] != prev["displayname"]: changed.append('display name from "{}" to "{}"'.format( prev["displayname"] or ev.state_key, @@ -439,6 +587,19 @@ class MatrixClient(nio.AsyncClient): )) if changed: + # Update our account profile if the event is newer than last update + if ev.state_key == self.user_id: + account = self.models[Account][self.user_id] + + if account.profile_updated < ev_date: + account.profile_updated = ev_date + account.display_name = now["displayname"] or "" + account.avatar_url = now["avatar_url"] or "" + + if ev.state_key in self.backend.clients or len(room.users) > 50: + self.skipped_events[room.room_id] += 1 + return None + return "%1 changed their {}.".format(" and ".join(changed)) log.warning("Invalid member event - %s", @@ -446,48 +607,65 @@ class MatrixClient(nio.AsyncClient): return None - async def onRoomMemberEvent(self, room, ev, from_past=False) -> None: - co = await self.get_room_member_event_content(ev) + async def onRoomMemberEvent(self, room, ev) -> None: + co = await self.process_room_member_event(room, ev) - if co is not None: - TimelineEventReceived.from_nio(room, ev, content=co) + if co is None: + # This is run from register_nio_event otherwise + await self.register_nio_room(room) + else: + await self.register_nio_event(room, ev, content=co) - async def onRoomAliasEvent(self, room, ev, from_past=False) -> None: + async def onRoomAliasEvent(self, room, ev) -> None: co = f"%1 set the room's main address to {ev.canonical_alias}." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomNameEvent(self, room, ev, from_past=False) -> None: + async def onRoomNameEvent(self, room, ev) -> None: co = f"%1 changed the room's name to \"{ev.name}\"." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomTopicEvent(self, room, ev, from_past=False) -> None: + async def onRoomTopicEvent(self, room, ev) -> None: co = f"%1 changed the room's topic to \"{ev.topic}\"." - TimelineEventReceived.from_nio(room, ev, content=co) + await self.register_nio_event(room, ev, content=co) - async def onRoomEncryptionEvent(self, room, ev, from_past=False) -> None: - co = f"%1 turned on encryption for this room." - TimelineEventReceived.from_nio(room, ev, content=co) + async def onRoomEncryptionEvent(self, room, ev) -> None: + co = "%1 turned on encryption for this room." + await self.register_nio_event(room, ev, content=co) - async def onOlmEvent(self, room, ev, from_past=False) -> None: - co = f"%1 sent an undecryptable olm message." - TimelineEventReceived.from_nio(room, ev, content=co) + async def onOlmEvent(self, room, ev) -> None: + co = "%1 sent an undecryptable olm message." + await self.register_nio_event(room, ev, content=co) - async def onMegolmEvent(self, room, ev, from_past=False) -> None: - co = f"%1 sent an undecryptable message." - TimelineEventReceived.from_nio(room, ev, content=co) + async def onMegolmEvent(self, room, ev) -> None: + co = "%1 sent an undecryptable message." + await self.register_nio_event(room, ev, content=co) - async def onBadEvent(self, room, ev, from_past=False) -> None: - co = f"%1 sent a malformed event." - TimelineEventReceived.from_nio(room, ev, content=co) + async def onBadEvent(self, room, ev) -> None: + co = "%1 sent a malformed event." + await self.register_nio_event(room, ev, content=co) - async def onUnknownBadEvent(self, room, ev, from_past=False) -> None: - co = f"%1 sent an event this client doesn't understand." - TimelineEventReceived.from_nio(room, ev, content=co) + async def onUnknownBadEvent(self, room, ev) -> None: + co = "%1 sent an event this client doesn't understand." + await self.register_nio_event(room, ev, content=co) + + + # Callbacks for nio ephemeral events + + async def onTypingNoticeEvent(self, room, ev) -> None: + # Prevent recent past typing notices from being shown for a split + # second on client startup: + if not self.first_sync_happened.is_set(): + return + + self.models[Room, self.user_id][room.room_id].typing_members = sorted( + room.user_name(user_id) for user_id in ev.users + if user_id not in self.backend.clients + ) diff --git a/src/python/models/__init__.py b/src/python/models/__init__.py new file mode 100644 index 00000000..aa73ad4c --- /dev/null +++ b/src/python/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +from typing import Tuple, Type, Union + +from .model_item import ModelItem + +# Type[ModelItem] or Tuple[Type[ModelItem], str...] +SyncId = Union[Type[ModelItem], Tuple[Union[Type[ModelItem], str], ...]] diff --git a/src/python/models/items.py b/src/python/models/items.py new file mode 100644 index 00000000..a24934c2 --- /dev/null +++ b/src/python/models/items.py @@ -0,0 +1,119 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +from ..html_filter import HTML_FILTER +from .model_item import ModelItem + + +@dataclass +class Account(ModelItem): + main_key = "user_id" + + user_id: str = field() + display_name: str = "" + avatar_url: str = "" + profile_updated: datetime = field(default_factory=datetime.now) + + def __lt__(self, other: "Account") -> bool: + name = self.display_name or self.user_id[1:] + other_name = other.display_name or other.user_id[1:] + return name < other_name + + +@dataclass +class Room(ModelItem): + main_key = "room_id" + + room_id: str = field() + display_name: str = "" + avatar_url: str = "" + topic: str = "" + inviter_id: str = "" + left: bool = False + filter_string: str = "" + typing_members: List[str] = field(default_factory=list) + + last_event: Optional[Dict[str, Any]] = None # Event __dict__ + + def __lt__(self, other: "Room") -> bool: + # Left rooms may still have an inviter_id, check left first. + if self.left and not other.left: + return True + + if other.inviter_id and not self.inviter_id: + return True + + name = self.display_name or self.room_id + other_name = other.display_name or other.room_id + return name < other_name + + +@dataclass +class Member(ModelItem): + main_key = "user_id" + + user_id: str = field() + display_name: str = "" + avatar_url: str = "" + typing: bool = False + power_level: int = 0 + filter_string: str = "" + + def __lt__(self, other: "Member") -> bool: + name = self.display_name or self.user_id[1:] + other_name = other.display_name or other.user_id[1:] + return name < other_name + + +@dataclass +class Event(ModelItem): + main_key = "event_id" + + client_id: str = field() + event_id: str = field() + event_type: str = field() # Type[nio.Event] + content: str = field() + inline_content: str = field() + date: datetime = field() + + sender_id: str = field() + sender_name: str = field() + sender_avatar: str = field() + + target_id: str = "" + target_name: str = "" + target_avatar: str = "" + + is_local_echo: bool = False + + def __post_init__(self) -> None: + self.inline_content = HTML_FILTER.filter_inline(self.content) + + + def __lt__(self, other: "Event") -> bool: + # Sort events from newest to oldest. return True means return False. + # Local echoes always stay first. + if self.is_local_echo and not other.is_local_echo: + return True + + if other.is_local_echo and not self.is_local_echo: + return False + + return self.date > other.date + + +@dataclass +class Device(ModelItem): + main_key = "device_id" + + device_id: str = field() + ed25519_key: str = field() + trusted: bool = False + blacklisted: bool = False + display_name: str = "" + last_seen_ip: str = "" + last_seen_date: str = "" diff --git a/src/python/models/model.py b/src/python/models/model.py new file mode 100644 index 00000000..c6cbd3bd --- /dev/null +++ b/src/python/models/model.py @@ -0,0 +1,116 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +import logging as log +import time +from threading import Lock, Thread +from typing import Any, Dict, Iterator, List, MutableMapping, Optional + +from . import SyncId +from ..pyotherside_events import ModelUpdated +from .model_item import ModelItem + + +class Model(MutableMapping): + def __init__(self, sync_id: SyncId) -> None: + self.sync_id: SyncId = sync_id + self.sortable: Optional[bool] = None + 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() + + + def __repr__(self) -> str: + try: + from pprintpp import pformat + except ImportError: + from pprint import pformat # type: ignore + + if isinstance(self.sync_id, tuple): + sid = (self.sync_id[0].__name__, *self.sync_id[1:]) # type: ignore + else: + sid = self.sync_id.__name__ # type: ignore + + return "%s(sync_id=%s, sortable=%r, %s)" % ( + type(self).__name__, sid, self.sortable, pformat(self._data), + ) + + + def __str__(self) -> str: + if isinstance(self.sync_id, tuple): + sid = (self.sync_id[0].__name__, *self.sync_id[1:]) # type: ignore + else: + sid = self.sync_id.__name__ # type: ignore + + return f"{sid!s}: {len(self)} items" + + + def __getitem__(self, key): + return self._data[key] + + + def __setitem__(self, key, value: ModelItem) -> None: + new = value + + if key in self: + existing = dict(self[key].__dict__) # copy to not alter with pop + merged = {**existing, **value.__dict__} + + existing.pop("parent_model", None) + merged.pop("parent_model", None) + + if merged == existing: + return + + new = type(value)(**merged) # type: ignore + + new.parent_model = self + + with self._sync_lock: + self._data[key] = new + self._changed = True + + + def __delitem__(self, key) -> None: + with self._sync_lock: + del self._data[key] + self._changed = True + + + def __iter__(self) -> Iterator: + return iter(self._data) + + + def __len__(self) -> int: + return len(self._data) + + + def _sync_loop(self) -> None: + while True: + time.sleep(0.25) + + if self._changed: + with self._sync_lock: + log.debug("Syncing %s", self) + ModelUpdated(self.sync_id, self.serialized()) + self._changed = False + + + def serialized(self) -> List[Dict[str, Any]]: + if self.sortable is True: + return [item.__dict__ for item in sorted(self._data.values())] + + if self.sortable is False or len(self) < 2: + return [item.__dict__ for item in self._data.values()] + + try: + return [item.__dict__ for item in sorted(self._data.values())] + except TypeError: + self.sortable = False + else: + self.sortable = True + + return self.serialized() diff --git a/src/python/models/model_item.py b/src/python/models/model_item.py new file mode 100644 index 00000000..4cf85d77 --- /dev/null +++ b/src/python/models/model_item.py @@ -0,0 +1,31 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +from typing import ClassVar, Optional + + +class ModelItem: + main_key: ClassVar[str] = "" + + def __init_subclass__(cls) -> None: + if not cls.main_key: + raise ValueError("Must specify main_key str class attribute.") + + super().__init_subclass__() + + + def __new__(cls, *_args, **_kwargs) -> "ModelItem": + from .model import Model + cls.parent_model: Optional[Model] = None + return super().__new__(cls) + + + def __setattr__(self, name: str, value) -> None: + 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 + + def __delattr__(self, name: str) -> None: + raise NotImplementedError() diff --git a/src/python/models/model_store.py b/src/python/models/model_store.py new file mode 100644 index 00000000..0dc407f5 --- /dev/null +++ b/src/python/models/model_store.py @@ -0,0 +1,57 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +from typing import Dict, Iterator, MutableMapping, Set, Tuple, Type, Union + +from dataclasses import dataclass, field + +from . import SyncId +from .model_item import ModelItem +from .model import Model + +KeyType = Union[Type[ModelItem], Tuple[Type, ...]] + +@dataclass(frozen=True) +class ModelStore(MutableMapping): + allowed_key_types: Set[KeyType] = field() + + data: Dict[SyncId, Model] = field(init=False, default_factory=dict) + + + def __getitem__(self, key: SyncId) -> Model: + try: + return self.data[key] + except KeyError: + if isinstance(key, tuple): + for i in key: + if not i: + raise ValueError(f"Empty string in key: {key!r}") + + key_type = (key[0],) + \ + tuple(type(el) for el in key[1:]) # type: ignore + else: + key_type = key # type: ignore + + if key_type not in self.allowed_key_types: + raise TypeError(f"{key_type!r} not in allowed key types: " + f"{self.allowed_key_types!r}") + + model = Model(key) + self.data[key] = model + return model + + + def __setitem__(self, key, item) -> None: + raise NotImplementedError() + + + def __delitem__(self, key: SyncId) -> None: + del self.data[key] + + + def __iter__(self) -> Iterator[SyncId]: + return iter(self.data) + + + def __len__(self) -> int: + return len(self.data) diff --git a/src/python/pyotherside.py b/src/python/pyotherside.py new file mode 100644 index 00000000..7e001876 --- /dev/null +++ b/src/python/pyotherside.py @@ -0,0 +1,30 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +import logging as log +from typing import Tuple, Callable + +AVAILABLE = True +try: + import pyotherside +except ModuleNotFoundError: + log.warning("pyotherside module is unavailable.") + AVAILABLE = False + +Size = Tuple[int, int] +ImageData = Tuple[bytearray, Size, int] # last int: pyotherside format enum + +format_data: int = pyotherside.format_data if AVAILABLE else 0 +format_svg_data: int = pyotherside.format_svg_data if AVAILABLE else 1 +format_rgb888: int = pyotherside.format_rgb888 if AVAILABLE else 2 +format_argb32: int = pyotherside.format_argb32 if AVAILABLE else 3 + + +def send(event: str, *args) -> None: + if AVAILABLE: + pyotherside.send(event, *args) + + +def set_image_provider(provider: Callable[[str, Size], ImageData]) -> None: + if AVAILABLE: + pyotherside.set_image_provider(provider) diff --git a/src/python/pyotherside_events.py b/src/python/pyotherside_events.py new file mode 100644 index 00000000..391a67f0 --- /dev/null +++ b/src/python/pyotherside_events.py @@ -0,0 +1,57 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Union + +from . import pyotherside +from .models import SyncId + + +@dataclass +class PyOtherSideEvent: + def __post_init__(self) -> None: + # CPython >= 3.6 or any Python >= 3.7 needed for correct dict order + args = [ + self._process_field(getattr(self, field)) + for field in self.__dataclass_fields__ # type: ignore + ] + pyotherside.send(type(self).__name__, *args) + + + @staticmethod + def _process_field(value: Any) -> Any: + if hasattr(value, "__class__") and issubclass(value.__class__, Enum): + return value.value + + return value + + +@dataclass +class ExitRequested(PyOtherSideEvent): + exit_code: int = 0 + + +@dataclass +class CoroutineDone(PyOtherSideEvent): + uuid: str = field() + result: Any = None + + +@dataclass +class ModelUpdated(PyOtherSideEvent): + sync_id: SyncId = field() + data: List[Dict[str, Any]] = field() + + serialized_sync_id: Union[str, List[str]] = field(init=False) + + 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__() diff --git a/src/python/utils.py b/src/python/utils.py index f1d0d6b2..9b0a2b61 100644 --- a/src/python/utils.py +++ b/src/python/utils.py @@ -2,10 +2,14 @@ # This file is part of harmonyqml, licensed under LGPLv3. import collections -from enum import auto as autostr +import xml.etree.cElementTree as xml_etree # FIXME: bandit warning from enum import Enum +from enum import auto as autostr +from typing import IO, Optional -auto = autostr # pylint: disable=invalid-name +import filetype + +auto = autostr class AutoStrEnum(Enum): @@ -22,3 +26,19 @@ def dict_update_recursive(dict1, dict2): dict_update_recursive(dict1[k], dict2[k]) else: dict1[k] = dict2[k] + + +def is_svg(file: IO) -> bool: + try: + _, element = next(xml_etree.iterparse(file, ("start",))) + return element.tag == "{http://www.w3.org/2000/svg}svg" + except (StopIteration, xml_etree.ParseError): + return False + + +def guess_mime(file: IO) -> Optional[str]: + if is_svg(file): + return "image/svg+xml" + + file.seek(0, 0) + return filetype.guess_mime(file) diff --git a/src/qml/Base/HAvatar.qml b/src/qml/Base/HAvatar.qml index a2b8ad92..861c7f89 100644 --- a/src/qml/Base/HAvatar.qml +++ b/src/qml/Base/HAvatar.qml @@ -35,7 +35,7 @@ HRectangle { text: name ? name.charAt(0) : "?" font.pixelSize: parent.height / 1.4 - color: Utils.hsla( + color: Utils.hsluv( name ? Utils.hueFrom(name) : 0, name ? theme.controls.avatar.letter.saturation : 0, theme.controls.avatar.letter.lightness, diff --git a/src/qml/Base/HListModel.qml b/src/qml/Base/HListModel.qml index e50cd30c..3c6d0fe9 100644 --- a/src/qml/Base/HListModel.qml +++ b/src/qml/Base/HListModel.qml @@ -2,124 +2,14 @@ // This file is part of harmonyqml, licensed under LGPLv3. import QtQuick 2.12 -import SortFilterProxyModel 0.2 +import QSyncable 1.0 -SortFilterProxyModel { - // To initialize a HListModel with items, - // use `Component.onCompleted: extend([{"foo": 1, "bar": 2}, ...])` +JsonListModel { + id: model + source: [] + Component.onCompleted: if (! keyField) { throw "keyField not set" } - id: sortFilteredModel - - property ListModel model: ListModel {} - sourceModel: model // Can't assign a "ListModel {}" directly here - - function append(dict) { return model.append(dict) } - function clear() { return model.clear() } - function insert(index, dict) { return model.inset(index, dict) } - function move(from, to, n=1) { return model.move(from, to, n) } - function remove(index, count=1) { return model.remove(index, count) } - function set(index, dict) { return model.set(index, dict) } - function sync() { return model.sync() } - function setProperty(index, prop, value) { - return model.setProperty(index, prop, value) - } - - function extend(newItems) { - for (let item of newItems) { model.append(item) } - } - - function getIndices(whereRolesAre, maxResults=null, maxTries=null) { - // maxResults, maxTries: null or int - let results = [] - - for (let i = 0; i < model.count; i++) { - let item = model.get(i) - let include = true - - for (let role in whereRolesAre) { - if (item[role] != whereRolesAre[role]) { - include = false - break - } - } - - if (include) { - results.push(i) - if (maxResults && results.length >= maxResults) { - break - } - } - - if (maxTries && i >= maxTries) { - break - } - } - return results - } - - function getWhere(rolesAre, maxResults=null, maxTries=null) { - let items = [] - - for (let indice of getIndices(rolesAre, maxResults, maxTries)) { - items.push(model.get(indice)) - } - return items - } - - function forEachWhere(rolesAre, func, maxResults=null, maxTries=null) { - for (let item of getWhere(rolesAre, maxResults, maxTries)) { - func(item) - } - } - - function upsert( - whereRolesAre, newItem, updateIfExist=true, maxTries=null - ) { - let indices = getIndices(whereRolesAre, 1, maxTries) - - if (indices.length == 0) { - model.append(newItem) - return model.get(model.count) - } - - let existing = model.get(indices[0]) - if (! updateIfExist) { return existing } - - // Really update only if existing and new item have a difference - for (var role in existing) { - if (Boolean(existing[role].getTime)) { - if (existing[role].getTime() != newItem[role].getTime()) { - model.set(indices[0], newItem) - return existing - } - } else { - if (existing[role] != newItem[role]) { - model.set(indices[0], newItem) - return existing - } - } - } - return existing - } - - function pop(index) { - let item = model.get(index) - model.remove(index) - return item - } - - function popWhere(rolesAre, maxResults=null, maxTries=null) { - let items = [] - - for (let indice of getIndices(rolesAre, maxResults, maxTries)) { - items.push(model.get(indice)) - model.remove(indice) - } - return items - } - - - function toObject(itemList=sortFilteredModel) { + function toObject(itemList=listModel) { let objList = [] for (let item of itemList) { diff --git a/src/qml/Base/HPage.qml b/src/qml/Base/HPage.qml index 796deccd..3845682e 100644 --- a/src/qml/Base/HPage.qml +++ b/src/qml/Base/HPage.qml @@ -8,6 +8,7 @@ import "../SidePane" SwipeView { default property alias columnChildren: contentColumn.children + property alias page: innerPage property alias header: innerPage.header property alias footer: innerPage.header @@ -81,7 +82,6 @@ SwipeView { HColumnLayout { id: contentColumn - spacing: theme.spacing width: innerFlickable.width height: innerFlickable.height } diff --git a/src/qml/Base/HRoomAvatar.qml b/src/qml/Base/HRoomAvatar.qml index 0075778b..011afab5 100644 --- a/src/qml/Base/HRoomAvatar.qml +++ b/src/qml/Base/HRoomAvatar.qml @@ -4,16 +4,13 @@ import QtQuick 2.12 HAvatar { - property string userId: "" - property string roomId: "" + property string displayName: "" + property string avatarUrl: "" - readonly property var roomInfo: rooms.getWhere({userId, roomId}, 1)[0] + name: displayName[0] == "#" && displayName.length > 1 ? + displayName.substring(1) : + displayName - // Avoid error messages when a room is forgotten - readonly property var dname: roomInfo ? roomInfo.displayName : "" - readonly property var avUrl: roomInfo ? roomInfo.avatarUrl : "" - - name: dname[0] == "#" && dname.length > 1 ? dname.substring(1) : dname - imageUrl: avUrl ? ("image://python/" + avUrl) : null - toolTipImageUrl: avUrl ? ("image://python/" + avUrl) : null + imageUrl: avatarUrl ? ("image://python/" + avatarUrl) : null + toolTipImageUrl: avatarUrl ? ("image://python/" + avatarUrl) : null } diff --git a/src/qml/Base/HTextField.qml b/src/qml/Base/HTextField.qml index 31da426b..d2ae15f9 100644 --- a/src/qml/Base/HTextField.qml +++ b/src/qml/Base/HTextField.qml @@ -26,9 +26,8 @@ TextField { border.color: field.activeFocus ? focusedBorderColor : borderColor border.width: bordered ? theme.controls.textField.borderWidth : 0 - Behavior on color { HColorAnimation { factor: 0.5 } } - Behavior on border.color { HColorAnimation { factor: 0.5 } } - Behavior on border.width { HNumberAnimation { factor: 0.5 } } + Behavior on color { HColorAnimation { factor: 0.25 } } + Behavior on border.color { HColorAnimation { factor: 0.25 } } } selectByMouse: true diff --git a/src/qml/Base/HUserAvatar.qml b/src/qml/Base/HUserAvatar.qml index 2e807899..12df51eb 100644 --- a/src/qml/Base/HUserAvatar.qml +++ b/src/qml/Base/HUserAvatar.qml @@ -5,23 +5,16 @@ import QtQuick 2.12 HAvatar { property string userId: "" - readonly property var userInfo: userId ? users.find(userId) : ({}) + property string displayName: "" + property string avatarUrl: "" readonly property var defaultImageUrl: - userInfo.avatarUrl ? ("image://python/" + userInfo.avatarUrl) : null + avatarUrl ? ("image://python/" + avatarUrl) : null readonly property var defaultToolTipImageUrl: - userInfo.avatarUrl ? ("image://python/" + userInfo.avatarUrl) : null + avatarUrl ? ("image://python/" + avatarUrl) : null - name: userInfo.displayName || userId.substring(1) // no leading @ + name: displayName || userId.substring(1) // no leading @ imageUrl: defaultImageUrl toolTipImageUrl:defaultToolTipImageUrl - - //HImage { - //id: status - //anchors.right: parent.right - //anchors.bottom: parent.bottom - //source: "../../icons/status.svg" - //sourceSize.width: 12 - //} } diff --git a/src/qml/Chat/Banners/LeftBanner.qml b/src/qml/Chat/Banners/LeftBanner.qml index 8b62b5c3..3d875424 100644 --- a/src/qml/Chat/Banners/LeftBanner.qml +++ b/src/qml/Chat/Banners/LeftBanner.qml @@ -6,7 +6,6 @@ import "../../Base" Banner { property string userId: "" - readonly property var userInfo: users.find(userId) color: theme.chat.leftBanner.background diff --git a/src/qml/Chat/Chat.qml b/src/qml/Chat/Chat.qml index d92ffbdd..e8f828be 100644 --- a/src/qml/Chat/Chat.qml +++ b/src/qml/Chat/Chat.qml @@ -4,29 +4,26 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import "../Base" +import "../utils.js" as Utils HPage { id: chatPage - property bool ready: roomInfo && ! roomInfo.loading + property string userId: "" + property string roomId: "" - property var roomInfo: null + property bool ready: roomInfo !== "waiting" + + readonly property var roomInfo: Utils.getItem( + modelSources[["Room", userId]] || [], "room_id", roomId + ) || "waiting" onRoomInfoChanged: if (! roomInfo) { pageStack.showPage("Default") } - readonly property string userId: roomInfo.userId - readonly property string category: roomInfo.category - readonly property string roomId: roomInfo.roomId - - readonly property var senderInfo: users.find(userId) - readonly property bool hasUnknownDevices: false - //category == "Rooms" ? - //Backend.clients.get(userId).roomHasUnknownDevices(roomId) : false - header: RoomHeader { + header: Loader { id: roomHeader - displayName: roomInfo.displayName - topic: roomInfo.topic + source: ready ? "RoomHeader.qml" : "" clip: height < implicitHeight width: parent.width @@ -37,18 +34,7 @@ HPage { page.leftPadding: 0 page.rightPadding: 0 - Loader { - Timer { - interval: 200 - repeat: true - running: ! ready - onTriggered: { - let info = rooms.find(userId, category, roomId) - if (! info.loading) { roomInfo = Qt.binding(() => info) } - } - } - source: ready ? "ChatSplitView.qml" : "../Base/HBusyIndicator.qml" Layout.fillWidth: ready diff --git a/src/qml/Chat/ChatSplitView.qml b/src/qml/Chat/ChatSplitView.qml index 58620f11..95a9bbdf 100644 --- a/src/qml/Chat/ChatSplitView.qml +++ b/src/qml/Chat/ChatSplitView.qml @@ -28,25 +28,21 @@ HSplitView { } InviteBanner { - visible: category == "Invites" - inviterId: roomInfo.inviterId + id: inviteBanner + visible: Boolean(inviterId) + inviterId: chatPage.roomInfo.inviter_id Layout.fillWidth: true } - //UnknownDevicesBanner { - //visible: category == "Rooms" && hasUnknownDevices - // - //Layout.fillWidth: true - //} - SendBox { id: sendBox - visible: category == "Rooms" && ! hasUnknownDevices + visible: ! inviteBanner.visible && ! leftBanner.visible } LeftBanner { - visible: category == "Left" + id: leftBanner + visible: chatPage.roomInfo.left userId: chatPage.userId Layout.fillWidth: true @@ -56,7 +52,8 @@ HSplitView { RoomSidePane { id: roomSidePane - activeView: roomHeader.activeButton + activeView: roomHeader.item ? roomHeader.item.activeButton : null + property int oldWidth: width onActiveViewChanged: activeView ? restoreAnimation.start() : hideAnimation.start() @@ -89,7 +86,9 @@ HSplitView { collapsed: width < theme.controls.avatar.size + theme.spacing property bool wasSnapped: false - property int referenceWidth: roomHeader.buttonsWidth + property int referenceWidth: + roomHeader.item ? roomHeader.item.buttonsWidth : 0 + onReferenceWidthChanged: { if (! chatSplitView.manuallyResized || wasSnapped) { if (wasSnapped) { chatSplitView.manuallyResized = false } diff --git a/src/qml/Chat/RoomHeader.qml b/src/qml/Chat/RoomHeader.qml index 6fe2d772..82618ca9 100644 --- a/src/qml/Chat/RoomHeader.qml +++ b/src/qml/Chat/RoomHeader.qml @@ -6,9 +6,6 @@ import QtQuick.Layouts 1.12 import "../Base" HRectangle { - property string displayName: "" - property string topic: "" - property alias buttonsImplicitWidth: viewButtons.implicitWidth property int buttonsWidth: viewButtons.Layout.preferredWidth property var activeButton: "members" @@ -29,14 +26,14 @@ HRectangle { HRoomAvatar { id: avatar - userId: chatPage.userId - roomId: chatPage.roomId + displayName: chatPage.roomInfo.display_name + avatarUrl: chatPage.roomInfo.avatar_url Layout.alignment: Qt.AlignTop } HLabel { id: roomName - text: displayName + text: chatPage.roomInfo.display_name font.pixelSize: theme.fontSize.big color: theme.chat.roomHeader.name elide: Text.ElideRight @@ -53,7 +50,7 @@ HRectangle { HLabel { id: roomTopic - text: topic + text: chatPage.roomInfo.topic font.pixelSize: theme.fontSize.small color: theme.chat.roomHeader.topic elide: Text.ElideRight diff --git a/src/qml/Chat/RoomSidePane/MemberDelegate.qml b/src/qml/Chat/RoomSidePane/MemberDelegate.qml index d450bb8e..2d4ff820 100644 --- a/src/qml/Chat/RoomSidePane/MemberDelegate.qml +++ b/src/qml/Chat/RoomSidePane/MemberDelegate.qml @@ -4,14 +4,13 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import "../../Base" +import "../../utils.js" as Utils HInteractiveRectangle { id: memberDelegate width: memberList.width height: childrenRect.height - property var memberInfo: users.find(model.userId) - Row { width: parent.width - leftPadding * 2 padding: roomSidePane.currentSpacing / 2 @@ -24,7 +23,9 @@ HInteractiveRectangle { HUserAvatar { id: avatar - userId: model.userId + userId: model.user_id + displayName: model.display_name + avatarUrl: model.avatar_url } HColumnLayout { @@ -32,7 +33,7 @@ HInteractiveRectangle { HLabel { id: memberName - text: memberInfo.displayName || model.userId + text: model.display_name || model.user_id elide: Text.ElideRight verticalAlignment: Qt.AlignVCenter diff --git a/src/qml/Chat/RoomSidePane/MembersView.qml b/src/qml/Chat/RoomSidePane/MembersView.qml index 89cc4a84..d6e7f172 100644 --- a/src/qml/Chat/RoomSidePane/MembersView.qml +++ b/src/qml/Chat/RoomSidePane/MembersView.qml @@ -3,7 +3,6 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 -import SortFilterProxyModel 0.2 import "../../Base" import "../../utils.js" as Utils @@ -13,23 +12,11 @@ HColumnLayout { bottomMargin: currentSpacing model: HListModel { - sourceModel: chatPage.roomInfo.members - - proxyRoles: ExpressionRole { - name: "displayName" - expression: users.find(userId).displayName || userId - } - - sorters: StringSorter { - roleName: "displayName" - } - - filters: ExpressionFilter { - function filterIt(filter, text) { - return Utils.filterMatches(filter, text) - } - expression: filterIt(filterField.text, displayName) - } + keyField: "user_id" + source: Utils.filterModelSource( + modelSources[["Member", chatPage.roomId]] || [], + filterField.text + ) } delegate: MemberDelegate {} diff --git a/src/qml/Chat/SendBox.qml b/src/qml/Chat/SendBox.qml index 9f8fce72..ce8492c6 100644 --- a/src/qml/Chat/SendBox.qml +++ b/src/qml/Chat/SendBox.qml @@ -4,6 +4,7 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import "../Base" +import "../utils.js" as Utils HRectangle { function setFocus() { areaScrollView.forceActiveFocus() } @@ -11,9 +12,12 @@ HRectangle { property string indent: " " property var aliases: window.settings.writeAliases - property string writingUserId: chatPage.userId property string toSend: "" + property string writingUserId: chatPage.userId + readonly property var writingUserInfo: + Utils.getItem(modelSources["Account"] || [], "user_id", writingUserId) + property bool textChangedSinceLostFocus: false property alias textArea: areaScrollView.area @@ -57,6 +61,8 @@ HRectangle { HUserAvatar { id: avatar userId: writingUserId + displayName: writingUserInfo.display_name + avatarUrl: writingUserInfo.avatar_url } HScrollableTextArea { @@ -166,6 +172,13 @@ HRectangle { }) area.Keys.onPressed.connect(event => { + if (event.modifiers == Qt.MetaModifier) { + // Prevent super+key from sending the key as text + // on xwayland + event.accepted = true + return + } + if (event.modifiers == Qt.NoModifier && event.key == Qt.Key_Backspace && ! textArea.selectedText) diff --git a/src/qml/Chat/Timeline/EventContent.qml b/src/qml/Chat/Timeline/EventContent.qml index a293f60f..20df07e6 100644 --- a/src/qml/Chat/Timeline/EventContent.qml +++ b/src/qml/Chat/Timeline/EventContent.qml @@ -19,7 +19,9 @@ Row { HUserAvatar { id: avatar - userId: model.senderId + userId: model.sender_id + displayName: model.sender_name + avatarUrl: model.sender_avatar width: hideAvatar ? 0 : 48 height: hideAvatar ? 0 : collapseAvatar ? 1 : 48 } @@ -52,8 +54,8 @@ Row { width: parent.width visible: ! hideNameLine - text: senderInfo.displayName || model.senderId - color: Utils.nameColor(avatar.name) + text: Utils.coloredNameHtml(model.sender_name, model.sender_id) + textFormat: Text.StyledText elide: Text.ElideRight horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft @@ -74,7 +76,7 @@ Row { Qt.formatDateTime(model.date, "hh:mm:ss") + "" + // local echo icon - (model.isLocalEcho ? + (model.is_local_echo ? " " : "") diff --git a/src/qml/Chat/Timeline/EventDelegate.qml b/src/qml/Chat/Timeline/EventDelegate.qml index f6c0a431..30ba5266 100644 --- a/src/qml/Chat/Timeline/EventDelegate.qml +++ b/src/qml/Chat/Timeline/EventDelegate.qml @@ -18,9 +18,7 @@ Column { nextItem = eventList.model.get(model.index - 1) } - property var senderInfo: senderInfo = users.find(model.senderId) - - property bool isOwn: chatPage.userId === model.senderId + property bool isOwn: chatPage.userId === model.sender_id property bool onRight: eventList.ownEventsOnRight && isOwn property bool combine: eventList.canCombine(previousItem, model) property bool talkBreak: eventList.canTalkBreak(previousItem, model) @@ -28,22 +26,22 @@ Column { readonly property bool smallAvatar: eventList.canCombine(model, nextItem) && - (model.eventType == "RoomMessageEmote" || - ! model.eventType.startsWith("RoomMessage")) + (model.event_type == "RoomMessageEmote" || + ! model.event_type.startsWith("RoomMessage")) readonly property bool collapseAvatar: combine readonly property bool hideAvatar: onRight readonly property bool hideNameLine: - model.eventType == "RoomMessageEmote" || - ! model.eventType.startsWith("RoomMessage") || + model.event_type == "RoomMessageEmote" || + ! model.event_type.startsWith("RoomMessage") || onRight || combine width: eventList.width topPadding: - model.eventType == "RoomCreateEvent" ? 0 : + model.event_type == "RoomCreateEvent" ? 0 : dayBreak ? theme.spacing * 4 : talkBreak ? theme.spacing * 6 : combine ? theme.spacing / 2 : diff --git a/src/qml/Chat/Timeline/EventList.qml b/src/qml/Chat/Timeline/EventList.qml index 1d1a9f94..0524ce12 100644 --- a/src/qml/Chat/Timeline/EventList.qml +++ b/src/qml/Chat/Timeline/EventList.qml @@ -2,7 +2,6 @@ // This file is part of harmonyqml, licensed under LGPLv3. import QtQuick 2.12 -import SortFilterProxyModel 0.2 import "../../Base" import "../../utils.js" as Utils @@ -22,7 +21,7 @@ HRectangle { return Boolean( ! canTalkBreak(item, itemAfter) && ! canDayBreak(item, itemAfter) && - item.senderId === itemAfter.senderId && + item.sender_id === itemAfter.sender_id && Utils.minutesBetween(item.date, itemAfter.date) <= 5 ) } @@ -42,18 +41,15 @@ HRectangle { } return Boolean( - itemAfter.eventType == "RoomCreateEvent" || + itemAfter.event_type == "RoomCreateEvent" || item.date.getDate() != itemAfter.date.getDate() ) } model: HListModel { - sourceModel: timelines - - filters: ValueFilter { - roleName: "roomId" - value: chatPage.roomId - } + keyField: "client_id" + source: + modelSources[["Event", chatPage.userId, chatPage.roomId]] || [] } property bool ownEventsOnRight: @@ -76,23 +72,20 @@ HRectangle { // Declaring this as "alias" provides the on... signal property real yPos: visibleArea.yPosition property bool canLoad: true - // property int zz: 0 + onYPosChanged: Qt.callLater(loadPastEvents) - onYPosChanged: { - if (chatPage.category != "Invites" && canLoad && yPos <= 0.1) { - // zz += 1 - // print(canLoad, zz) - eventList.canLoad = false - py.callClientCoro( - chatPage.userId, "load_past_events", [chatPage.roomId], - moreToLoad => { eventList.canLoad = moreToLoad } - ) - } + function loadPastEvents() { + if (chatPage.invited_id || ! canLoad || yPos > 0.1) { return } + eventList.canLoad = false + py.callClientCoro( + chatPage.userId, "load_past_events", [chatPage.roomId], + moreToLoad => { eventList.canLoad = moreToLoad } + ) } } HNoticePage { - text: qsTr("Nothing to show here yet...") + text: qsTr("Nothing here yet...") visible: eventList.model.count < 1 anchors.fill: parent diff --git a/src/qml/Chat/TypingMembersBar.qml b/src/qml/Chat/TypingMembersBar.qml index 12df0735..60ce9a7d 100644 --- a/src/qml/Chat/TypingMembersBar.qml +++ b/src/qml/Chat/TypingMembersBar.qml @@ -11,31 +11,42 @@ HRectangle { property alias label: typingLabel color: theme.chat.typingMembers.background - implicitHeight: typingLabel.text ? typingLabel.height : 0 + implicitHeight: typingLabel.text ? rowLayout.height : 0 Behavior on implicitHeight { HNumberAnimation {} } HRowLayout { + id: rowLayout spacing: theme.spacing - anchors.fill: parent - Layout.leftMargin: spacing - Layout.rightMargin: spacing - Layout.topMargin: spacing / 4 - Layout.bottomMargin: spacing / 4 HIcon { id: icon svgName: "typing" // TODO: animate - height: typingLabel.height + + Layout.fillHeight: true + Layout.leftMargin: rowLayout.spacing / 2 } HLabel { id: typingLabel - text: chatPage.roomInfo.typingText textFormat: Text.StyledText elide: Text.ElideRight + text: { + let tm = chatPage.roomInfo.typing_members + + if (tm.length == 0) return "" + if (tm.length == 1) return qsTr("%1 is typing...").arg(tm[0]) + + return qsTr("%1 and %2 are typing...") + .arg(tm.slice(0, -1).join(", ")).arg(tm.slice(-1)[0]) + } Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: rowLayout.spacing / 4 + Layout.bottomMargin: rowLayout.spacing / 4 + Layout.leftMargin: rowLayout.spacing / 2 + Layout.rightMargin: rowLayout.spacing / 2 } } } diff --git a/src/qml/EventHandlers/includes.js b/src/qml/EventHandlers/includes.js deleted file mode 100644 index fd673974..00000000 --- a/src/qml/EventHandlers/includes.js +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -"use strict" - -// FIXME: Obsolete method, but need Qt 5.12+ for standard JS modules import -Qt.include("app.js") -Qt.include("users.js") -Qt.include("rooms.js") diff --git a/src/qml/EventHandlers/rooms.js b/src/qml/EventHandlers/rooms.js deleted file mode 100644 index 0facac36..00000000 --- a/src/qml/EventHandlers/rooms.js +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -"use strict" - -Qt.include("../utils.js") - -function typingTextFor(members, ourUserId) { - let ourUsers = [] - let profiles = [] - let names = [] - - for (let i = 0; i < accounts.count; i++) { - ourUsers.push(accounts.get(i).userId) - } - - for (let member of members) { - if (! ourUsers.includes(member)) { profiles.push(users.find(member)) } - } - - profiles.sort((left, right) => { - if (left.displayName < right.displayName) { return -1 } - if (left.displayName > right.displayName) { return +1 } - return 0 - }) - - for (let profile of profiles) { - names.push(coloredNameHtml(profile.displayName, profile.userId)) - } - - if (names.length == 0) { return "" } - if (names.length == 1) { return qsTr("%1 is typing...").arg(names[0]) } - - let text = qsTr("%1 and %2 are typing...") - - if (names.length == 2) { return text.arg(names[0]).arg(names[1]) } - - return text.arg(names.slice(0, -1).join(", ")).arg(names.slice(-1)[0]) -} - - -function onRoomUpdated( - userId, category, roomId, displayName, avatarUrl, topic, - members, typingMembers, inviterId -) { - roomCategories.upsert({userId, name: category}, {userId, name: category}) - - function find(category) { - let found = rooms.getIndices({userId, roomId, category}, 1) - return found.length > 0 ? found[0] : null - } - - let replace = null - if (category == "Invites") { replace = find("Rooms") || find("Left") } - else if (category == "Rooms") { replace = find("Invites") || find("Left") } - else if (category == "Left") { replace = find("Invites") || find("Rooms")} - - let item = { - loading: false, - typingText: typingTextFor(typingMembers, userId), - - userId, category, roomId, displayName, avatarUrl, topic, members, - inviterId - } - - if (replace === null) { - rooms.upsert({userId, roomId, category}, item) - } else { - rooms.set(replace, item) - } -} - - -function onRoomForgotten(userId, roomId) { - rooms.popWhere({userId, roomId}) -} - - -function onTimelineEventReceived( - eventType, roomId, eventId, senderId, date, content, isLocalEcho, - targetUserId -) { - let item = { - eventType: py.getattr(eventType, "__name__"), - roomId, eventId, senderId, date, content, isLocalEcho, targetUserId - } - - if (isLocalEcho) { - timelines.append(item) - return - } - - // Replace first matching local echo - let found = timelines.getIndices( - {roomId, senderId, content, "isLocalEcho": true}, 1, 250 - ) - - if (found.length > 0) { - timelines.set(found[0], item) - } - // Multiple clients will emit duplicate events with the same eventId - else if (item.eventType == "OlmEvent" || item.eventType == "MegolmEvent") { - // Don't replace if an item with the same eventId is found in these - // cases, because it would be the ecrypted version of the event. - timelines.upsert({eventId}, item, false, 250) - } - else { - timelines.upsert({eventId}, item, true, 250) - } -} - - -var onTimelineMessageReceived = onTimelineEventReceived diff --git a/src/qml/EventHandlers/users.js b/src/qml/EventHandlers/users.js deleted file mode 100644 index 40b87d9e..00000000 --- a/src/qml/EventHandlers/users.js +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -"use strict" - -function onAccountUpdated(userId) { - accounts.append({userId}) -} - -function onAccountDeleted(userId) { - accounts.popWhere({userId}, 1) -} - -function onUserUpdated(userId, displayName, avatarUrl) { - users.upsert({userId}, {userId, displayName, avatarUrl, loading: false}) -} - -function onDeviceUpdated(userId, deviceId, ed25519Key, trust, displayName, - lastSeenIp, lastSeenDate) { -} - -function onDeviceDeleted(userId, deviceId) { -} diff --git a/src/qml/Models/Accounts.qml b/src/qml/Models/Accounts.qml deleted file mode 100644 index 8b1243d0..00000000 --- a/src/qml/Models/Accounts.qml +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel { - sorters: StringSorter { - roleName: "userId" - numericMode: true // human numeric sort - } -} diff --git a/src/qml/Models/Devices.qml b/src/qml/Models/Devices.qml deleted file mode 100644 index 4c2487b1..00000000 --- a/src/qml/Models/Devices.qml +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel {} diff --git a/src/qml/Models/RoomCategories.qml b/src/qml/Models/RoomCategories.qml deleted file mode 100644 index d2cd250c..00000000 --- a/src/qml/Models/RoomCategories.qml +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel { - sorters: [ - FilterSorter { ValueFilter { roleName: "name"; value: "Invites" } }, - FilterSorter { ValueFilter { roleName: "name"; value: "Rooms" } }, - FilterSorter { ValueFilter { roleName: "name"; value: "Left" } } - ] -} diff --git a/src/qml/Models/Rooms.qml b/src/qml/Models/Rooms.qml deleted file mode 100644 index 0813fa88..00000000 --- a/src/qml/Models/Rooms.qml +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel { - sorters: StringSorter { - roleName: "displayName" - } - - readonly property ListModel _emptyModel: ListModel {} - - function find(userId, category, roomId) { - if (! userId) { return } - - let found = rooms.getWhere({userId, roomId, category}, 1)[0] - if (found) { return found } - - return { - userId, category, roomId, - displayName: "", - avatarUrl: "", - topic: "", - members: _emptyModel, - typingText: "", - inviterId: "", - loading: true, - } - } -} diff --git a/src/qml/Models/Timelines.qml b/src/qml/Models/Timelines.qml deleted file mode 100644 index 1de29d6e..00000000 --- a/src/qml/Models/Timelines.qml +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel { - function lastEventOf(roomId) { - for (let i = 0; i < count; i++) { - let item = get(i) // TODO: standardize - if (item.roomId == roomId) { return item } - } - return null - } - - sorters: ExpressionSorter { - expression: modelLeft.isLocalEcho && ! modelRight.isLocalEcho ? - true : - ! modelLeft.isLocalEcho && modelRight.isLocalEcho ? - false : - modelLeft.date > modelRight.date // descending order - } -} diff --git a/src/qml/Models/Users.qml b/src/qml/Models/Users.qml deleted file mode 100644 index ed07be7a..00000000 --- a/src/qml/Models/Users.qml +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HListModel { - function find(userId) { - // Happens when SortFilterProxyModel ExpressionFilter/Sorter/Role tests - // the expression with invalid data to establish property bindings - if (! userId) { return } - - let found = getWhere({userId}, 1)[0] - if (found) { return found } - - py.callCoro("request_user_update_event", [userId]) - - return { - userId, - displayName: "", - avatarUrl: "", - loading: true, - } - } -} diff --git a/src/qml/Pages/EditAccount/EditAccount.qml b/src/qml/Pages/EditAccount/EditAccount.qml index 94fa7fd7..005670f7 100644 --- a/src/qml/Pages/EditAccount/EditAccount.qml +++ b/src/qml/Pages/EditAccount/EditAccount.qml @@ -14,10 +14,14 @@ HPage { property int avatarPreferredSize: 256 property string userId: "" - readonly property var userInfo: users.find(userId) - readonly property bool ready: userInfo && ! userInfo.loading - property string headerName: userInfo ? userInfo.displayName : "" + readonly property bool ready: accountInfo !== "waiting" + + readonly property var accountInfo: Utils.getItem( + modelSources["Account"] || [], "user_id", userId + ) || "waiting" + + property string headerName: ready ? accountInfo.display_name : userId hideHeaderUnderHeight: avatarPreferredSize headerLabel.text: qsTr("Account settings for %1").arg( @@ -27,6 +31,7 @@ HPage { HSpacer {} Repeater { + id: repeater model: ["Profile.qml", "Encryption.qml"] HRectangle { @@ -34,6 +39,9 @@ HPage { Behavior on color { HColorAnimation {} } Layout.alignment: Qt.AlignCenter + Layout.topMargin: header.visible || index > 0 ? theme.spacing : 0 + Layout.bottomMargin: + header.visible || index < repeater.count - 1? theme.spacing : 0 Layout.maximumWidth: Math.min(parent.width, 640) Layout.preferredWidth: diff --git a/src/qml/Pages/EditAccount/Profile.qml b/src/qml/Pages/EditAccount/Profile.qml index c4c555ea..18c0eed3 100644 --- a/src/qml/Pages/EditAccount/Profile.qml +++ b/src/qml/Pages/EditAccount/Profile.qml @@ -16,7 +16,7 @@ HGridLayout { userId, "set_displayname", [nameField.field.text], () => { saveButton.nameChangeRunning = false editAccount.headerName = - Qt.binding(() => userInfo.displayName) + Qt.binding(() => accountInfo.display_name) } ) } @@ -40,12 +40,12 @@ HGridLayout { } function cancelChanges() { - nameField.field.text = userInfo.displayName + nameField.field.text = accountInfo.display_name aliasField.field.text = aliasField.currentAlias fileDialog.selectedFile = "" fileDialog.file = "" - editAccount.headerName = Qt.binding(() => userInfo.displayName) + editAccount.headerName = Qt.binding(() => accountInfo.display_name) } columns: 2 @@ -59,6 +59,8 @@ HGridLayout { id: avatar userId: editAccount.userId + displayName: accountInfo.display_name + avatarUrl: accountInfo.avatar_url imageUrl: fileDialog.selectedFile || fileDialog.file || defaultImageUrl toolTipImageUrl: "" @@ -72,16 +74,22 @@ HGridLayout { visible: opacity > 0 opacity: ! fileDialog.dialog.visible && (! avatar.imageUrl || avatar.hovered) ? 1 : 0 - Behavior on opacity { HNumberAnimation {} } anchors.fill: parent - color: Utils.hsla(0, 0, 0, avatar.imageUrl ? 0.7 : 1) + color: Utils.hsluv(0, 0, 0, + (! avatar.imageUrl && overlayHover.hovered) ? 0.9 : 0.7 + ) + + Behavior on opacity { HNumberAnimation {} } + Behavior on color { HColorAnimation {} } HColumnLayout { anchors.centerIn: parent spacing: currentSpacing width: parent.width + HoverHandler { id: overlayHover } + HIcon { svgName: "upload-avatar" dimension: 64 @@ -92,7 +100,11 @@ HGridLayout { HLabel { text: qsTr("Upload profile picture") - color: Utils.hsla(0, 0, 90, 1) + color: (! avatar.imageUrl && overlayHover.hovered) ? + Qt.lighter(theme.colors.accentText, 1.2) : + Utils.hsluv(0, 0, 90, 1) + Behavior on color { HColorAnimation {} } + font.pixelSize: theme.fontSize.big * avatar.height / avatarPreferredSize wrapMode: Text.WordWrap @@ -107,7 +119,7 @@ HGridLayout { id: fileDialog fileType: HFileDialogOpener.FileType.Images dialog.title: qsTr("Select profile picture for %1") - .arg(userInfo.displayName) + .arg(accountInfo.display_name) } } @@ -129,14 +141,14 @@ HGridLayout { } HLabeledTextField { - property bool changed: field.text != userInfo.displayName + property bool changed: field.text != accountInfo.display_name property string fText: field.text onFTextChanged: editAccount.headerName = field.text id: nameField label.text: qsTr("Display name:") - field.text: userInfo.displayName + field.text: accountInfo.display_name field.onAccepted: applyChanges() Layout.fillWidth: true diff --git a/src/qml/Python.qml b/src/qml/Python.qml index fe666c77..e573cf34 100644 --- a/src/qml/Python.qml +++ b/src/qml/Python.qml @@ -4,7 +4,7 @@ import QtQuick 2.12 import QtQuick.Controls 2.12 import io.thp.pyotherside 1.5 -import "EventHandlers/includes.js" as EventHandlers +import "event_handlers.js" as EventHandlers Python { id: py @@ -60,21 +60,17 @@ Python { addImportPath("src") addImportPath("qrc:/") importNames("python", ["APP"], () => { - call("APP.is_debug_on", [Qt.application.arguments], on => { - window.debug = on + loadSettings(() => { + callCoro("saved_accounts.any_saved", [], any => { + py.ready = true + willLoadAccounts(any) - loadSettings(() => { - callCoro("saved_accounts.any_saved", [], any => { - py.ready = true - willLoadAccounts(any) - - if (any) { - py.loadingAccounts = true - py.callCoro("load_saved_accounts", [], () => { - py.loadingAccounts = false - }) - } - }) + if (any) { + py.loadingAccounts = true + py.callCoro("load_saved_accounts", [], () => { + py.loadingAccounts = false + }) + } }) }) }) diff --git a/src/qml/Shortcuts.qml b/src/qml/Shortcuts.qml index e4c86087..b2b9d10b 100644 --- a/src/qml/Shortcuts.qml +++ b/src/qml/Shortcuts.qml @@ -34,7 +34,7 @@ Item { Shortcut { sequences: settings.keys ? settings.keys.startDebugger : [] - onActivated: if (window.debug) { py.call("APP.pdb") } + onActivated: if (debugMode) { py.call("APP.pdb") } } /* diff --git a/src/qml/SidePane/AccountDelegate.qml b/src/qml/SidePane/AccountDelegate.qml index 05f5dde4..6ec75d44 100644 --- a/src/qml/SidePane/AccountDelegate.qml +++ b/src/qml/SidePane/AccountDelegate.qml @@ -10,14 +10,14 @@ Column { width: parent.width spacing: theme.spacing / 2 - property var userInfo: users.find(model.userId) property bool expanded: true + readonly property var modelItem: model Component.onCompleted: - expanded = ! window.uiState.collapseAccounts[model.userId] + expanded = ! window.uiState.collapseAccounts[model.user_id] onExpandedChanged: { - window.uiState.collapseAccounts[model.userId] = ! expanded + window.uiState.collapseAccounts[model.user_id] = ! expanded window.uiStateChanged() } @@ -28,7 +28,7 @@ Column { TapHandler { onTapped: pageStack.showPage( - "EditAccount/EditAccount", { "userId": model.userId } + "EditAccount/EditAccount", { "userId": model.user_id } ) } @@ -38,46 +38,22 @@ Column { HUserAvatar { id: avatar - // Need to do this because conflict with the model property - Component.onCompleted: userId = model.userId + userId: model.user_id + displayName: model.display_name + avatarUrl: model.avatar_url } - HColumnLayout { + HLabel { + id: accountLabel + color: theme.sidePane.account.name + text: model.display_name || model.user_id + font.pixelSize: theme.fontSize.big + elide: HLabel.ElideRight + leftPadding: sidePane.currentSpacing + rightPadding: leftPadding + Layout.fillWidth: true Layout.fillHeight: true - - HLabel { - id: accountLabel - color: theme.sidePane.account.name - text: userInfo.displayName || model.userId - font.pixelSize: theme.fontSize.big - elide: HLabel.ElideRight - leftPadding: sidePane.currentSpacing - rightPadding: leftPadding - - Layout.fillWidth: true - } - - HTextField { - visible: false // TODO - - id: statusEdit - // text: userInfo.statusMessage - placeholderText: qsTr("Set status message") - font.pixelSize: theme.fontSize.small - background: null - bordered: false - - padding: 0 - leftPadding: accountLabel.leftPadding - rightPadding: leftPadding - Layout.fillWidth: true - - onEditingFinished: { - //Backend.setStatusMessage(model.userId, text) TODO - pageStack.forceActiveFocus() - } - } } ExpandButton { @@ -88,17 +64,15 @@ Column { } } - RoomCategoriesList { + RoomList { id: roomCategoriesList visible: height > 0 width: parent.width height: childrenRect.height * (accountDelegate.expanded ? 1 : 0) clip: heightAnimation.running - userId: userInfo.userId + userId: modelItem.user_id - Behavior on height { - HNumberAnimation { id: heightAnimation } - } + Behavior on height { HNumberAnimation { id: heightAnimation } } } } diff --git a/src/qml/SidePane/AccountList.qml b/src/qml/SidePane/AccountList.qml index 4031945e..cfe815d6 100644 --- a/src/qml/SidePane/AccountList.qml +++ b/src/qml/SidePane/AccountList.qml @@ -9,6 +9,10 @@ HListView { id: accountList clip: true - model: accounts + model: HListModel { + keyField: "user_id" + source: modelSources["Account"] || [] + } + delegate: AccountDelegate {} } diff --git a/src/qml/SidePane/ExpandButton.qml b/src/qml/SidePane/ExpandButton.qml index 675276db..e77c85f1 100644 --- a/src/qml/SidePane/ExpandButton.qml +++ b/src/qml/SidePane/ExpandButton.qml @@ -2,7 +2,6 @@ // This file is part of harmonyqml, licensed under LGPLv3. import QtQuick 2.12 -import QtQuick.Layouts 1.12 import "../Base" HUIButton { diff --git a/src/qml/SidePane/RoomCategoriesList.qml b/src/qml/SidePane/RoomCategoriesList.qml deleted file mode 100644 index 3b9bb19e..00000000 --- a/src/qml/SidePane/RoomCategoriesList.qml +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import QtQuick.Layouts 1.12 -import SortFilterProxyModel 0.2 -import "../Base" - -HFixedListView { - property string userId: "" - - id: roomCategoriesList - - model: SortFilterProxyModel { - sourceModel: roomCategories - filters: ValueFilter { - roleName: "userId" - value: userId - } - } - - delegate: RoomCategoryDelegate {} -} diff --git a/src/qml/SidePane/RoomCategoryDelegate.qml b/src/qml/SidePane/RoomCategoryDelegate.qml deleted file mode 100644 index 6fb0ba34..00000000 --- a/src/qml/SidePane/RoomCategoryDelegate.qml +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import QtQuick.Layouts 1.12 -import "../Base" - -Column { - id: roomCategoryDelegate - width: roomCategoriesList.width - - property int normalHeight: childrenRect.height // avoid binding loop - - opacity: roomList.model.count > 0 ? 1 : 0 - height: normalHeight * opacity - visible: opacity > 0 - - Behavior on opacity { HNumberAnimation {} } - - property string roomListUserId: userId - property bool expanded: true - - Component.onCompleted: { - if (! window.uiState.collapseCategories[model.userId]) { - window.uiState.collapseCategories[model.userId] = {} - window.uiStateChanged() - } - - expanded = !window.uiState.collapseCategories[model.userId][model.name] - } - - onExpandedChanged: { - window.uiState.collapseCategories[model.userId][model.name] = !expanded - window.uiStateChanged() - } - - HRowLayout { - width: parent.width - - HLabel { - id: roomCategoryLabel - text: model.name - font.weight: Font.DemiBold - elide: Text.ElideRight - topPadding: theme.spacing / 2 - bottomPadding: topPadding - - Layout.leftMargin: sidePane.currentSpacing - Layout.fillWidth: true - } - - ExpandButton { - expandableItem: roomCategoryDelegate - iconDimension: 12 - } - } - - RoomList { - id: roomList - visible: height > 0 - width: roomCategoriesList.width - accountList.Layout.leftMargin - opacity: roomCategoryDelegate.expanded ? 1 : 0 - height: childrenRect.height * opacity - clip: listHeightAnimation.running - - userId: roomListUserId - category: name - - Behavior on opacity { - HNumberAnimation { id: listHeightAnimation } - } - } -} diff --git a/src/qml/SidePane/RoomDelegate.qml b/src/qml/SidePane/RoomDelegate.qml index 04bdaef9..23daf067 100644 --- a/src/qml/SidePane/RoomDelegate.qml +++ b/src/qml/SidePane/RoomDelegate.qml @@ -12,11 +12,7 @@ HInteractiveRectangle { height: childrenRect.height color: theme.sidePane.room.background - TapHandler { - onTapped: pageStack.showRoom( - roomList.userId, roomList.category, model.roomId - ) - } + TapHandler { onTapped: pageStack.showRoom(userId, model.room_id) } Row { width: parent.width - leftPadding * 2 @@ -30,8 +26,8 @@ HInteractiveRectangle { HRoomAvatar { id: roomAvatar - userId: model.userId - roomId: model.roomId + displayName: model.display_name + avatarUrl: model.avatar_url } HColumnLayout { @@ -40,9 +36,9 @@ HInteractiveRectangle { HLabel { id: roomLabel color: theme.sidePane.room.name - text: model.displayName || "Empty room" + text: model.display_name || "Empty room" textFormat: - model.displayName? Text.PlainText : Text.StyledText + model.display_name? Text.PlainText : Text.StyledText elide: Text.ElideRight verticalAlignment: Qt.AlignVCenter @@ -50,30 +46,27 @@ HInteractiveRectangle { } HRichLabel { - function getText(ev) { - if (! ev) { return "" } - - if (ev.eventType == "RoomMessageEmote" || - ! ev.eventType.startsWith("RoomMessage")) - { - return Utils.processedEventText(ev) - } - - return Utils.coloredNameHtml( - users.find(ev.senderId).displayName, - ev.senderId - ) + ": " + py.callSync("inlinify", [ev.content]) - } - - // Have to do it like this to avoid binding loop - property var lastEv: timelines.lastEventOf(model.roomId) - onLastEvChanged: text = getText(lastEv) - id: subtitleLabel color: theme.sidePane.room.subtitle visible: Boolean(text) textFormat: Text.StyledText + text: { + if (! model.last_event) { return "" } + + let ev = model.last_event + + if (ev.event_type === "RoomMessageEmote" || + ! ev.event_type.startsWith("RoomMessage")) { + return Utils.processedEventText(ev) + } + + return Utils.coloredNameHtml( + ev.sender_name, ev.sender_id + ) + ": " + ev.inline_content + } + + font.pixelSize: theme.fontSize.small elide: Text.ElideRight diff --git a/src/qml/SidePane/RoomList.qml b/src/qml/SidePane/RoomList.qml index f471fafc..ac07a621 100644 --- a/src/qml/SidePane/RoomList.qml +++ b/src/qml/SidePane/RoomList.qml @@ -2,38 +2,19 @@ // This file is part of harmonyqml, licensed under LGPLv3. import QtQuick 2.12 -import QtQuick.Layouts 1.12 -import SortFilterProxyModel 0.2 import "../Base" import "../utils.js" as Utils HFixedListView { id: roomList - property string userId: "" - property string category: "" - model: SortFilterProxyModel { - sourceModel: rooms - filters: AllOf { - ValueFilter { - roleName: "category" - value: category - } - - ValueFilter { - roleName: "userId" - value: userId - } - - ExpressionFilter { - // Utils... won't work directly in expression? - function filterIt(filter, text) { - return Utils.filterMatches(filter, text) - } - expression: filterIt(paneToolBar.roomFilter, displayName) - } - } + model: HListModel { + source: Utils.filterModelSource( + modelSources[["Room", userId]] || [], + paneToolBar.roomFilter, + ) + keyField: "room_id" } delegate: RoomDelegate {} diff --git a/src/qml/UI.qml b/src/qml/UI.qml index d954e361..ec848b23 100644 --- a/src/qml/UI.qml +++ b/src/qml/UI.qml @@ -37,7 +37,7 @@ HRectangle { let props = window.uiState.pageProperties if (page == "Chat/Chat.qml") { - pageStack.showRoom(props.userId, props.category, props.roomId) + pageStack.showRoom(props.userId, props.roomId) } else { pageStack.show(page, props) } @@ -45,11 +45,11 @@ HRectangle { } property bool accountsPresent: - accounts.count > 0 || py.loadingAccounts + (modelSources["Account"] || []).length > 0 || py.loadingAccounts HImage { - visible: Boolean(Qt.resolvedUrl(source)) id: mainUIBackground + visible: Boolean(Qt.resolvedUrl(source)) fillMode: Image.PreserveAspectCrop source: theme.ui.image sourceSize.width: Screen.width @@ -99,12 +99,11 @@ HRectangle { window.uiStateChanged() } - function showRoom(userId, category, roomId) { - let roomInfo = rooms.find(userId, category, roomId) - show("Chat/Chat.qml", {roomInfo}) + function showRoom(userId, roomId) { + show("Chat/Chat.qml", {userId, roomId}) window.uiState.page = "Chat/Chat.qml" - window.uiState.pageProperties = {userId, category, roomId} + window.uiState.pageProperties = {userId, roomId} window.uiStateChanged() } diff --git a/src/qml/Window.qml b/src/qml/Window.qml index 82250564..5767c640 100644 --- a/src/qml/Window.qml +++ b/src/qml/Window.qml @@ -4,7 +4,6 @@ import QtQuick 2.12 import QtQuick.Controls 2.12 import "Base" -import "Models" ApplicationWindow { id: window @@ -14,23 +13,14 @@ ApplicationWindow { width: 640 height: 480 visible: true - title: "Harmony QML" color: "transparent" - Component.onCompleted: { - Qt.application.organization = "harmonyqml" - Qt.application.name = "harmonyqml" - Qt.application.displayName = "Harmony QML" - Qt.application.version = "0.1.0" - window.ready = true - } - - property bool debug: false - property bool ready: false + // 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 mainUI: null - // Note: settingsChanged(), uiStateChanged(), etc must be called manually property var settings: ({}) onSettingsChanged: py.saveConfig("ui_settings", settings) @@ -42,27 +32,16 @@ ApplicationWindow { Shortcuts { id: shortcuts} Python { id: py } - // Models - Accounts { id: accounts } - Devices { id: devices } - RoomCategories { id: roomCategories } - Rooms { id: rooms } - Timelines { id: timelines } - Users { id: users } - - LoadingScreen { - id: loadingScreen + Loader { anchors.fill: parent - visible: uiLoader.scale < 1 + source: py.ready ? "" : "LoadingScreen.qml" } Loader { id: uiLoader anchors.fill: parent - - property bool ready: window.ready && py.ready - scale: uiLoader.ready ? 1 : 0.5 - source: uiLoader.ready ? "UI.qml" : "" + scale: py.ready ? 1 : 0.5 + source: py.ready ? "UI.qml" : "" Behavior on scale { HNumberAnimation {} } } diff --git a/src/qml/EventHandlers/app.js b/src/qml/event_handlers.js similarity index 65% rename from src/qml/EventHandlers/app.js rename to src/qml/event_handlers.js index d199c65d..2299bde3 100644 --- a/src/qml/EventHandlers/app.js +++ b/src/qml/event_handlers.js @@ -3,11 +3,19 @@ "use strict" + function onExitRequested(exitCode) { Qt.exit(exitCode) } + function onCoroutineDone(uuid, result) { py.pendingCoroutines[uuid](result) delete pendingCoroutines[uuid] } + + +function onModelUpdated(syncId, data, serializedSyncId) { + window.modelSources[serializedSyncId] = data + window.modelSourcesChanged() +} diff --git a/src/qml/utils.js b/src/qml/utils.js index fa69f82a..e41eab88 100644 --- a/src/qml/utils.js +++ b/src/qml/utils.js @@ -19,20 +19,6 @@ function hsla(hue, saturation, lightness, alpha=1.0) { } -function arrayToModelItem(keysName, array) { - // Convert an array to an object suitable to be in a model, example: - // [1, 2, 3] → [{keysName: 1}, {keysName: 2}, {keysName: 3}] - let items = [] - - for (let item of array) { - let obj = {} - obj[keysName] = item - items.push(obj) - } - return items -} - - function hueFrom(string) { // Calculate and return a unique hue between 0 and 360 for the string let hue = 0 @@ -52,7 +38,7 @@ function nameColor(name) { } -function coloredNameHtml(name, userId, displayText=null) { +function coloredNameHtml(name, userId, displayText=null, disambiguate=false) { // substring: remove leading @ return "" + escapeHtml(displayText || name || userId) + @@ -71,19 +57,20 @@ function escapeHtml(string) { function processedEventText(ev) { - if (ev.eventType == "RoomMessageEmote") { - let name = users.find(ev.senderId).displayName - return "" + coloredNameHtml(name) + " " + ev.content + "" + if (ev.event_type == "RoomMessageEmote") { + return "" + + coloredNameHtml(ev.sender_name, ev.sender_id) + " " + + ev.content + "" } - if (ev.eventType.startsWith("RoomMessage")) { return ev.content } + if (ev.event_type.startsWith("RoomMessage")) { return ev.content } - let name = users.find(ev.senderId).displayName - let text = qsTr(ev.content).arg(coloredNameHtml(name, ev.senderId)) + let text = qsTr(ev.content).arg( + coloredNameHtml(ev.sender_name, ev.sender_id) + ) - if (text.includes("%2") && ev.targetUserId) { - let tname = users.find(ev.targetUserId).displayName - text = text.arg(coloredNameHtml(tname, ev.targetUserId)) + if (text.includes("%2") && ev.target_id) { + text = text.arg(coloredNameHtml(ev.target_name, ev.target_id)) } return text @@ -105,6 +92,17 @@ function filterMatches(filter, text) { } +function filterModelSource(source, filter_text, property="filter_string") { + if (! filter_text) { return source } + + let results = [] + for (let item of source) { + if (filterMatches(filter_text, item[property])) { results.push(item) } + } + return results +} + + function thumbnailParametersFor(width, height) { // https://matrix.org/docs/spec/client_server/latest#thumbnails @@ -127,3 +125,11 @@ function thumbnailParametersFor(width, height) { function minutesBetween(date1, date2) { return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000) } + + +function getItem(array, mainKey, value) { + for (let i = 0; i < array.length; i++) { + if (array[i][mainKey] === value) { return array[i] } + } + return undefined +} diff --git a/src/themes/Default.qpl b/src/themes/Default.qpl index 64d81caf..41605066 100644 --- a/src/themes/Default.qpl +++ b/src/themes/Default.qpl @@ -196,7 +196,7 @@ chat: color body: colors.text color date: colors.dimText - color greenText: hsluv(80, colors.saturation * 2.25, 80) + color greenText: hsluv(135, colors.saturation * 2.25, 80) color link: colors.link color code: colors.code diff --git a/submodules/SortFilterProxyModel b/submodules/SortFilterProxyModel deleted file mode 160000 index 770789ee..00000000 --- a/submodules/SortFilterProxyModel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 770789ee484abf69c230cbf1b64f39823e79a181 diff --git a/submodules/qsyncable b/submodules/qsyncable new file mode 160000 index 00000000..f5ca07b7 --- /dev/null +++ b/submodules/qsyncable @@ -0,0 +1 @@ +Subproject commit f5ca07b71cecda685d0dd4b3c74d2fb2ca71f711