New backend work

Models, account connection, fetching user profiles,
show connected accounts in sidebar
This commit is contained in:
miruka 2019-06-28 18:12:45 -04:00
parent e5bdf6a497
commit a1b4d8900f
27 changed files with 458 additions and 42 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__
.mypy_cache
build
dist
*.egg-info
*.pyc
*.qmlc
*.jsc
.pylintrc
tmp-*

98
TODO.md
View File

@ -0,0 +1,98 @@
- license headers
- replace "property var" by "property <object>" 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 <https://doc.qt.io/qt-5/qtquickcontrols2-configuration.html>
- 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?
- `<pre>` 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
```

Binary file not shown.

View File

@ -26,7 +26,7 @@ class App:
debug = False debug = False
if "-d" in cli_flags or "--debug" in cli_flags: 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 debug = True
from .backend import Backend from .backend import Backend
@ -47,28 +47,43 @@ class App:
self.loop.run_forever() 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) 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, def call_backend_coro(self,
name: str, name: str,
args: Optional[List[str]] = None, args: Optional[List[str]] = None,
kwargs: Optional[Dict[str, Any]] = None) -> str: kwargs: Optional[Dict[str, Any]] = None) -> str:
# To be used from QML return self._call_coro(
getattr(self.backend, name)(*args or [], **kwargs or {})
coro = getattr(self.backend, name)(*args or [], **kwargs or {}) )
uuid = str(uuid4())
self._run_in_loop(coro).add_done_callback( def call_client_coro(self,
lambda future: CoroutineDone(uuid=uuid, result=future.result()) 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: def pdb(self, additional_data: Sequence = ()) -> None:
# pylint: disable=all # pylint: disable=all
ad = additional_data ad = additional_data
rl = self.run_in_loop
ba = self.backend ba = self.backend
cl = self.backend.clients # type: ignore cl = self.backend.clients # type: ignore
tcl = lambda user: cl[f"@test_{user}:matrix.org"] tcl = lambda user: cl[f"@test_{user}:matrix.org"]

View File

@ -1,11 +1,12 @@
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from atomicfile import AtomicFile from atomicfile import AtomicFile
from .app import App from .app import App
from .events import users
from .matrix_client import MatrixClient from .matrix_client import MatrixClient
SavedAccounts = Dict[str, Dict[str, str]] SavedAccounts = Dict[str, Dict[str, str]]
@ -34,6 +35,7 @@ class Backend:
) )
await client.login(password) await client.login(password)
self.clients[client.user_id] = client self.clients[client.user_id] = client
users.AccountUpdated(client.user_id)
async def resume_client(self, async def resume_client(self,
@ -46,12 +48,14 @@ class Backend:
) )
await client.resume(user_id=user_id, token=token, device_id=device_id) await client.resume(user_id=user_id, token=token, device_id=device_id)
self.clients[client.user_id] = client self.clients[client.user_id] = client
users.AccountUpdated(client.user_id)
async def logout_client(self, user_id: str) -> None: async def logout_client(self, user_id: str) -> None:
client = self.clients.pop(user_id, None) client = self.clients.pop(user_id, None)
if client: if client:
await client.close() await client.logout()
users.AccountDeleted(user_id)
async def logout_all_clients(self) -> None: async def logout_all_clients(self) -> None:

View File

@ -8,18 +8,22 @@ from .event import Event
@dataclass @dataclass
class RoomUpdated(Event): class RoomUpdated(Event):
user_id: str = field()
category: str = field()
room_id: str = field() room_id: str = field()
display_name: Optional[str] = None display_name: Optional[str] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
topic: Optional[str] = None topic: Optional[str] = None
last_event_date: Optional[datetime] = None last_event_date: Optional[datetime] = None
inviter: Optional[Dict[str, str]] = None inviter: Optional[str] = None
left_event: Optional[Dict[str, str]] = None left_event: Optional[Dict[str, str]] = None
@dataclass @dataclass
class RoomDeleted(Event): class RoomDeleted(Event):
user_id: str = field()
category: str = field()
room_id: str = field() room_id: str = field()

View File

@ -25,6 +25,7 @@ class UserUpdated(Event):
user_id: str = field() user_id: str = field()
display_name: Optional[str] = None display_name: Optional[str] = None
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
status_message: Optional[str] = None
# Devices # Devices

View File

@ -1,7 +1,14 @@
import asyncio
import inspect
import platform
from contextlib import suppress
from typing import Optional from typing import Optional
import nio import nio
from . import __about__
from .events import rooms, users
class MatrixClient(nio.AsyncClient): class MatrixClient(nio.AsyncClient):
def __init__(self, def __init__(self,
@ -9,8 +16,12 @@ class MatrixClient(nio.AsyncClient):
homeserver: str = "https://matrix.org", homeserver: str = "https://matrix.org",
device_id: Optional[str] = None) -> None: 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) super().__init__(homeserver=homeserver, user=user, device_id=device_id)
self.connect_callbacks()
def __repr__(self) -> str: def __repr__(self) -> str:
return "%s(user_id=%r, homeserver=%r, device_id=%r)" % ( 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: async def resume(self, user_id: str, token: str, device_id: str) -> None:
self.receive_response(nio.LoginResponse(user_id, device_id, token)) 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
)

View File

@ -37,16 +37,27 @@ ListModel {
return results 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 // new_item can contain only the keys we're interested in updating
var indices = getIndices(where_role, is, 1) var indices = getIndices(where_role, is, 1)
if (indices.length == 0) { if (indices.length == 0) {
listModel.append(new_item) listModel.append(new_item)
} else { return listModel.get(listModel.count)
}
if (update_if_exist != false) {
listModel.set(indices[0], new_item) listModel.set(indices[0], new_item)
} }
return listModel.get(indices[0])
} }
function pop(index) { function pop(index) {
@ -54,4 +65,38 @@ ListModel {
listModel.remove(index) listModel.remove(index)
return item 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)
}
} }

View File

@ -1,2 +1,5 @@
// FIXME: Obsolete method, but need Qt 5.12+ for standard JS modules import // FIXME: Obsolete method, but need Qt 5.12+ for standard JS modules import
Qt.include("app.js") Qt.include("app.js")
Qt.include("users.js")
Qt.include("rooms.js")
Qt.include("rooms_timeline.js")

View File

@ -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) {
}

View File

View File

@ -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) {
}

34
src/qml/Models.qml Normal file
View File

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

View File

@ -6,16 +6,25 @@ import "EventHandlers/includes.js" as EventHandlers
Python { Python {
id: py id: py
signal ready(bool accountsToLoad) property bool ready: false
property var pendingCoroutines: ({}) property var pendingCoroutines: ({})
property bool loadingAccounts: false
function callCoro(name, args, kwargs, callback) { function callCoro(name, args, kwargs, callback) {
call("APP.call_backend_coro", [name, args, kwargs], function(uuid){ call("APP.call_backend_coro", [name, args, kwargs], function(uuid){
pendingCoroutines[uuid] = callback || function() {} 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: { Component.onCompleted: {
for (var func in EventHandlers) { for (var func in EventHandlers) {
if (EventHandlers.hasOwnProperty(func)) { if (EventHandlers.hasOwnProperty(func)) {
@ -29,8 +38,14 @@ Python {
window.debug = debug_on window.debug = debug_on
callCoro("has_saved_accounts", [], {}, function(has) { callCoro("has_saved_accounts", [], {}, function(has) {
print(has) loadingAccounts = has
py.ready(has) py.ready = true
if (has) {
py.callCoro("load_saved_accounts", [], {}, function() {
loadingAccounts = false
})
}
}) })
}) })
}) })

View File

@ -6,7 +6,9 @@ Column {
id: accountDelegate id: accountDelegate
width: parent.width 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 string roomCategoriesListUserId: userId
property bool expanded: true property bool expanded: true
@ -18,7 +20,7 @@ Column {
HAvatar { HAvatar {
id: avatar id: avatar
name: user.displayName.value name: user.displayName
} }
HColumnLayout { HColumnLayout {
@ -27,7 +29,7 @@ Column {
HLabel { HLabel {
id: accountLabel id: accountLabel
text: user.displayName.value text: user.displayName || user.userId
elide: HLabel.ElideRight elide: HLabel.ElideRight
maximumLineCount: 1 maximumLineCount: 1
Layout.fillWidth: true Layout.fillWidth: true

View File

@ -6,6 +6,6 @@ HListView {
id: accountList id: accountList
clip: true clip: true
model: Backend.accounts model: models.accounts
delegate: AccountDelegate {} delegate: AccountDelegate {}
} }

View File

@ -8,7 +8,10 @@ import "SidePane"
Item { Item {
id: mainUI 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 { HImage {
id: mainUIBackground id: mainUIBackground
@ -26,7 +29,7 @@ Item {
SidePane { SidePane {
id: sidePane id: sidePane
visible: accountsLoggedIn visible: accountsPresent
collapsed: width < Layout.minimumWidth + normalSpacing collapsed: width < Layout.minimumWidth + normalSpacing
property int parentWidth: parent.width 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 { Timer {
// TODO: remove this, debug // TODO: remove this, debug
id: initialRoomTimer id: initialRoomTimer

View File

@ -1,5 +1,6 @@
import QtQuick 2.7 import QtQuick 2.7
import QtQuick.Controls 2.2 import QtQuick.Controls 2.2
import "Base"
ApplicationWindow { ApplicationWindow {
id: window id: window
@ -7,7 +8,7 @@ ApplicationWindow {
height: 480 height: 480
visible: true visible: true
color: "black" color: "black"
title: "Test" title: "Harmony QML"
property bool debug: false property bool debug: false
property bool ready: false property bool ready: false
@ -23,6 +24,10 @@ ApplicationWindow {
id: py id: py
} }
Models {
id: models
}
LoadingScreen { LoadingScreen {
id: loadingScreen id: loadingScreen
anchors.fill: parent anchors.fill: parent
@ -38,7 +43,7 @@ ApplicationWindow {
source: uiLoader.ready ? "UI.qml" : "" source: uiLoader.ready ? "UI.qml" : ""
Behavior on scale { Behavior on scale {
NumberAnimation { duration: 100 } NumberAnimation { duration: HStyle.animationDuration }
} }
} }
} }