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
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"]

View File

@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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")

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

View File

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

View File

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

View File

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

View File

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