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