Display room messages and other events

This commit is contained in:
miruka 2019-04-14 12:56:30 -04:00
parent 5c8fd4500d
commit 9c66166c4f
16 changed files with 340 additions and 110 deletions

21
TODO.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "<font color=gray>" +
displayName + " " + contentText +
"&nbsp;&nbsp;<font size=" + smallSize + "px>" +
Qt.formatDateTime(date_time, "hh:mm:ss") +
"</font></font>"
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
)
}
}

View File

@ -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 + "&nbsp;&nbsp;") +
//"<font size=" + smallSize + "px color=gray>" +
//Qt.formatDateTime(date_time, "hh:mm:ss") +
//"</font>" +
// (isOwn ? "&nbsp;&nbsp;" + content : "")
text: (isUndecryptableEvent ?
"<font color=darkred>Missing decryption keys for this message.</font>" :
dict.formatted_body || dict.body) +
"&nbsp;&nbsp;<font size=" + smallSize + "px color=gray>" +
Qt.formatDateTime(date_time, "hh:mm:ss") +
"</font>"
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
)
}
}
}

View File

@ -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 + "&nbsp;&nbsp;") +
//"<font size=" + smallSize + "px color=gray>" +
//Qt.formatDateTime(date_time, "hh:mm:ss") +
//"</font>" +
// (isOwn ? "&nbsp;&nbsp;" + content : "")
text: content +
"&nbsp;&nbsp;<font size=" + smallSize + "px color=gray>" +
Qt.formatDateTime(date_time, "hh:mm:ss") +
"</font>"
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) }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "<font color=\"" + color_ + "\">" +
client.getUser(room_id, msg.sender_id).display_name +
Backend.getUser(msg.sender_id).display_name +
":</font> " +
msg.content
}
id: subtitleLabel
visible: text !== ""
text: msgModel.reloadThis, get_text()
//text: msgModel.reloadThis, get_text()
textFormat: Text.StyledText
font.pixelSize: smallSize