From a1b4d8900fc213b4c911afb697b00ad5d09b8f5b Mon Sep 17 00:00:00 2001 From: miruka Date: Fri, 28 Jun 2019 18:12:45 -0400 Subject: [PATCH] New backend work Models, account connection, fetching user profiles, show connected accounts in sidebar --- .gitignore | 12 +++ TODO.md | 98 +++++++++++++++++ src/__pycache__/__about__.cpython-36.pyc | Bin 375 -> 0 bytes src/__pycache__/__init__.cpython-36.pyc | Bin 184 -> 0 bytes src/__pycache__/app.cpython-36.pyc | Bin 3126 -> 0 bytes src/__pycache__/backend.cpython-36.pyc | Bin 4480 -> 0 bytes src/__pycache__/matrix_client.cpython-36.pyc | Bin 1126 -> 0 bytes src/app.py | 35 ++++-- src/backend.py | 8 +- .../__pycache__/__init__.cpython-36.pyc | Bin 156 -> 0 bytes src/events/__pycache__/app.cpython-36.pyc | Bin 658 -> 0 bytes src/events/__pycache__/event.cpython-36.pyc | Bin 1099 -> 0 bytes src/events/__pycache__/system.cpython-36.pyc | Bin 669 -> 0 bytes src/events/rooms.py | 8 +- src/events/users.py | 7 +- src/matrix_client.py | 102 ++++++++++++++++++ src/qml/Base/HListModel.qml | 49 ++++++++- src/qml/EventHandlers/includes.js | 3 + src/qml/EventHandlers/rooms.js | 60 +++++++++++ src/qml/EventHandlers/rooms_timeline.js | 0 src/qml/EventHandlers/users.js | 24 +++++ src/qml/Models.qml | 34 ++++++ src/qml/Python.qml | 23 +++- src/qml/SidePane/AccountDelegate.qml | 8 +- src/qml/SidePane/AccountList.qml | 2 +- src/qml/UI.qml | 18 +--- src/qml/Window.qml | 9 +- 27 files changed, 458 insertions(+), 42 deletions(-) create mode 100644 .gitignore delete mode 100644 src/__pycache__/__about__.cpython-36.pyc delete mode 100644 src/__pycache__/__init__.cpython-36.pyc delete mode 100644 src/__pycache__/app.cpython-36.pyc delete mode 100644 src/__pycache__/backend.cpython-36.pyc delete mode 100644 src/__pycache__/matrix_client.cpython-36.pyc delete mode 100644 src/events/__pycache__/__init__.cpython-36.pyc delete mode 100644 src/events/__pycache__/app.cpython-36.pyc delete mode 100644 src/events/__pycache__/event.cpython-36.pyc delete mode 100644 src/events/__pycache__/system.cpython-36.pyc create mode 100644 src/qml/EventHandlers/rooms.js create mode 100644 src/qml/EventHandlers/rooms_timeline.js create mode 100644 src/qml/EventHandlers/users.js create mode 100644 src/qml/Models.qml 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 5b42927238f758f791ab40ac172a8eb92cf98b11..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 375
zcmYjN%}&EG3{JcLY%2o^UVsCaU7DdCxJ=U|hS*{Jj#DILg(cdmHAzX*N$neP4wX@eaN&@LCCuH&jwwXtlyc
s<)$V6I}HkDC^mYD0qBb=EkIgSO?xC1D|0^nd80XW5cq+&^Oi&Z8?E?nzyJUM

diff --git a/src/__pycache__/__init__.cpython-36.pyc b/src/__pycache__/__init__.cpython-36.pyc
deleted file mode 100644
index f949ebca0decc13c39a9199f82bac66e5829a9c7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 184
zcmXr!<>gA6D;z7zz`*brh~a<<$Z`PUVgVqL!jQt4!;s4u#mLBz!W7J)$^4QLD6GkN
zi`g+Cz)zDYiaD{Mpa`UAB|{N2kOC9G-1IZ@b5r%R6Y~=F3ySj7^$RLXGV=3)RDMZD
zYEf}!N@{#!ab;d|W`2B9er8T;Q9({(NveKvQL=t~d}dx|NqoFsLFFwDo80`A(wtN~
Kkgdf)%m4s=8!lS_

diff --git a/src/__pycache__/app.cpython-36.pyc b/src/__pycache__/app.cpython-36.pyc
deleted file mode 100644
index 2d95b18fd37162d565a45576aef76564f23d97d6..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 3126
zcmaJ@%Z}W}6|Lg4-_!HdjN?3EIB3V3F)SmCKoLf^Gi|(cAm5Qyeu3L80xj|bUgg|kYoxK0bfb!w#p30ybI(0hxBC6gKcbyK
z{=3E4KiL~U8}>1p{xdqsBu|;3tCjN15*8=hPVLMQjeCfh`$&r*dcBiY&isE!nIs?dKeK9oU^&cLg_+A#={@qmd>2uq^X1i-oU>FiY|&
z<*f?UG_FQH)x$^B0G;RsL3ut6XlfAD^p-?YYJVI?=W#AEqA&6&DZ~J82JvTcUIl4U
zl(j>>qOj|}83{DpSFsXZdJEv%reG6}V_5RUWk;O;nVICfps+b;?
z7u8vjADx9NEAorSS$d>ZbcARQ%Zs`d1WBHN85?+4chE6Dv^=ZB2Yk^3sE4m6fI96c
zO@c`pPPN*?#K@xQ_)(-(dIsc=VMu;yNo&c^J-s7sx~g{pB>0pK{z0}I=
zOPjN$y=2lipj%4|P`6G`7S>I<@NeFf@pwLcr2247vU52lSpjGVd`sCkZMG~n$A
zF&G^bd4Q+EBuV2SIt%k@tajl*eV4pA>MgRrgRXXsdoykTY`VAU`2jj=n$pR*V-10d
zWA%8?TC@oplYlF(5d@U|zoY5jp(Ea>EaR70@RNH>URjmB;xlK(X6}lA2u}T+UG#35
z>!tN6%R3IMyz`De`8}(AiMgCTeusm7oc#gq(^o81>%#WJ&%-J@d-*SXYy|N#chf?u
zcQBwR9!jvmAOKrqU8JAIwO57;T&uSpRz-Q2IQR>2D;ul)J_D6z0PNfk-#%?QtnNhO{Bz4
z#AY|K?Nb=!z-aWkFm*VIO_U?63G6+%_&SQ;$JmExnm)$(VrXJ}gUBGZL()+@P!?T#
z=bzKH3Dm3zueoSsQ?U9NU1dsa$yX?%ayUgO8goAZT_|dLYdmz_5t*~Me5_N=b#DzCm?{Dgm
zMoogKvg+3DG|a{_ymO2@jm8!~Bmkl?O@p;Q8e9)=rv(P}9T?3U*cyH4Wmr|pRCH}?
zSY5bKgsIpJr3@stwt$?GuaVoN;YT#*ed>r7>MASXj|tC2w)u?^evHYDGJ0?E4k@Fx
z*!{1;0*wuPJ!$slK!#B5YeGpoD&@-L>jcIULJ3IOCFF00VtB@0=j77CF3VEu8UKPK
zPY&hj(lulDrML7*N7nn2`+u_bC1hyH{(?i$U+se?#S8Da!ge1lxNsN{)t*khq^ppkR>
zl6ZCk_`D>SkS5WKbB%*h5b7vNFz%KrfnvFGo|T%EzZlOb)|rN8UD3)!`Ri9vBJHI3
z!pE)!pIY65$)zIfHADTMm9HbA!vTUS;l*dGdGv0#-Z!G9)RZx=#(>t`m
TXmW`(nE&^X68!nl9^C&A9+kZ2

diff --git a/src/__pycache__/backend.cpython-36.pyc b/src/__pycache__/backend.cpython-36.pyc
deleted file mode 100644
index a8216184dd772d0c0c620ba7568895fdfab8981d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 4480
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
N`Vyz9=Bm#+{0|p^1YZCE

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 0a6489d03a958045e7ed060ce13b70fb50782cd6..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 156
zcmXr!<>lJyFBHoF1dl-k3@`#24nSPY0whuxf*CX!{Z=v*frJsnFHil9{M=Oi?8Ll8
z{eq(Wbp3+Ll8pR3{lwzRyyW=&l8n@%;>?uP_@w;IoYbO%oWzn;{oFfiuwu+EG3;jqMHKDHKqaLiXFVIZ
zQdj3`~CG)9j
zT+{ZAwLbpZL9g!UsUBZ_2kqK$`hJDVC5C;407OznB>RF>N)FM1D~A2KFRp%c?j!7H
zunv?7qHP)bq^@-{KwbN+u7^?0eaQ2=-p@^cs>l>jTDPMD&pYjY5|g8Pg})coZXBTc
z*6601%y?5xi`$L5+BIev^|BxO%9^%H>*@Wd-x}?h6mw#Q@r{$A`rpsDt_u7pS(Uj`kU|}
QHoR-jUa*|!G>LNh4@ygqhyVZp

diff --git a/src/events/__pycache__/event.cpython-36.pyc b/src/events/__pycache__/event.cpython-36.pyc
deleted file mode 100644
index 91f0bd13274f9f35dfe46d401bcc1ed32b5291f5..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 1099
zcmZuwO=}x55S6rAdt*BxkhGK@3O$v**o9t$Nug<=2VZhq2tr-S&ZgRrNOGt{dUBvS
z6?*HT^xnUuYft?PIdx{;e1QcTz13)E=FOAdY;N|tKX#5^Fhc&22ZICNhw0vfQAANg
zN}5rMm>E_^S=8dl@Qfc4#Z`PoRIKST%3>YCPDgcNr@LfJnsrokMAGCh#3CsT{Gghb
zux=>nWp1Q%lN?>QBLc7w(>(*DNk$dPn2r=x?214*-iG6$|90-{BX2QWPT?#LU_uZd
z!gNUEl0a@c`At$5cqL06sIW0OT66PZbW)dk^hs87)Yy79Y8L*au3)MCiMFm#T1dC3
z@}d?W>%wT;80q!M*?gqWb>-b^88(aXM9g%hEx1LcKYMX5&0LEOtOUyy;tK!~%sH2*yV=cO<5QbV=MVvc)KE0o|Kb^t+JKtkK^_04_`
zr*4~#t^P?>r1%xjpz+YbIK&4?cLKt7toCzTJ?c4%hSLqfd?i!b%9UJ`Q}PZ(`$9cE
zWj?wFe9EWvlKx`fKq!UkM3YCMP@EkM^%Tqu1U%k?-^n$(q)Yalo$;d;SE!^r)6fQQ
zL0dL_9pY)BjlwJDw3*(w;ik7|3g`2>Y{t778Rh8Ei&rhBp%6{&yeO)|3o+a?buNt?
z1GxPM*zXE)cU?uB#(^gN2iF9XRw{76XteD?f|OaL5xBH7*A|n~cE_S3Ve?ViZP;=h
zFogGLk6rAnDJ|t~T-%3Jc7O&&TdVsPIB>3XXBKhV4P3)pvB*C3@91b}Q8|n4ZZAlC
YdR^F%?5(cj7@wF!8Ur?9NwnSh2fSkYng9R*

diff --git a/src/events/__pycache__/system.cpython-36.pyc b/src/events/__pycache__/system.cpython-36.pyc
deleted file mode 100644
index 905a2cbea8e4424aa7bcc9cc6efbb99818b873bc..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 669
zcmZuuJ&)8d5cNmmO+pr(bP_df8d6Y#;7)L&+v0%65{jbra2pZFS=&LR+G6j|@R!_D
z-9JE7jI(gpoTYghzwsN-vtO>)#oKWHq@sae4s*pqu3F!2
z`_{RTe(j)Fcl1jF^Qu*Zm%fMM4h09IpV%dsFM6-+dv3o-H
W`tWo7z9l 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 }
         }
     }
 }