diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b8d40a74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__ +.mypy_cache +build +dist +*.egg-info +*.pyc +*.qmlc +*.jsc + +.pylintrc + +tmp-* diff --git a/TODO.md b/TODO.md index e69de29b..3bd7cd72 100644 --- a/TODO.md +++ b/TODO.md @@ -0,0 +1,98 @@ +- license headers +- replace "property var" by "property " where applicable +- [debug mode](https://docs.python.org/3/library/asyncio-dev.html) + +OLD + +- Refactoring + - Migrate more JS functions to their own files / Implement in Python instead + - Don't bake in size properties for components + +- Bug fixes + - dataclass-like `default_factory` for ListItem + - Prevent briefly seeing login screen if there are accounts to + resumeSession for but they take time to appear + - 100% CPU usage when hitting top edge to trigger messages loading + - Sending `![A picture](https://picsum.photos/256/256)` → not clickable? + - Icons, images and HStyle singleton aren't reloaded + - `MessageDelegate.qml:63: TypeError: 'reloadPreviousItem' not a function` + - RoomEventsList scrolling when resizing the window + +- UI + - Invite to room + - Accounts delegates background + - SidePane delegates hover effect + - Server selection + - Register/Forgot? for SignIn dialog + - Scaling + - See [Text.fontSizeMode](https://doc.qt.io/qt-5/qml-qtquick-text.html#fontSizeMode-prop) + - Add room + - 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 + - Better theming/styling system + - See about + - Settings page + - Multiaccount aliases + - Message/text selection + +- Major features + - E2E + - Device verification + - Edit/delete own devices + - Request room keys from own other devices + - Auto-trust accounts within the same client + - Import/export keys + - Uploads + - QQuickImageProvider + - Read receipts + - Status message and presence + - Links preview + +- Client improvements + - Filtering rooms: search more than display names? + - nio.MatrixRoom has `typing_users`, no need to handle it on our own + - Initial sync filter and lazy load, see weechat-matrix `_handle_login()` + - See also `handle_response()`'s `keys_query` request + - HTTP/2 + - `retry_after_ms` when rate-limited + - 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) + - Make links in room subtitle clickable, formatting? + - `
` scrollbar on overflow
+  - Handle cases where an avatar char is # or @ (#alias room, @user\_id)
+  - When inviting someone to direct chat, room is "Empty room" until accepted,
+    it should be the peer's display name instead.
+  - Keep an accounts order
+  - See `Qt.callLater()` potential usages
+  - Banner name color instead of bold
+  - Animate RoomEventDelegate DayBreak apparition
+
+- Missing nio support
+  - MatrixRoom invited members list
+  - Invite events are missing their timestamps (needed for sorting)
+  - Left room events after client reboot
+  - `org.matrix.room.preview_urls` event
+  - `m.room.aliases` event
+  - Support "Empty room (was ...)" after peer left
+
+- Waiting for approval/release
+  - nio avatars
+  - olm/olm-devel 0.3.1 in void repos
+
+- Distribution
+  - Review setup.py, add dependencies
+  - README.md
+  - Use PyInstaller or pyqtdeploy
+    - Test command:
+    ```
+    pyinstaller --onefile --windowed --name harmonyqml \
+                --add-data 'harmonyqml/components:harmonyqml/components' \
+                --additional-hooks-dir . \
+                --upx-dir ~/opt/upx-3.95-amd64_linux \
+                run.py
+    ```
diff --git a/src/__pycache__/__about__.cpython-36.pyc b/src/__pycache__/__about__.cpython-36.pyc
deleted file mode 100644
index 5b429272..00000000
Binary files a/src/__pycache__/__about__.cpython-36.pyc and /dev/null differ
diff --git a/src/__pycache__/__init__.cpython-36.pyc b/src/__pycache__/__init__.cpython-36.pyc
deleted file mode 100644
index f949ebca..00000000
Binary files a/src/__pycache__/__init__.cpython-36.pyc and /dev/null differ
diff --git a/src/__pycache__/app.cpython-36.pyc b/src/__pycache__/app.cpython-36.pyc
deleted file mode 100644
index 2d95b18f..00000000
Binary files a/src/__pycache__/app.cpython-36.pyc and /dev/null differ
diff --git a/src/__pycache__/backend.cpython-36.pyc b/src/__pycache__/backend.cpython-36.pyc
deleted file mode 100644
index a8216184..00000000
Binary files a/src/__pycache__/backend.cpython-36.pyc and /dev/null differ
diff --git a/src/__pycache__/matrix_client.cpython-36.pyc b/src/__pycache__/matrix_client.cpython-36.pyc
deleted file mode 100644
index 4a5cf5f0..00000000
Binary files a/src/__pycache__/matrix_client.cpython-36.pyc and /dev/null differ
diff --git a/src/app.py b/src/app.py
index 9d83b7ef..2fed9cf3 100644
--- a/src/app.py
+++ b/src/app.py
@@ -26,7 +26,7 @@ class App:
         debug = False
 
         if "-d" in cli_flags or "--debug" in cli_flags:
-            self._run_in_loop(self._exit_on_app_file_change())
+            self.run_in_loop(self._exit_on_app_file_change())
             debug = True
 
         from .backend import Backend
@@ -47,28 +47,43 @@ class App:
         self.loop.run_forever()
 
 
-    def _run_in_loop(self, coro: Coroutine) -> Future:
+    def run_in_loop(self, coro: Coroutine) -> Future:
         return asyncio.run_coroutine_threadsafe(coro, self.loop)
 
 
+    def _call_coro(self, coro: Coroutine) -> str:
+        uuid = str(uuid4())
+
+        self.run_in_loop(coro).add_done_callback(
+            lambda future: CoroutineDone(uuid=uuid, result=future.result())
+        )
+        return uuid
+
+
     def call_backend_coro(self,
                           name:   str,
                           args:   Optional[List[str]]      = None,
                           kwargs: Optional[Dict[str, Any]] = None) -> str:
-        # To be used from QML
-
-        coro = getattr(self.backend, name)(*args or [], **kwargs or {})
-        uuid = str(uuid4())
-
-        self._run_in_loop(coro).add_done_callback(
-            lambda future: CoroutineDone(uuid=uuid, result=future.result())
+        return self._call_coro(
+            getattr(self.backend, name)(*args or [], **kwargs or {})
+        )
+
+
+    def call_client_coro(self,
+                         account_id: str,
+                         name:       str,
+                         args:       Optional[List[str]]      = None,
+                         kwargs:     Optional[Dict[str, Any]] = None) -> str:
+        client = self.backend.clients[account_id]  # type: ignore
+        return self._call_coro(
+            getattr(client, name)(*args or [], **kwargs or {})
         )
-        return uuid
 
 
     def pdb(self, additional_data: Sequence = ()) -> None:
         # pylint: disable=all
         ad = additional_data
+        rl = self.run_in_loop
         ba = self.backend
         cl = self.backend.clients  # type: ignore
         tcl = lambda user: cl[f"@test_{user}:matrix.org"]
diff --git a/src/backend.py b/src/backend.py
index 359f9840..ca2c75b6 100644
--- a/src/backend.py
+++ b/src/backend.py
@@ -1,11 +1,12 @@
 import asyncio
 import json
 from pathlib import Path
-from typing import Dict, Optional, Tuple
+from typing import Any, Dict, Optional, Tuple
 
 from atomicfile import AtomicFile
 
 from .app import App
+from .events import users
 from .matrix_client import MatrixClient
 
 SavedAccounts = Dict[str, Dict[str, str]]
@@ -34,6 +35,7 @@ class Backend:
         )
         await client.login(password)
         self.clients[client.user_id] = client
+        users.AccountUpdated(client.user_id)
 
 
     async def resume_client(self,
@@ -46,12 +48,14 @@ class Backend:
         )
         await client.resume(user_id=user_id, token=token, device_id=device_id)
         self.clients[client.user_id] = client
+        users.AccountUpdated(client.user_id)
 
 
     async def logout_client(self, user_id: str) -> None:
         client = self.clients.pop(user_id, None)
         if client:
-            await client.close()
+            await client.logout()
+            users.AccountDeleted(user_id)
 
 
     async def logout_all_clients(self) -> None:
diff --git a/src/events/__pycache__/__init__.cpython-36.pyc b/src/events/__pycache__/__init__.cpython-36.pyc
deleted file mode 100644
index 0a6489d0..00000000
Binary files a/src/events/__pycache__/__init__.cpython-36.pyc and /dev/null differ
diff --git a/src/events/__pycache__/app.cpython-36.pyc b/src/events/__pycache__/app.cpython-36.pyc
deleted file mode 100644
index cea2dde8..00000000
Binary files a/src/events/__pycache__/app.cpython-36.pyc and /dev/null differ
diff --git a/src/events/__pycache__/event.cpython-36.pyc b/src/events/__pycache__/event.cpython-36.pyc
deleted file mode 100644
index 91f0bd13..00000000
Binary files a/src/events/__pycache__/event.cpython-36.pyc and /dev/null differ
diff --git a/src/events/__pycache__/system.cpython-36.pyc b/src/events/__pycache__/system.cpython-36.pyc
deleted file mode 100644
index 905a2cbe..00000000
Binary files a/src/events/__pycache__/system.cpython-36.pyc and /dev/null differ
diff --git a/src/events/rooms.py b/src/events/rooms.py
index 6c06af58..d1f53413 100644
--- a/src/events/rooms.py
+++ b/src/events/rooms.py
@@ -8,19 +8,23 @@ from .event import Event
 
 @dataclass
 class RoomUpdated(Event):
+    user_id:         str                = field()
+    category:        str                = field()
     room_id:         str                = field()
     display_name:    Optional[str]      = None
     avatar_url:      Optional[str]      = None
     topic:           Optional[str]      = None
     last_event_date: Optional[datetime] = None
 
-    inviter:    Optional[Dict[str, str]] = None
+    inviter:    Optional[str]            = None
     left_event: Optional[Dict[str, str]] = None
 
 
 @dataclass
 class RoomDeleted(Event):
-    room_id: str = field()
+    user_id:  str = field()
+    category: str = field()
+    room_id:  str = field()
 
 
 @dataclass
diff --git a/src/events/users.py b/src/events/users.py
index 1991eb4c..bf0f7aa3 100644
--- a/src/events/users.py
+++ b/src/events/users.py
@@ -22,9 +22,10 @@ class AccountDeleted(Event):
 
 @dataclass
 class UserUpdated(Event):
-    user_id:      str           = field()
-    display_name: Optional[str] = None
-    avatar_url:   Optional[str] = None
+    user_id:        str           = field()
+    display_name:   Optional[str] = None
+    avatar_url:     Optional[str] = None
+    status_message: Optional[str] = None
 
 
 # Devices
diff --git a/src/matrix_client.py b/src/matrix_client.py
index dc89deb2..022b1a2f 100644
--- a/src/matrix_client.py
+++ b/src/matrix_client.py
@@ -1,7 +1,14 @@
+import asyncio
+import inspect
+import platform
+from contextlib import suppress
 from typing import Optional
 
 import nio
 
+from . import __about__
+from .events import rooms, users
+
 
 class MatrixClient(nio.AsyncClient):
     def __init__(self,
@@ -9,8 +16,12 @@ class MatrixClient(nio.AsyncClient):
                  homeserver: str           = "https://matrix.org",
                  device_id:  Optional[str] = None) -> None:
 
+        # TODO: ensure homeserver starts with a scheme://
+        self.sync_task: Optional[asyncio.Task] = None
         super().__init__(homeserver=homeserver, user=user, device_id=device_id)
 
+        self.connect_callbacks()
+
 
     def __repr__(self) -> str:
         return "%s(user_id=%r, homeserver=%r, device_id=%r)" % (
@@ -18,5 +29,96 @@ class MatrixClient(nio.AsyncClient):
         )
 
 
+    def connect_callbacks(self) -> None:
+        for name in dir(nio.responses):
+            if name.startswith("_"):
+                continue
+
+            obj = getattr(nio.responses, name)
+            if inspect.isclass(obj) and issubclass(obj, nio.Response):
+                with suppress(AttributeError):
+                    self.add_response_callback(getattr(self, f"on{name}"), obj)
+
+
+    async def start_syncing(self) -> None:
+        self.sync_task = asyncio.ensure_future(  # type: ignore
+            self.sync_forever(timeout=10_000)
+        )
+
+
+    @property
+    def default_device_name(self) -> str:
+        os_ = f" on {platform.system()}".rstrip()
+        os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
+        return f"{__about__.__pretty_name__}{os_}"
+
+
+    async def login(self, password: str) -> None:
+        response = await super().login(password, self.default_device_name)
+
+        if isinstance(response, nio.LoginError):
+            print(response)
+        else:
+            await self.start_syncing()
+
+
     async def resume(self, user_id: str, token: str, device_id: str) -> None:
         self.receive_response(nio.LoginResponse(user_id, device_id, token))
+        await self.start_syncing()
+
+
+    async def logout(self) -> None:
+        if self.sync_task:
+            self.sync_task.cancel()
+            with suppress(asyncio.CancelledError):
+                await self.sync_task
+
+        await self.close()
+
+
+    async def request_user_update_event(self, user_id: str) -> None:
+        response = await self.get_profile(user_id)
+
+        users.UserUpdated(
+            user_id        = user_id,
+            display_name   = response.displayname,
+            avatar_url     = response.avatar_url,
+            status_message = None,  # TODO
+        )
+
+
+    # Callbacks for nio responses
+
+    async def onSyncResponse(self, response: nio.SyncResponse) -> None:
+        for room_id in response.rooms.invite:
+            room: nio.rooms.MatrixRoom = self.invited_rooms[room_id]
+
+            rooms.RoomUpdated(
+                user_id      = self.user_id,
+                category     = "Invites",
+                room_id      = room_id,
+                display_name = room.display_name,
+                avatar_url   = room.gen_avatar_url,
+                topic        = room.topic,
+                inviter      = room.inviter,
+            )
+
+        for room_id in response.rooms.join:
+            room = self.rooms[room_id]
+
+            rooms.RoomUpdated(
+                user_id      = self.user_id,
+                category     = "Rooms",
+                room_id      = room_id,
+                display_name = room.display_name,
+                avatar_url   = room.gen_avatar_url,
+                topic        = room.topic,
+            )
+
+        for room_id in response.rooms.left:
+            rooms.RoomUpdated(
+                user_id  = self.user_id,
+                category = "Left",
+                room_id  = room_id,
+                # left_event TODO
+            )
diff --git a/src/qml/Base/HListModel.qml b/src/qml/Base/HListModel.qml
index ad864eba..44536ea9 100644
--- a/src/qml/Base/HListModel.qml
+++ b/src/qml/Base/HListModel.qml
@@ -37,16 +37,27 @@ ListModel {
         return results
     }
 
-    function upsert(where_role, is, new_item) {
+    function forEachWhere(where_role, is, max, func) {
+        var items = getWhere(where_role, is, max)
+        for (var i = 0; i < items.length; i++) {
+            func(item)
+        }
+    }
+
+    function upsert(where_role, is, new_item, update_if_exist) {
         // new_item can contain only the keys we're interested in updating
 
         var indices = getIndices(where_role, is, 1)
 
         if (indices.length == 0) {
             listModel.append(new_item)
-        } else {
+            return listModel.get(listModel.count)
+        }
+
+        if (update_if_exist != false) {
             listModel.set(indices[0], new_item)
         }
+        return listModel.get(indices[0])
     }
 
     function pop(index) {
@@ -54,4 +65,38 @@ ListModel {
         listModel.remove(index)
         return item
     }
+
+    function popWhere(where_role, is, max) {
+        var indices = getIndices(where_role, is, max)
+        var results = []
+
+        for (var i = 0; i < indices.length; i++) {
+            results.push(listModel.get(indices[i]))
+            listModel.remove(indices[i])
+        }
+        return results
+    }
+
+
+    function toObject(item_list) {
+        item_list = item_list || listModel
+        var obj_list = []
+
+        for (var i = 0; i < item_list.count; i++) {
+            var item = item_list.get(i)
+            var obj  = JSON.parse(JSON.stringify(item))
+
+            for (var role in obj) {
+                if (obj[role]["objectName"] != undefined) {
+                    obj[role] = toObject(item[role])
+                }
+            }
+            obj_list.push(obj)
+        }
+        return obj_list
+    }
+
+    function toJson() {
+        return JSON.stringify(toObject(), null, 4)
+    }
 }
diff --git a/src/qml/EventHandlers/includes.js b/src/qml/EventHandlers/includes.js
index 3b4d7409..a3466ada 100644
--- a/src/qml/EventHandlers/includes.js
+++ b/src/qml/EventHandlers/includes.js
@@ -1,2 +1,5 @@
 // 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")
+Qt.include("rooms_timeline.js")
diff --git a/src/qml/EventHandlers/rooms.js b/src/qml/EventHandlers/rooms.js
new file mode 100644
index 00000000..5e137781
--- /dev/null
+++ b/src/qml/EventHandlers/rooms.js
@@ -0,0 +1,60 @@
+function clientId(user_id, category, room_id) {
+    return user_id + " " + room_id + " " + category
+}
+
+
+function onRoomUpdated(user_id, category, room_id, display_name, avatar_url,
+                       topic, last_event_date, inviter, left_event) {
+
+    var client_id = clientId(user_id, category, room_id)
+    var rooms     = models.rooms
+
+    if (category == "Invites") {
+        rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id))
+        rooms.popWhere("clientId", clientId(user_id, "Left", room_id))
+    }
+    else if (category == "Rooms") {
+        rooms.popWhere("clientId", clientId(user_id, "Invites", room_id))
+        rooms.popWhere("clientId", clientId(user_id, "Left", room_id))
+    }
+    else if (category == "Left") {
+        var old_room  =
+            rooms.popWhere("clientId", clientId(user_id, "Rooms", room_id)) ||
+            rooms.popWhere("clientId", clientId(user_id, "Invites", room_id))
+
+        if (old_room) {
+            display_name = old_room.displayName
+            avatar_url   = old_room.avatarUrl
+            topic        = old_room.topic
+            inviter      = old_room.topic
+        }
+    }
+
+    rooms.upsert("clientId", client_id , {
+        "clientId":      client_id,
+        "userId":        user_id,
+        "category":      category,
+        "roomId":        room_id,
+        "displayName":   display_name,
+        "avatarUrl":     avatar_url,
+        "topic":         topic,
+        "lastEventDate": last_event_date,
+        "inviter":       inviter,
+        "leftEvent":     left_event
+    })
+    //print("room up", rooms.toJson())
+}
+
+
+function onRoomDeleted(user_id, category, room_id) {
+    var client_id = clientId(user_id, category, room_id)
+    return models.rooms.popWhere("clientId", client_id, 1)
+}
+
+
+function onRoomMemberUpdated(room_id, user_id, typing) {
+}
+
+
+function onRoomMemberDeleted(room_id, user_id) {
+}
diff --git a/src/qml/EventHandlers/rooms_timeline.js b/src/qml/EventHandlers/rooms_timeline.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/qml/EventHandlers/users.js b/src/qml/EventHandlers/users.js
new file mode 100644
index 00000000..9322a97d
--- /dev/null
+++ b/src/qml/EventHandlers/users.js
@@ -0,0 +1,24 @@
+function onAccountUpdated(user_id) {
+    models.accounts.append({"userId": user_id})
+}
+
+function AccountDeleted(user_id) {
+    models.accounts.popWhere("userId", user_id, 1)
+}
+
+function onUserUpdated(user_id, display_name, avatar_url, status_message) {
+    models.users.upsert("userId", user_id, {
+        "userId":        user_id,
+        "displayName":   display_name,
+        "avatarUrl":     avatar_url,
+        "statusMessage": status_message
+    })
+
+}
+
+function onDeviceUpdated(user_id, device_id, ed25519_key, trust, display_name,
+                         last_seen_ip, last_seen_date) {
+}
+
+function onDeviceDeleted(user_id, device_id) {
+}
diff --git a/src/qml/Models.qml b/src/qml/Models.qml
new file mode 100644
index 00000000..2aaac983
--- /dev/null
+++ b/src/qml/Models.qml
@@ -0,0 +1,34 @@
+import QtQuick 2.7
+import "Base"
+
+QtObject {
+    property HListModel accounts: HListModel {}
+
+    property HListModel users: HListModel {
+        function getUser(as_account_id, wanted_user_id) {
+            wanted_user_id = wanted_user_id || as_account_id
+
+            var found = users.getWhere("userId", wanted_user_id, 1)
+            if (found.length > 0) { return found[0] }
+
+            users.append({
+                "userId":        wanted_user_id,
+                "displayName":   "",
+                "avatarUrl":     "",
+                "statusMessage": ""
+            })
+
+            py.callClientCoro(
+                as_account_id, "request_user_update_event", [wanted_user_id]
+            )
+
+            return users.getWhere("userId", wanted_user_id, 1)[0]
+        }
+    }
+
+    property HListModel devices: HListModel {}
+
+    property HListModel rooms: HListModel {}
+
+    property HListModel timelines: HListModel {}
+}
diff --git a/src/qml/Python.qml b/src/qml/Python.qml
index c9582a38..2273f01e 100644
--- a/src/qml/Python.qml
+++ b/src/qml/Python.qml
@@ -6,16 +6,25 @@ import "EventHandlers/includes.js" as EventHandlers
 Python {
     id: py
 
-    signal ready(bool accountsToLoad)
-
+    property bool ready: false
     property var pendingCoroutines: ({})
 
+    property bool loadingAccounts: false
+
     function callCoro(name, args, kwargs, callback) {
         call("APP.call_backend_coro", [name, args, kwargs], function(uuid){
             pendingCoroutines[uuid] = callback || function() {}
         })
     }
 
+    function callClientCoro(account_id, name, args, kwargs, callback) {
+        var args = [account_id, name, args, kwargs]
+
+        call("APP.call_client_coro", args, function(uuid){
+            pendingCoroutines[uuid] = callback || function() {}
+        })
+    }
+
     Component.onCompleted: {
         for (var func in EventHandlers) {
             if (EventHandlers.hasOwnProperty(func)) {
@@ -29,8 +38,14 @@ Python {
                 window.debug = debug_on
 
                 callCoro("has_saved_accounts", [], {}, function(has) {
-                    print(has)
-                    py.ready(has)
+                    loadingAccounts = has
+                    py.ready = true
+
+                    if (has) {
+                        py.callCoro("load_saved_accounts", [], {}, function() {
+                            loadingAccounts = false
+                        })
+                    }
                 })
             })
         })
diff --git a/src/qml/SidePane/AccountDelegate.qml b/src/qml/SidePane/AccountDelegate.qml
index 70c24631..3a9ef467 100644
--- a/src/qml/SidePane/AccountDelegate.qml
+++ b/src/qml/SidePane/AccountDelegate.qml
@@ -6,7 +6,9 @@ Column {
     id: accountDelegate
     width: parent.width
 
-    property var user: Backend.users.get(userId)
+    // Avoid binding loop by using Component.onCompleted
+    property var user: null
+    Component.onCompleted: user = models.users.getUser(userId)
 
     property string roomCategoriesListUserId: userId
     property bool expanded: true
@@ -18,7 +20,7 @@ Column {
 
         HAvatar {
             id: avatar
-            name: user.displayName.value
+            name: user.displayName
         }
 
         HColumnLayout {
@@ -27,7 +29,7 @@ Column {
 
             HLabel {
                 id: accountLabel
-                text: user.displayName.value
+                text: user.displayName || user.userId
                 elide: HLabel.ElideRight
                 maximumLineCount: 1
                 Layout.fillWidth: true
diff --git a/src/qml/SidePane/AccountList.qml b/src/qml/SidePane/AccountList.qml
index 71503557..9c51ec35 100644
--- a/src/qml/SidePane/AccountList.qml
+++ b/src/qml/SidePane/AccountList.qml
@@ -6,6 +6,6 @@ HListView {
     id: accountList
     clip: true
 
-    model: Backend.accounts
+    model: models.accounts
     delegate: AccountDelegate {}
 }
diff --git a/src/qml/UI.qml b/src/qml/UI.qml
index 9f00e2be..fc07b5f9 100644
--- a/src/qml/UI.qml
+++ b/src/qml/UI.qml
@@ -8,7 +8,10 @@ import "SidePane"
 Item {
     id: mainUI
 
-    property bool accountsLoggedIn: Backend.clients.count > 0
+    property bool accountsPresent:
+        models.accounts.count > 0 || py.loadingAccounts
+    onAccountsPresentChanged:
+        pageStack.showPage(accountsPresent ? "Default" : "SignIn")
 
     HImage {
         id: mainUIBackground
@@ -26,7 +29,7 @@ Item {
 
         SidePane {
             id: sidePane
-            visible: accountsLoggedIn
+            visible: accountsPresent
             collapsed: width < Layout.minimumWidth + normalSpacing
 
             property int parentWidth: parent.width
@@ -68,17 +71,6 @@ Item {
                 )
             }
 
-            Connections {
-                target: py
-                onReady: function(accountsToLoad) {
-                    pageStack.showPage(accountsToLoad ? "Default" : "SignIn")
-                    if (accountsToLoad) {
-                        py.callCoro("load_saved_accounts")
-                        // initialRoomTimer.start()
-                    }
-                }
-            }
-
             Timer {
                 // TODO: remove this, debug
                 id: initialRoomTimer
diff --git a/src/qml/Window.qml b/src/qml/Window.qml
index 72797caa..a6c1e9ec 100644
--- a/src/qml/Window.qml
+++ b/src/qml/Window.qml
@@ -1,5 +1,6 @@
 import QtQuick 2.7
 import QtQuick.Controls 2.2
+import "Base"
 
 ApplicationWindow {
     id: window
@@ -7,7 +8,7 @@ ApplicationWindow {
     height: 480
     visible: true
     color: "black"
-    title: "Test"
+    title: "Harmony QML"
 
     property bool debug: false
     property bool ready: false
@@ -23,6 +24,10 @@ ApplicationWindow {
         id: py
     }
 
+    Models {
+        id: models
+    }
+
     LoadingScreen {
         id: loadingScreen
         anchors.fill: parent
@@ -38,7 +43,7 @@ ApplicationWindow {
         source: uiLoader.ready ? "UI.qml" : ""
 
         Behavior on scale {
-            NumberAnimation { duration: 100 }
+            NumberAnimation { duration: HStyle.animationDuration }
         }
     }
 }