diff --git a/harmonyqml/backend/__init__.py b/harmonyqml/backend/__init__.py index 838c7f35..5f185003 100644 --- a/harmonyqml/backend/__init__.py +++ b/harmonyqml/backend/__init__.py @@ -1,5 +1,4 @@ # Copyright 2019 miruka # This file is part of harmonyqml, licensed under GPLv3. - -from .dummy import DummyBackend +from .backend import Backend diff --git a/harmonyqml/backend/backend.py b/harmonyqml/backend/backend.py new file mode 100644 index 00000000..d5618365 --- /dev/null +++ b/harmonyqml/backend/backend.py @@ -0,0 +1,42 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +import hashlib + +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot + +from .client_manager import ClientManager +from .model.qml_models import QMLModels + + +class Backend(QObject): + def __init__(self) -> None: + super().__init__() + self._client_manager: ClientManager = ClientManager() + self._models: QMLModels = QMLModels() + + from .signal_manager import SignalManager + self._signal_manager: SignalManager = SignalManager(self) + + # a = self._client_manager; m = self._models + # from PyQt5.QtCore import pyqtRemoveInputHook as PRI + # import pdb; PRI(); pdb.set_trace() + + self.clientManager.configLoad() + + + @pyqtProperty("QVariant", constant=True) + def clientManager(self): + return self._client_manager + + + @pyqtProperty("QVariant", constant=True) + def models(self): + return self._models + + + @pyqtSlot(str, result=float) + def hueFromString(self, string: str) -> float: + # pylint:disable=no-self-use + md5 = hashlib.md5(bytes(string, "utf-8")).hexdigest() + return float("0.%s" % int(md5[-10:], 16)) diff --git a/harmonyqml/backend/base.py b/harmonyqml/backend/base.py deleted file mode 100644 index 9ee75264..00000000 --- a/harmonyqml/backend/base.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright 2019 miruka -# This file is part of harmonyqml, licensed under GPLv3. - -import hashlib -from typing import Any, DefaultDict, Dict, NamedTuple, Optional - -from PyQt5.QtCore import QDateTime, QObject, pyqtProperty, pyqtSlot - -from .enums import Activity, MessageKind, Presence -from .list_model import ListModel, _QtListModel - - -class User(NamedTuple): - user_id: str - display_name: str - avatar_url: Optional[str] = None - status_message: Optional[str] = None - - -class Room(NamedTuple): - room_id: str - display_name: str - description: str = "" - unread_messages: int = 0 - presence: Presence = Presence.none - activity: Activity = Activity.none - last_activity_timestamp_ms: Optional[int] = None - avatar_url: Optional[str] = None - - -class Message(NamedTuple): - sender_id: str - date_time: QDateTime - content: str - kind: MessageKind = MessageKind.text - sender_avatar: Optional[str] = None - - -class Backend(QObject): - def __init__(self) -> None: - super().__init__() - self._known_users: Dict[str, User] = {} - - self.accounts: ListModel = ListModel() - self.rooms: DefaultDict[str, ListModel] = DefaultDict(ListModel) - self.messages: DefaultDict[str, ListModel] = DefaultDict(ListModel) - - - @pyqtProperty(_QtListModel, constant=True) - def accountsModel(self) -> _QtListModel: - return self.accounts.qt_model - - - @pyqtProperty("QVariantMap", constant=True) - def roomsModel(self) -> Dict[str, _QtListModel]: - return {account_id: l.qt_model for account_id, l in self.rooms.items()} - - - @pyqtProperty("QVariantMap", constant=True) - def messagesModel(self) -> Dict[str, _QtListModel]: - return {room_id: l.qt_model for room_id, l in self.messages.items()} - - - @pyqtSlot(str, str, str) - def sendMessage(self, sender_id: str, room_id: str, markdown: str) -> None: - self.localEcho(sender_id, room_id, markdown) - self.sendToServer(sender_id, room_id, markdown) - - - def localEcho(self, sender_id: str, room_id: str, html: str) -> None: - self.messages[room_id].append(Message( - sender_id, QDateTime.currentDateTime(), html, - )) - - - def sendToServer(self, sender_id: str, room_id: str, html: str) -> None: - pass - - - @pyqtSlot(str, result="QVariantMap") - def getUser(self, user_id: str) -> Dict[str, Any]: - for user in self.accounts: - if user.user_id == user_id: - return user._asdict() - - try: - return self._known_users[user_id]._asdict() - except KeyError: - name = user_id.lstrip("@").split(":")[0].capitalize() - user = User(user_id, name) - self._known_users[user_id] = user - return user._asdict() - - - @pyqtSlot(str, result=float) - def hueFromString(self, string: str) -> float: - # pylint: disable=no-self-use - md5 = hashlib.md5(bytes(string, "utf-8")).hexdigest() - return float("0.%s" % int(md5[-10:], 16)) - - - @pyqtSlot(str, str) - def setStatusMessage(self, user_id: str, to: str) -> None: - for user in self.accounts: - if user.user_id == user_id: - user.status_message = to - break - else: - raise ValueError(f"{user_id} not found in Backend.accounts") diff --git a/harmonyqml/backend/matrix_nio/client.py b/harmonyqml/backend/client.py similarity index 80% rename from harmonyqml/backend/matrix_nio/client.py rename to harmonyqml/backend/client.py index 497d709a..a85ce5d9 100644 --- a/harmonyqml/backend/matrix_nio/client.py +++ b/harmonyqml/backend/client.py @@ -4,13 +4,15 @@ import functools from concurrent.futures import Future, ThreadPoolExecutor from threading import Event -from typing import Callable, DefaultDict +from typing import Callable, DefaultDict, Dict from PyQt5.QtCore import QObject, pyqtSlot import nio import nio.responses as nr +from .model.items import User + # One pool per hostname/remote server; # multiple Client for different accounts on the same server can exist. _POOLS: DefaultDict[str, ThreadPoolExecutor] = \ @@ -38,7 +40,7 @@ class Client(QObject): self.pool: ThreadPoolExecutor = _POOLS[self.host] - from .net import NetworkManager + from .network_manager import NetworkManager self.net: NetworkManager = NetworkManager(self) self._stop_sync: Event = Event() @@ -75,11 +77,25 @@ class Client(QObject): self.net.write(self.nio.disconnect()) + @pyqtSlot() @futurize def startSyncing(self) -> None: while True: - print(self, self.net.talk(self.nio.sync, timeout=10)) + self.net.talk(self.nio.sync, timeout=10) if self._stop_sync.is_set(): self._stop_sync.clear() break + + + @pyqtSlot(str, str, result="QVariantMap") + def getUser(self, room_id: str, user_id: str) -> Dict[str, str]: + try: + name = self.nio.rooms[room_id].user_name(user_id) + except KeyError: + name = None + + return User( + user_id = user_id, + display_name = name or user_id, + )._asdict() diff --git a/harmonyqml/backend/matrix_nio/client_manager.py b/harmonyqml/backend/client_manager.py similarity index 79% rename from harmonyqml/backend/matrix_nio/client_manager.py rename to harmonyqml/backend/client_manager.py index 817f4ca8..a5f8fc26 100644 --- a/harmonyqml/backend/matrix_nio/client_manager.py +++ b/harmonyqml/backend/client_manager.py @@ -6,11 +6,12 @@ import json import os import platform import threading -from concurrent.futures import Future from typing import Dict, Optional from atomicfile import AtomicFile -from PyQt5.QtCore import QObject, QStandardPaths, pyqtProperty, pyqtSlot +from PyQt5.QtCore import ( + QObject, QStandardPaths, pyqtProperty, pyqtSignal, pyqtSlot +) from harmonyqml import __about__ @@ -23,6 +24,10 @@ _CONFIG_LOCK = threading.Lock() class ClientManager(QObject): + clientAdded = pyqtSignal(Client) + clientDeleted = pyqtSignal(str) + + def __init__(self) -> None: super().__init__() self._clients: Dict[str, Client] = {} @@ -32,7 +37,7 @@ class ClientManager(QObject): return f"{type(self).__name__}(clients={self.clients!r})" - @pyqtProperty("QVariantMap") + @pyqtProperty("QVariantMap", constant=True) def clients(self): return self._clients @@ -40,13 +45,9 @@ class ClientManager(QObject): @pyqtSlot() def configLoad(self) -> None: for user_id, info in self.configAccounts().items(): - cli = Client(info["hostname"], user_id) - - def on_done(_: Future, cli=cli) -> None: - self._clients[cli.nio.user_id] = cli - - cli.resumeSession(user_id, info["token"], info["device_id"])\ - .add_done_callback(on_done) + client = Client(info["hostname"], user_id) + client.resumeSession(user_id, info["token"], info["device_id"])\ + .add_done_callback(lambda _, c=client: self._on_connected(c)) @pyqtSlot(str, str, str) @@ -54,22 +55,25 @@ class ClientManager(QObject): def new(self, hostname: str, username: str, password: str, device_id: str = "") -> None: - cli = Client(hostname, username, device_id) + client = Client(hostname, username, device_id) + client.login(password, self.defaultDeviceName)\ + .add_done_callback(lambda _: self._on_connected(client)) - def on_done(_: Future, cli=cli) -> None: - self._clients[cli.nio.user_id] = cli - cli.login(password, self.defaultDeviceName).add_done_callback(on_done) + def _on_connected(self, client: Client) -> None: + self.clients[client.nio.user_id] = client + self.clientAdded.emit(client) @pyqtSlot(str) def delete(self, user_id: str) -> None: - client = self._clients.pop(user_id, None) + client = self.clients.pop(user_id, None) if client: + self.clientDeleted.emit(user_id) client.logout() - @pyqtProperty(str) + @pyqtProperty(str, constant=True) def defaultDeviceName(self) -> str: # pylint: disable=no-self-use os_ = f" on {platform.system()}".rstrip() os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else "" diff --git a/harmonyqml/backend/dummy.py b/harmonyqml/backend/dummy.py deleted file mode 100644 index 13b095c9..00000000 --- a/harmonyqml/backend/dummy.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2019 miruka -# This file is part of harmonyqml, licensed under GPLv3. - -from PyQt5.QtCore import QDateTime, Qt - -from .base import Backend, Message, Room, User - - -class DummyBackend(Backend): - def __init__(self) -> None: - super().__init__() - - dt = lambda t: QDateTime.fromString(f"2019-03-19T{t}.123", - Qt.ISODateWithMs) - db = lambda t: QDateTime.fromString(f"2019-03-20T{t}.456", - Qt.ISODateWithMs) - - self.accounts.extend([ - User("@renko:matrix.org", "Renko", None, "Sleeping, zzz..."), - User("@mary:matrix.org", "Mary"), - ]) - - self.rooms["@renko:matrix.org"].extend([ - Room("!test:matrix.org", "Test", "Test room"), - Room("!mary:matrix.org", "Mary", - "Lorem ipsum sit dolor amet this is a long text to test " - "wrapping of room subtitle etc 1234 example foo bar abc", 2), - Room("!foo:matrix.org", "Another room"), - ]) - - self.rooms["@mary:matrix.org"].extend([ - Room("!test:matrix.org", "Test", "Test room"), - Room("!mary:matrix.org", "Renko", "Lorem ipsum sit dolor amet"), - ]) - - self.messages["!test:matrix.org"].extend([ - Message("@renko:matrix.org", dt("10:20:13"), "Lorem"), - Message("@renko:matrix.org", dt("10:22:01"), "Ipsum"), - Message("@renko:matrix.org", dt("10:22:50"), "Combine"), - Message("@renko:matrix.org", dt("10:30:41"), - "Time passed, don't combine"), - Message("@mary:matrix.org", dt("10:31:12"), - "Different person, don't combine"), - Message("@mary:matrix.org", dt("10:32:04"), - "But combine me"), - Message("@mary:matrix.org", dt("13:10:20"), - "Long time passed, conv break"), - - Message("@renko:matrix.org", db("10:22:01"), "Daybreak"), - Message("@mary:matrix.org", db("10:22:03"), - "A longer message to test text wrapping. " - "Lorem ipsum dolor sit amet, consectetuer adipiscing " - "elit. Aenean commodo ligula " - "eget dolor. Aenean massa. Cem sociis natoque penaibs " - "et magnis dis parturient montes, nascetur ridiculus " - "mus. Donec quam. "), - ]) - - self.messages["!mary:matrix.org"].extend([ - Message("@mary:matrix.org", dt("10:22:23"), "First"), - Message("@mary:matrix.org", dt("12:24:10"), "Second"), - ]) - - self.messages["!foo:matrix.org"].extend([]) diff --git a/harmonyqml/backend/matrix_nio/__init__.py b/harmonyqml/backend/matrix_nio/__init__.py deleted file mode 100644 index 7fc6b031..00000000 --- a/harmonyqml/backend/matrix_nio/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2019 miruka -# This file is part of harmonyqml, licensed under GPLv3. - -from .backend import MatrixNioBackend diff --git a/harmonyqml/backend/matrix_nio/backend.py b/harmonyqml/backend/matrix_nio/backend.py deleted file mode 100644 index 5358badd..00000000 --- a/harmonyqml/backend/matrix_nio/backend.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2019 miruka -# This file is part of harmonyqml, licensed under GPLv3. - -from typing import Any, DefaultDict, Dict, NamedTuple, Optional - -from PyQt5.QtCore import QDateTime, QObject, pyqtProperty, pyqtSlot - -from matrix_client.user import User as MatrixUser - -from ..base import Backend, User -from .client_manager import ClientManager - - -class MatrixNioBackend(Backend): - def __init__(self) -> None: - super().__init__() - self._client_manager = ClientManager() - - # a = self._client_manager - # from PyQt5.QtCore import pyqtRemoveInputHook as PRI; import pdb; PRI(); pdb.set_trace() - - self._client_manager.configLoad() - - - @pyqtProperty("QVariant") - def clientManager(self): - return self._client_manager diff --git a/harmonyqml/backend/model/__init__.py b/harmonyqml/backend/model/__init__.py new file mode 100644 index 00000000..93ad0d1e --- /dev/null +++ b/harmonyqml/backend/model/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from .list_model import ListModel, _QtListModel +from .qml_models import QMLModels +from . import enums, items diff --git a/harmonyqml/backend/enums.py b/harmonyqml/backend/model/enums.py similarity index 100% rename from harmonyqml/backend/enums.py rename to harmonyqml/backend/model/enums.py diff --git a/harmonyqml/backend/model/items.py b/harmonyqml/backend/model/items.py new file mode 100644 index 00000000..cee270bf --- /dev/null +++ b/harmonyqml/backend/model/items.py @@ -0,0 +1,34 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from typing import NamedTuple, Optional + +from PyQt5.QtCore import QDateTime + +from .enums import Activity, MessageKind, Presence + + +class User(NamedTuple): + user_id: str + display_name: str + avatar_url: Optional[str] = None + status_message: Optional[str] = None + + +class Room(NamedTuple): + room_id: str + display_name: str + description: str = "" + unread_messages: int = 0 + presence: Presence = Presence.none + activity: Activity = Activity.none + last_activity_timestamp_ms: Optional[int] = None + avatar_url: Optional[str] = None + + +class Message(NamedTuple): + sender_id: str + date_time: QDateTime + content: str + kind: MessageKind = MessageKind.text + sender_avatar: Optional[str] = None diff --git a/harmonyqml/backend/list_model.py b/harmonyqml/backend/model/list_model.py similarity index 92% rename from harmonyqml/backend/list_model.py rename to harmonyqml/backend/model/list_model.py index a9417fa2..48f7d404 100644 --- a/harmonyqml/backend/list_model.py +++ b/harmonyqml/backend/model/list_model.py @@ -65,6 +65,16 @@ class _QtListModel(QAbstractListModel): return self._list[index]._asdict() + @pyqtSlot(str, "QVariant", result=int) + def indexWhere(self, prop: str, is_value: Any) -> int: + for i, item in enumerate(self._list): + if getattr(item, prop) == is_value: + return i + + raise ValueError(f"No {type(self._ref_namedlist)} in list with " + f"property {prop!r} set to {is_value!r}.") + + @pyqtSlot(int, list) def insert(self, index: int, value: NewValue) -> None: value = self._convert_new_value(value) @@ -176,6 +186,10 @@ class ListModel(MutableSequence): self.qt_model.insert(index, value) + def indexWhere(self, prop: str, is_value: Any) -> int: + return self.qt_model.indexWhere(prop, is_value) + + def setProperty(self, index: int, prop: str, value: Any) -> None: "Set role of the item at *index* to *value*." self.qt_model.setProperty(index, prop, value) diff --git a/harmonyqml/backend/model/qml_models.py b/harmonyqml/backend/model/qml_models.py new file mode 100644 index 00000000..26be7052 --- /dev/null +++ b/harmonyqml/backend/model/qml_models.py @@ -0,0 +1,31 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from typing import DefaultDict, Dict + +from PyQt5.QtCore import QObject, pyqtProperty + +from .list_model import ListModel, _QtListModel + + +class QMLModels(QObject): + def __init__(self) -> None: + super().__init__() + self._accounts: ListModel = ListModel() + self._rooms: DefaultDict[str, ListModel] = DefaultDict(ListModel) + self._messages: DefaultDict[str, ListModel] = DefaultDict(ListModel) + + + @pyqtProperty(_QtListModel, constant=True) + def accounts(self) -> _QtListModel: + return self._accounts.qt_model + + + @pyqtProperty("QVariantMap", constant=True) + def rooms(self) -> Dict[str, _QtListModel]: + return {user_id: l.qt_model for user_id, l in self._rooms.items()} + + + @pyqtProperty("QVariantMap", constant=True) + def messages(self) -> Dict[str, _QtListModel]: + return {room_id: l.qt_model for room_id, l in self._messages.items()} diff --git a/harmonyqml/backend/matrix_nio/net.py b/harmonyqml/backend/network_manager.py similarity index 100% rename from harmonyqml/backend/matrix_nio/net.py rename to harmonyqml/backend/network_manager.py diff --git a/harmonyqml/backend/signal_manager.py b/harmonyqml/backend/signal_manager.py new file mode 100644 index 00000000..03f833f9 --- /dev/null +++ b/harmonyqml/backend/signal_manager.py @@ -0,0 +1,33 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from PyQt5.QtCore import QObject + +from .backend import Backend +from .client import Client +from .model.items import User + + +class SignalManager(QObject): + def __init__(self, backend: Backend) -> None: + super().__init__() + self.backend = backend + self.connectAll() + + + def connectAll(self) -> None: + be = self.backend + be.clientManager.clientAdded.connect(self.onClientAdded) + be.clientManager.clientDeleted.connect(self.onClientDeleted) + + + def onClientAdded(self, client: Client) -> None: + self.backend.models.accounts.append(User( + user_id = client.nio.user_id, + display_name = client.nio.user_id.lstrip("@").split(":")[0], + )) + + + def onClientDeleted(self, user_id: str) -> None: + accs = self.backend.models.accounts + del accs[accs.indexWhere("user_id", user_id)] diff --git a/harmonyqml/components/chat/MessageDelegate.qml b/harmonyqml/components/chat/MessageDelegate.qml index 9ba57ad5..20e56bcd 100644 --- a/harmonyqml/components/chat/MessageDelegate.qml +++ b/harmonyqml/components/chat/MessageDelegate.qml @@ -11,7 +11,7 @@ Column { } readonly property string displayName: - Backend.getUser(sender_id).display_name + Backend.getUser(chatPage.room.room_id, sender_id).display_name readonly property bool isOwn: chatPage.user.user_id === sender_id diff --git a/harmonyqml/components/chat/MessageList.qml b/harmonyqml/components/chat/MessageList.qml index 34431c1a..402ed40f 100644 --- a/harmonyqml/components/chat/MessageList.qml +++ b/harmonyqml/components/chat/MessageList.qml @@ -13,7 +13,7 @@ Rectangle { ListView { id: messageListView anchors.fill: parent - model: Backend.messagesModel[chatPage.room.room_id] + model: Backend.models.messages[chatPage.room.room_id] delegate: MessageDelegate {} //highlight: Rectangle {color: "lightsteelblue"; radius: 5} diff --git a/harmonyqml/components/side_pane/AccountDelegate.qml b/harmonyqml/components/side_pane/AccountDelegate.qml index 6eb5a876..00d142a2 100644 --- a/harmonyqml/components/side_pane/AccountDelegate.qml +++ b/harmonyqml/components/side_pane/AccountDelegate.qml @@ -67,7 +67,7 @@ ColumnLayout { id: "roomList" visible: true interactive: false // no scrolling - user: Backend.getUser(user_id) + for_user_id: user_id Layout.minimumHeight: roomList.visible ? diff --git a/harmonyqml/components/side_pane/AccountList.qml b/harmonyqml/components/side_pane/AccountList.qml index 1b29903a..e9f455c5 100644 --- a/harmonyqml/components/side_pane/AccountList.qml +++ b/harmonyqml/components/side_pane/AccountList.qml @@ -5,7 +5,7 @@ import QtQuick.Layouts 1.4 ListView { id: "accountList" spacing: 8 - model: Backend.accountsModel + model: Backend.models.accounts delegate: AccountDelegate {} clip: true } diff --git a/harmonyqml/components/side_pane/RoomDelegate.qml b/harmonyqml/components/side_pane/RoomDelegate.qml index e86b0ec7..5cd072da 100644 --- a/harmonyqml/components/side_pane/RoomDelegate.qml +++ b/harmonyqml/components/side_pane/RoomDelegate.qml @@ -9,7 +9,7 @@ MouseArea { height: Math.max(roomLabel.height + subtitleLabel.height, avatar.height) onClicked: pageStack.show_room( - roomList.user, + roomList.user_id, roomList.model.get(index) ) @@ -38,22 +38,23 @@ MouseArea { } Base.HLabel { function get_text() { - var msgs = Backend.messagesModel[room_id] + var msgs = Backend.models.messages[room_id] if (msgs.count < 1) { return "" } var msg = msgs.get(-1) - var color_ = (msg.sender_id === roomList.user.user_id ? + var color_ = (msg.sender_id === roomList.user_id ? "darkblue" : "purple") + var client = Backend.clientManager.clients[RoomList.for_user_id] return "" + - Backend.getUser(msg.sender_id).display_name + + client.getUser(room_id, msg.sender_id).display_name + ": " + msg.content } id: subtitleLabel visible: text !== "" - text: Backend.messagesModel[room_id].reloadThis, get_text() + text: Backend.models.messages[room_id].reloadThis, get_text() textFormat: Text.StyledText font.pixelSize: smallSize diff --git a/harmonyqml/components/side_pane/RoomList.qml b/harmonyqml/components/side_pane/RoomList.qml index 67d70d45..e42842b9 100644 --- a/harmonyqml/components/side_pane/RoomList.qml +++ b/harmonyqml/components/side_pane/RoomList.qml @@ -4,7 +4,7 @@ import QtQuick.Layouts 1.4 import "../base" as Base ListView { - property var user: null + property var for_user_id: null property int contentHeight: 0 @@ -21,6 +21,6 @@ ListView { id: "roomList" spacing: 8 - model: Backend.roomsModel[user.user_id] + model: Backend.models.rooms[for_user_id] delegate: RoomDelegate {} } diff --git a/harmonyqml/engine.py b/harmonyqml/engine.py index 51785835..2f87bfc7 100644 --- a/harmonyqml/engine.py +++ b/harmonyqml/engine.py @@ -11,7 +11,7 @@ from PyQt5.QtQml import QQmlApplicationEngine from .__about__ import __doc__ from .app import Application -from .backend.matrix_nio.backend import MatrixNioBackend as CurrentBackend +from .backend.backend import Backend # logging.basicConfig(level=logging.INFO) @@ -23,7 +23,7 @@ class Engine(QQmlApplicationEngine): parent: Optional[QObject] = None) -> None: super().__init__(parent) self.app = app - self.backend = CurrentBackend() + self.backend = Backend() self.app_dir = Path(sys.argv[0]).resolve().parent # Set QML properties