diff --git a/TODO.md b/TODO.md index 277cc02f..5026c86a 100644 --- a/TODO.md +++ b/TODO.md @@ -1 +1,20 @@ -- +- Separate categories for invited, group and direct rooms +- Invited → Accept/Deny dialog +- Keep the room header name and topic updated +- Merge login page +- Show actual display name for AccountDelegate + +- When inviting someone to direct chat, room is "Empty room" until accepted, + it should be the peer's display name instead. +- Support "Empty room (was ...)" after peer left + +- Catch network errors in socket operations + +- Proper logoff when closing client + +- Handle cases where an avatar char is # or @ (#alias room, @user\_id) + +- Use Loader? for MessageDelegate to show sub-components based on condition +- Better names and organization for the Message components + +- Load previous events on scroll up diff --git a/harmonyqml/backend/backend.py b/harmonyqml/backend/backend.py index d5618365..407bdf94 100644 --- a/harmonyqml/backend/backend.py +++ b/harmonyqml/backend/backend.py @@ -2,10 +2,12 @@ # This file is part of harmonyqml, licensed under GPLv3. import hashlib +from typing import Dict from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot from .client_manager import ClientManager +from .model.items import User from .model.qml_models import QMLModels @@ -35,6 +37,18 @@ class Backend(QObject): return self._models + @pyqtSlot(str, result="QVariantMap") + def getUser(self, user_id: str) -> Dict[str, str]: + for client in self.clientManager.clients.values(): + for room in client.nio.rooms.values(): + + name = room.user_name(user_id) + if name: + return User(user_id=user_id, display_name=name)._asdict() + + return User(user_id=user_id, display_name=user_id)._asdict() + + @pyqtSlot(str, result=float) def hueFromString(self, string: str) -> float: # pylint:disable=no-self-use diff --git a/harmonyqml/backend/client.py b/harmonyqml/backend/client.py index d72492cb..18ae3528 100644 --- a/harmonyqml/backend/client.py +++ b/harmonyqml/backend/client.py @@ -7,15 +7,13 @@ import sys import traceback from concurrent.futures import Future, ThreadPoolExecutor from threading import Event, currentThread -from typing import Callable, DefaultDict, Dict +from typing import Callable, DefaultDict from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, 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] = \ @@ -39,9 +37,10 @@ def futurize(func: Callable) -> Callable: class Client(QObject): - roomInvited = pyqtSignal(str) - roomJoined = pyqtSignal(str) - roomLeft = pyqtSignal(str) + roomInvited = pyqtSignal(str) + roomJoined = pyqtSignal(str) + roomLeft = pyqtSignal(str) + roomEventReceived = pyqtSignal(str, str, dict) def __init__(self, hostname: str, username: str, device_id: str = "" @@ -114,21 +113,13 @@ class Client(QObject): for room_id in response.rooms.invite: self.roomInvited.emit(room_id) - for room_id in response.rooms.join: + for room_id, room_info in response.rooms.join.items(): self.roomJoined.emit(room_id) + for ev in room_info.timeline.events: + self.roomEventReceived.emit( + room_id, type(ev).__name__, ev.__dict__ + ) + for room_id in response.rooms.leave: self.roomLeft.emit(room_id) - - - @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/model/items.py b/harmonyqml/backend/model/items.py index 6980a5c0..d7a2268b 100644 --- a/harmonyqml/backend/model/items.py +++ b/harmonyqml/backend/model/items.py @@ -1,11 +1,11 @@ # Copyright 2019 miruka # This file is part of harmonyqml, licensed under GPLv3. -from typing import NamedTuple, Optional +from typing import Dict, NamedTuple, Optional from PyQt5.QtCore import QDateTime -from .enums import Activity, MessageKind, Presence +from .enums import Activity, Presence class User(NamedTuple): @@ -26,9 +26,7 @@ class Room(NamedTuple): 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 RoomEvent(NamedTuple): + type: str + date_time: QDateTime + dict: Dict[str, str] diff --git a/harmonyqml/backend/model/qml_models.py b/harmonyqml/backend/model/qml_models.py index 7e4f927b..d3fd4585 100644 --- a/harmonyqml/backend/model/qml_models.py +++ b/harmonyqml/backend/model/qml_models.py @@ -13,9 +13,9 @@ class QMLModels(QObject): def __init__(self) -> None: super().__init__() - self._accounts: ListModel = ListModel() - self._rooms: ListModelMap = ListModelMap() - self._messages: ListModelMap = ListModelMap() + self._accounts: ListModel = ListModel() + self._rooms: ListModelMap = ListModelMap() + self._room_events: ListModelMap = ListModelMap() @pyqtProperty(ListModel, constant=True) @@ -29,5 +29,5 @@ class QMLModels(QObject): @pyqtProperty("QVariant", constant=True) - def messages(self): - return self._messages + def roomEvents(self): + return self._room_events diff --git a/harmonyqml/backend/signal_manager.py b/harmonyqml/backend/signal_manager.py index 0a95fb48..1f3e846d 100644 --- a/harmonyqml/backend/signal_manager.py +++ b/harmonyqml/backend/signal_manager.py @@ -1,18 +1,18 @@ # Copyright 2019 miruka # This file is part of harmonyqml, licensed under GPLv3. -from typing import Optional +from typing import Any, Dict, Optional -from PyQt5.QtCore import QObject +from PyQt5.QtCore import QDateTime, QObject, pyqtBoundSignal from .backend import Backend from .client import Client -from .model.items import Room, User +from .model.items import Room, RoomEvent, User class SignalManager(QObject): def __init__(self, backend: Backend) -> None: - super().__init__() + super().__init__(parent=backend) self.backend = backend cm = self.backend.clientManager @@ -34,10 +34,15 @@ class SignalManager(QObject): def connectClient(self, client: Client) -> None: - for sig_name in ("roomInvited", "roomJoined", "roomLeft"): - sig = getattr(client, sig_name) - on_sig = getattr(self, f"on{sig_name[0].upper()}{sig_name[1:]}") - sig.connect(lambda room_id, o=on_sig, c=client: o(c, room_id)) + for name in dir(client): + attr = getattr(client, name) + + if isinstance(attr, pyqtBoundSignal): + def onSignal(*args, name=name) -> None: + func = getattr(self, f"on{name[0].upper()}{name[1:]}") + func(client, *args) + + attr.connect(onSignal) def onRoomInvited(self, client: Client, room_id: str) -> None: @@ -69,3 +74,25 @@ class SignalManager(QObject): def onRoomLeft(self, client: Client, room_id: str) -> None: rooms = self.backend.models.rooms[client.userID] del rooms[rooms.indexWhere("room_id", room_id)] + + + def onRoomEventReceived( + self, _: Client, room_id: str, etype: str, edict: Dict[str, Any] + ) -> None: + model = self.backend.models.roomEvents[room_id] + date_time = QDateTime.fromMSecsSinceEpoch(edict["server_timestamp"]) + new_event = RoomEvent(type=etype, date_time=date_time, dict=edict) + + # Insert event in model at the right position, based on timestamps + # to keep them sorted by date of arrival. + # Iterate in reverse, since a new event is more likely to be appended, + # but events can arrive out of order. + if not model or model[-1].date_time < new_event.date_time: + model.append(new_event) + else: + for i, event in enumerate(reversed(model)): + if event.date_time < new_event.date_time: + model.insert(-i, new_event) + break + else: + model.insert(0, new_event) diff --git a/harmonyqml/components/UI.qml b/harmonyqml/components/UI.qml index a39a5b47..ac43eb5d 100644 --- a/harmonyqml/components/UI.qml +++ b/harmonyqml/components/UI.qml @@ -17,9 +17,9 @@ Controls1.SplitView { function show_page(componentName) { pageStack.replace(componentName + ".qml") } - function show_room(user_obj, room_obj) { + function show_room(user_id, room_obj) { pageStack.replace( - "chat/Root.qml", { user: user_obj, room: room_obj } + "chat/Root.qml", { user_id: user_id, room: room_obj } ) } diff --git a/harmonyqml/components/chat/EventContent.qml b/harmonyqml/components/chat/EventContent.qml new file mode 100644 index 00000000..1c98cee1 --- /dev/null +++ b/harmonyqml/components/chat/EventContent.qml @@ -0,0 +1,45 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.4 +import "../base" as Base +import "get_event_text.js" as GetEventTextJS + +Row { + id: row + spacing: standardSpacing + layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight + anchors.right: isOwn ? parent.right : undefined + + readonly property string contentText: + (isMessage || isUndecryptableEvent) ? + "" : + GetEventTextJS.get_event_text(type, dict) + + Base.Avatar { + id: avatar + name: displayName + invisible: combine + dimmension: 28 + } + + Base.HLabel { + id: contentLabel + text: "" + + displayName + " " + contentText + + "  " + + Qt.formatDateTime(date_time, "hh:mm:ss") + + "" + textFormat: Text.RichText + background: Rectangle {color: "#DDD"} + wrapMode: Text.Wrap + + leftPadding: horizontalPadding + rightPadding: horizontalPadding + topPadding: verticalPadding + bottomPadding: verticalPadding + + Layout.maximumWidth: Math.min( + 600, messageListView.width - avatar.width - row.spacing + ) + } +} diff --git a/harmonyqml/components/chat/MessageContent.qml b/harmonyqml/components/chat/MessageContent.qml new file mode 100644 index 00000000..10801ab8 --- /dev/null +++ b/harmonyqml/components/chat/MessageContent.qml @@ -0,0 +1,61 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.4 +import "../base" as Base + +Row { + id: row + spacing: standardSpacing + layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight + anchors.right: isOwn ? parent.right : undefined + + Base.Avatar { id: avatar; invisible: combine; name: displayName } + + ColumnLayout { + spacing: 0 + + Base.HLabel { + visible: ! combine + id: nameLabel + text: displayName + background: Rectangle {color: "#DDD"} + color: isOwn ? "teal" : "purple" + elide: Text.ElideRight + maximumLineCount: 1 + Layout.preferredWidth: contentLabel.width + horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft + + leftPadding: horizontalPadding + rightPadding: horizontalPadding + topPadding: verticalPadding + } + + Base.HLabel { + id: contentLabel + //text: (isOwn ? "" : content + "  ") + + //"" + + //Qt.formatDateTime(date_time, "hh:mm:ss") + + //"" + + // (isOwn ? "  " + content : "") + + text: (isUndecryptableEvent ? + "Missing decryption keys for this message." : + dict.formatted_body || dict.body) + + "  " + + Qt.formatDateTime(date_time, "hh:mm:ss") + + "" + textFormat: Text.RichText + background: Rectangle {color: "#DDD"} + wrapMode: Text.Wrap + + leftPadding: horizontalPadding + rightPadding: horizontalPadding + bottomPadding: verticalPadding + + Layout.minimumWidth: nameLabel.implicitWidth + Layout.maximumWidth: Math.min( + 600, messageListView.width - avatar.width - row.spacing + ) + } + } +} diff --git a/harmonyqml/components/chat/MessageDelegate.qml b/harmonyqml/components/chat/MessageDelegate.qml index 20e56bcd..91caece4 100644 --- a/harmonyqml/components/chat/MessageDelegate.qml +++ b/harmonyqml/components/chat/MessageDelegate.qml @@ -10,11 +10,16 @@ Column { return Math.round((((date2 - date1) % 86400000) % 3600000) / 60000) } + readonly property bool isMessage: type.startsWith("RoomMessage") + + readonly property bool isUndecryptableEvent: + type === "OlmEvent" || type === "MegolmEvent" + readonly property string displayName: - Backend.getUser(chatPage.room.room_id, sender_id).display_name + Backend.getUser(dict.sender).display_name readonly property bool isOwn: - chatPage.user.user_id === sender_id + chatPage.user_id === dict.sender readonly property var previousData: index > 0 ? messageListView.model.get(index - 1) : null @@ -23,7 +28,8 @@ Column { readonly property bool combine: ! isFirstMessage && - previousData.sender_id == sender_id && + previousData.isMessage === isMessage && + previousData.dict.sender === dict.sender && mins_between(previousData.date_time, date_time) <= 5 readonly property bool dayBreak: @@ -49,59 +55,7 @@ Column { Daybreak { visible: dayBreak } + MessageContent { visible: isMessage || isUndecryptableEvent } - Row { - id: row - spacing: standardSpacing - layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight - anchors.right: isOwn ? parent.right : undefined - - Base.Avatar { id: avatar; invisible: combine; name: displayName } - - ColumnLayout { - spacing: 0 - - Base.HLabel { - visible: ! combine - id: nameLabel - text: displayName - background: Rectangle {color: "#DDD"} - color: isOwn ? "teal" : "purple" - elide: Text.ElideRight - maximumLineCount: 1 - Layout.preferredWidth: contentLabel.width - horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft - - leftPadding: horizontalPadding - rightPadding: horizontalPadding - topPadding: verticalPadding - } - - Base.HLabel { - id: contentLabel - //text: (isOwn ? "" : content + "  ") + - //"" + - //Qt.formatDateTime(date_time, "hh:mm:ss") + - //"" + - // (isOwn ? "  " + content : "") - - text: content + - "  " + - Qt.formatDateTime(date_time, "hh:mm:ss") + - "" - textFormat: Text.RichText - background: Rectangle {color: "#DDD"} - wrapMode: Text.Wrap - - leftPadding: horizontalPadding - rightPadding: horizontalPadding - bottomPadding: verticalPadding - - Layout.minimumWidth: nameLabel.implicitWidth - Layout.maximumWidth: Math.min( - 600, messageListView.width - avatar.width - row.spacing - ) - } - } - } + EventContent { visible: ! (isMessage || isUndecryptableEvent) } } diff --git a/harmonyqml/components/chat/MessageList.qml b/harmonyqml/components/chat/MessageList.qml index 3c33812c..065638cf 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.models.messages.get(chatPage.room.room_id) + model: Backend.models.roomEvents.get(chatPage.room.room_id) delegate: MessageDelegate {} //highlight: Rectangle {color: "lightsteelblue"; radius: 5} diff --git a/harmonyqml/components/chat/RoomHeader.qml b/harmonyqml/components/chat/RoomHeader.qml index 6a1784f7..b0f78789 100644 --- a/harmonyqml/components/chat/RoomHeader.qml +++ b/harmonyqml/components/chat/RoomHeader.qml @@ -33,7 +33,7 @@ Rectangle { Base.HLabel { id: "roomDescription" - text: chatPage.room.description + text: chatPage.room.description || "" font.pixelSize: smallSize elide: Text.ElideRight maximumLineCount: 1 diff --git a/harmonyqml/components/chat/Root.qml b/harmonyqml/components/chat/Root.qml index e7f01209..5c775538 100644 --- a/harmonyqml/components/chat/Root.qml +++ b/harmonyqml/components/chat/Root.qml @@ -3,7 +3,7 @@ import QtQuick.Controls 2.2 import QtQuick.Layouts 1.4 ColumnLayout { - property var user: null + property var user_id: null property var room: null id: chatPage diff --git a/harmonyqml/components/chat/SendBox.qml b/harmonyqml/components/chat/SendBox.qml index 3919561e..c789b39a 100644 --- a/harmonyqml/components/chat/SendBox.qml +++ b/harmonyqml/components/chat/SendBox.qml @@ -20,7 +20,7 @@ Rectangle { Base.Avatar { id: "avatar" - name: chatPage.user.display_name + name: Backend.getUser(chatPage.user_id).display_name dimmension: root.Layout.minimumHeight //visible: textArea.text === "" visible: textArea.height <= root.Layout.minimumHeight diff --git a/harmonyqml/components/chat/get_event_text.js b/harmonyqml/components/chat/get_event_text.js new file mode 100644 index 00000000..8ca0abf0 --- /dev/null +++ b/harmonyqml/components/chat/get_event_text.js @@ -0,0 +1,122 @@ +function get_event_text(type, dict) { + switch (type) { + case "RoomCreateEvent": + return (dict.federate ? "allowed" : "blocked") + + " users on other matrix servers " + + (dict.federate ? "to join" : "from joining") + + " this room." + break + + case "RoomGuestAccessEvent": + return (dict.guest_access === "can_join" ? "allowed " : "forbad") + + "guests to join the room." + break + + case "RoomJoinRulesEvent": + return "made the room " + + (dict.join_rule === "public." ? "public" : "invite only.") + break + + case "RoomHistoryVisibilityEvent": + return get_history_visibility_event_text(dict) + break + + case "PowerLevelsEvent": + return "changed the room's permissions." + + case "RoomMemberEvent": + return get_member_event_text(dict) + break + + case "RoomAliasEvent": + return "set the room's main address to " + + dict.canonical_alias + "." + break + + case "RoomNameEvent": + return "changed the room's name to \"" + dict.name + "\"." + break + + case "RoomTopicEvent": + return "changed the room's topic to \"" + dict.topic + "\"." + break + + case "RoomEncryptionEvent": + return "turned on encryption for this room." + break + + default: + console.log(type + "\n" + JSON.stringify(dict, null, 4) + "\n") + return "did something this client does not understand." + + //case "CallEvent": TODO + } +} + + +function get_history_visibility_event_text(dict) { + switch (dict.history_visibility) { + case "shared": + var end = "all room members." + break + + case "world_readable": + var end = "any member or outsider." + break + + case "joined": + var end = "all room members since they joined." + break + + case "invited": + var end = "all room members since they were invited." + break + } + + return "made future history visible to " + end +} + + +function get_member_event_text(dict) { + var info = dict.content, prev = dict.prev_content + + if (! prev || (info.membership != prev.membership)) { + switch (info.membership) { + case "join": + return "joined the room." + break + + case "invite": + var name = Backend.getUser(dict.state_key).display_name + var name = name === dict.state_key ? info.displayname : name + return "invited " + name + " to the room." + break + + case "leave": + return "left the room." + break + + case "ban": + return "was banned from the room." + break + } + } + + var changed = [] + + if (prev && (info.avatar_url != prev.avatar_url)) { + changed.push("profile picture") + } + + if (prev && (info.displayname != prev.displayname)) { + changed.push("display name from \"" + + (prev.displayname || dict.state_key) + '" to "' + + (info.displayname || dict.state_key) + '"') + } + + if (changed.length > 0) { + return "changed their " + changed.join(" and ") + "." + } + + return "" +} diff --git a/harmonyqml/components/side_pane/RoomDelegate.qml b/harmonyqml/components/side_pane/RoomDelegate.qml index 9e5d60d7..c0eec909 100644 --- a/harmonyqml/components/side_pane/RoomDelegate.qml +++ b/harmonyqml/components/side_pane/RoomDelegate.qml @@ -9,7 +9,7 @@ MouseArea { height: roomList.childrenHeight onClicked: pageStack.show_room( - roomList.user_id, + roomList.for_user_id, roomList.model.get(index) ) @@ -38,7 +38,7 @@ MouseArea { rightPadding: leftPadding } Base.HLabel { - property var msgModel: Backend.models.messages.get(room_id) + property var msgModel: Backend.models.roomEvents.get(room_id) function get_text() { if (msgModel.count < 1) { return "" } @@ -46,17 +46,16 @@ MouseArea { var msg = msgModel.get(-1) var color_ = (msg.sender_id === roomList.user_id ? "darkblue" : "purple") - var client = Backend.clientManager.clients[RoomList.for_user_id] return "" + - client.getUser(room_id, msg.sender_id).display_name + + Backend.getUser(msg.sender_id).display_name + ": " + msg.content } id: subtitleLabel visible: text !== "" - text: msgModel.reloadThis, get_text() + //text: msgModel.reloadThis, get_text() textFormat: Text.StyledText font.pixelSize: smallSize