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
zcmXr!<>k7wQ8<=?k%8ec0}^0lU|?`yU|=X#VPIfLVTfW#VT@u-VTxi(VUA)>VToc%
zVU1!}zQ`1o5q@$m)O>G64qxvBB-QT*}o1x2YPC6!QtTio&SWvNBQnfZC~@wd3*
z~MrYzzz#;+MI8
zMt*Lpes*GBqJBY9e!6}^Wl2VUo<72B@df$CiTcGw$@=l}iAnjTCGqik1(mlrY;yBc
aN^?@}K*3nd$H2hA!ogA6D;z7zz`*dB0SPcMFfceUFfbGgFfcHrFr+Z%Fyt~uF)}iwFayv6Jo5a6fD6vdoaP*4QYvy!2RnSlX9{BqOJ$j?pH&rZxs)GsK?PuDM~EXl~v
z(=VvZFUd$PD$YzvjZZAD%uCM9k59_a%tfk=EE4;cgMr~O0}^0nU|?`yU|=Yo!N9uSjp7B%v*qyR@<;K5
z+3Yz2xq?xGj0`CpDV#Y%xx!JxU^Z8dNUms6~Ab
zUs{rxmwJoEB{R9?7K=}2amg(X|ALat{Jg}RTO7fug{7%^$sjXWOG`6TOf(s9u{#zN
zxMUU;zhq=!V9;c|#Tg%;n3P{y5+AS0bc+{mq)UEY>MdT^ip-KAkRin-sVT`Im%%VQ
zEHvjZFfgPtL@}l?L@}i>rZCN6Okqx8X<>W@|;S`Y;)+ixxh>G1}b}T3WhiFl1
zNoi4DGRWs3e}HHf1_lOaP^z$DU|=X=C}GS3`=Xbrh9RCgg`tKao~48}i>(>NVlUyS
zVaVc4VeVzDVTk9dVaVdj;%;UD#d~&0T9%qu5}%WwUvP^BL>7UZeTx??5?@@BSX7c)6vYjai7x@Ckz1@_QBdM3
zPR&VM$xtNCz`*cJM?WJ!H&s77F)vZSpeR3Gzo4=tBR@|+Be5tqKd-VdH%Gs?C|N(T
zpg^yn@)k#Yd}dx|Nqjs%D1f-YK_BoL|cr7+bnX0sKGq%ec%BDETZEQT6}EXHPrW=2Ma
z6qZ`16xI~B9Hw06T4qLu8s-{?6n1c+Gu1E!GiY-7xm7Xgrc|-(>ZYV7m8ORj@j?!j{cd>=waL!k)qm3aCOAMg|a!WME_nXGmcHr@|sovIeEP
z#PY;sEo~^HYjcK+Xrb2Nc2JFo=(0FG?-W&nZj2#a575l$uv^ODMP`
zzrYcc20cqsixNR4N|6A_BxXqPfDF0C7!L^$P}(X2hl&yd1A`jKi)^6iXXIfrV62h>
zg$AyGhX)YI)u1#3PA8xkC}GHAtYOGv0(lffXEDRG8l#^kOOY5T8}SvR`m`g3vWV*$YoL`iW
znw~*!1O*=0ouIe}yA$G4P}*el(`1IYO##=ux50CpS5IA}uFWVyuxDmrelBo?I?-(t%y2hk9Rf_=omz`%->
z`HDdmUkNyi)i5?Q)i4zZq%+ho7l>st^fA`5fYTmBGh-)61xpP}Gh-HOJ3|^HsBCWG
z07XCtC~YzOp~M!*B4Lm#LFuA|p#!9uv6&%-u@@8$On#v71JydW*osn%OLIyznTkLK
zNs&G%88Jq&r9tbrB0YEzfP8<8!zL#&Hz_624&>-!P&%qo2L(WKVopvxw0w*Qg{z)T
zPJVJ?PO+UHOot}hE%x-(lEjjdB5;nr#avucRKyK39~7ohf{7_9@hSOvsqrAgKxS#O
zLZU$%q(%osfRaTKJXSOrAKA=N1*i*E1p>EU`S!CVVKR3!Zepz5)#E#Yz`%<
z#U=4o+*Y}XB}JJPdih1^;A{@6OiGJES?U&3at>GslIaZ?7#O@j0RfIHNs#voQj#!Z
z2V63tBq2~K0Zu|9;M%tXoaI5OE`_<5xf#?RV)1(kN@s4GOt;uF^HNgtN}_mD^NLH0
zQsWbglQT0lnQpNb6lLa>++s~B%`GSfmHhchSs=G&73b%nxzZQrN=;U9`iNpr&dE&8
zD=97lRsXk`3sRDzI6*~9Nl{{QswO)mZJ2-x6{f_LD5j*uDCUynoG7NOlp`Cae>=G#d?Va1(2cyZ@BEHjps21644^AR{>#dB9MCk%x(giHA{una>pf9+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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

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

literal 1126
zcmXr!<>e~bC>*<*s<+
z_413-{WMu_v6L347Tw~?$j?nJPAw`+ExN^-l3JFToEo2*a*M4fwWPEtFBud9Ajg7e
zRt5$JW>9E|F)%QcFf=n{G1f5EFxD`qF!nNo!X%hMlgUq$xd`OFB9Q7Lb_NCpO{QC{
z#ia$QMYlNO<1_OzOXA}}wil=7q(Kycm4j6>-r|gpPtHj!E{>01$?(fsALKaw?8Ll8
z{eq(Wbp3+Ll8pR3{fxw--2A-C!rYwrg8bq{{ox?A~H4M#+S&TIdSxhwySQ
z_*?8yy^t^ixmT01h?{|dp@{K3P-!3+Qw
CfCOIv

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!<>lJyFBHqbz`*brM8Gg30|SEt0|P@b3j+f~3PUi1CZpdmtREkrnU`4-AFo$Xd5gm)H$SB`C)EyQMKQ=304oY8pa1{>

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

literal 658
zcmXr!<>fk^EE4;bfq~&M0}^0mU|?`yU|=W?VPIfLVMt-jVaR2SVq^rdnR1wNnWLC<
zS)y1N8B&;2SaMi%*`nAO8B&?u8B$nN*jgA;*izY=nVT7-*ui4#DI6^fDI8ESjug&d
z22HM)AnP<4Z!tUORcbQb;!H^_Nlea3EH1vqnwFWGlk$?0fq_Aj@fNFVS!!NMGDtNH
zgJi(26JuatNM(p(Oks#(3S~%PjABk@sb@}M3TDt`zQyZWky#RyT3DJ|T#}kn1k&WE
z$$E<;K0YroH#I)~7FT?HZhlH>4v5VYA75CSm;;fC;!Lf`EQwFfPf5MSoS9d$lA(x)
zfdNANGS<(?&rQ|OPRvWxFDS}S*Dt6n$;i*s&qyrF&CjbW%+1j+E=tx<1-ZIdKe3=d
zub{Gsoq>UY0~DT23=9lhOkh5CFDamSDV4dIA&MoHwVow~F^Vmfy`C+FF_=M<=@zeZ
zeo=mDNoHQEOMYIepC(5U$oL{okTbbJ1PcQL!!4H5(#(`w%*7=|x7dnOi%WA#ia-uP
z_y)q_1!>@8U|;|-ia|QLn79~=Kq7vcoVVCYDho36(!pAzxZ&}XT3o~o(g;$2ixuqO
sA~ui!$g^P05Yyp7R|GQt7KaTa(Ct7GQVf#hVG>~CVdi1vVBui|07^@bhyVZp

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
zcmXr!<>g|ZAsj2m#K7>F0ST}$FfceUFfbImF)%QsFr+Z%Fyt~uF*1VKOgT)s%u&o>
zHggV36pK4U3QG!W3quNPDq}Nq6l*H8J3|Uv3VRDf3VSnCGh-B63Ue@nCdW&V1)7Yv
zSX}c;b2S-nai%1eBqrx178m;^gQQ@X8D_Zz0|P@ULlk2QLljdga|&Y$Qwu{gV-yS6
zIF?)7j-@5}!6ii?~vu3u1Dl98XMUr?D}l95_eoSBjupIBU(mzd==A{-TmZZk#rB;;0mnG(urp9xETmo_y
z15=eEl9TnYs`k@ly~Pn94{~UH{4K8d_}u)I(wx-z_**>j@r9*{IZzp%;*!LY%;enE
zl8pS6B9I+m{~!?@AU}da0K_N;Ie>$)2*k%8*iuM=4GQWxj48}1EG;Zi%qfh)44SOB
zSY1Kh_0wd$#a5JBQd*RU66T;t1L0yl1_p+9hBQWy87&+g3|R~=49$$Sj3ta&OeM@6
z49$#LENP52jPscK7(i(#m_d`-55*`@B-AmXs7}GTma*Gq}Z?mYJH90!m`VsX1woumJh~7ne;=W^qY!er|!C
z2*_kmXs|I>se`?w7aw1cUtAKOnU`4-AFpSVlb@WJQ*5V)(4)zEOC&xXo?PR>))dFb
z-{M9}Cbw9MQ}a@8v6NI6q!w{8FfeE`6@k2QizTrry%_8mO-8Ut5y(xqcu}ks06B{d
zl!#fl7`d3Lgt0hHlLc&U5jQAic|Zgx(!eGo5g@~h_!t-%Ko}D5Oklny`z@B#ywcnv
ykneAC!@ZMQTm*`_B2aLH69`x}l8K-Qfw-B&29g}@Kv}F9WD^e)4-*Ho02=_jV*8o^

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
zcmXr!<>j)!B^>*Mfq~&M0}^0mU|?`yU|=W?VPIfLVMt-jVaR2SVq^rdnR1wNnWLC<
zS)y1N8B&;2SaMi%*`nAO8B&?u8B$nN*jgA;*izY=nVT7-*ui4#DI6^fDI8ESjug&d
z22HM)AnP<4Z!tUORcbQb;!H^_Nlea3EH1vqnwFWGlk$?0fq_Aj@fNFVS!!NMGDtNH
zgJi(26JuatNM(p(Oks#(3S~%PjABk@sb@}M3TDt`z9ryTP~cjTSrU|5SejZ~lA2Nk
z(&wkidW$1IJ})shH9r0pSA2YKeoAQ$h|LopUs#%$1Cfd1Os&W)iBHZ?Nxj9KnOCxs
zp@@fp0Ydz;*3Zb#P1Vm%%uCcSD9TUQFQ_cZ$j{TyNG!_D&#NrV&519_FHY1iE=tx<
z1-ZUhzqqovBsEvBpt6Xafq{Vo6tqkX3=CXMU_N%=DWLc+mARQAiY1k`o+X7biY=AB
zo-Ktjm_d{27O!)DQGRJjW?rgGeqO4dCPxv-_##e_bGbkS3j+hgEtb;K%#>Tq#U(|z
z*osn%OLIz!Kn_6o3c}(AY2agEU;r_SK{~mZxEPB-B7T~jx7bQ53o`T4!CIrZ;Zc@a
zT*M602vUEG73}3AHjn_wvtZ2-)8Ro_1Ty{>hYcj~?LZMz43gww5@6zC=3(Su;b8;-
Dn#GhC

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 }
         }
     }
 }