Add support for non-message room events

This commit is contained in:
miruka 2019-07-02 22:22:29 -04:00
parent a6653179e5
commit 9d5701da19
11 changed files with 299 additions and 247 deletions

View File

@ -576,7 +576,7 @@ distdir: FORCE
clean: compiler_clean clean: compiler_clean
-$(DEL_FILE) $(OBJECTS) -$(DEL_FILE) $(OBJECTS)
-$(DEL_FILE) build/moc build/obj build/rcc src/python/__pycache__ src/python/events/__pycache__ build/resources.qrc build Makefile .qmake.stash src/python/__pycache__/__about__.cpython-36.pyc src/python/__pycache__/__init__.cpython-36.pyc src/python/__pycache__/app.cpython-36.pyc src/python/__pycache__/backend.cpython-36.pyc src/python/__pycache__/html_filter.cpython-36.pyc src/python/__pycache__/matrix_client.cpython-36.pyc src/python/events/__pycache__/__init__.cpython-36.pyc src/python/events/__pycache__/app.cpython-36.pyc src/python/events/__pycache__/event.cpython-36.pyc src/python/events/__pycache__/rooms.cpython-36.pyc src/python/events/__pycache__/rooms_timeline.cpython-36.pyc src/python/events/__pycache__/users.cpython-36.pyc -$(DEL_FILE) build/moc build/obj build/rcc src/python/__pycache__ src/python/events/__pycache__ build/resources.qrc build Makefile .qmake.stash src/python/__pycache__/__about__.cpython-36.pyc src/python/__pycache__/__init__.cpython-36.pyc src/python/__pycache__/app.cpython-36.pyc src/python/__pycache__/backend.cpython-36.pyc src/python/__pycache__/html_filter.cpython-36.pyc src/python/__pycache__/matrix_client.cpython-36.pyc src/python/events/__pycache__/__init__.cpython-36.pyc src/python/events/__pycache__/app.cpython-36.pyc src/python/events/__pycache__/event.cpython-36.pyc src/python/events/__pycache__/rooms.cpython-36.pyc src/python/events/__pycache__/rooms_timeline.cpython-36.pyc src/python/events/__pycache__/timeline.cpython-36.pyc src/python/events/__pycache__/users.cpython-36.pyc
-$(DEL_FILE) *~ core *.core -$(DEL_FILE) *~ core *.core

View File

@ -1,9 +1,12 @@
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from enum import auto
from typing import Dict, Optional, Sequence, Type, Union
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .event import Event import nio
from .event import AutoStrEnum, Event
@dataclass @dataclass
@ -38,3 +41,52 @@ class RoomMemberUpdated(Event):
class RoomMemberDeleted(Event): class RoomMemberDeleted(Event):
room_id: str = field() room_id: str = field()
user_id: str = field() user_id: str = field()
# Timeline
class ContentType(AutoStrEnum):
html = auto()
image = auto()
audio = auto()
video = auto()
file = auto()
location = auto()
@dataclass
class TimelineEventReceived(Event):
event_type: Type[nio.Event] = field()
room_id: str = field()
event_id: str = field()
sender_id: str = field()
date: datetime = field()
content: str = field()
content_type: ContentType = ContentType.html
is_local_echo: bool = False
show_name_line: bool = False
translatable: Union[bool, Sequence[str]] = True
target_user_id: Optional[str] = None
@classmethod
def from_nio(cls, room: nio.rooms.MatrixRoom, ev: nio.Event, **fields
) -> "TimelineEventReceived":
return cls(
event_type = type(ev),
room_id = room.room_id,
event_id = ev.event_id,
sender_id = ev.sender,
date = datetime.fromtimestamp(ev.server_timestamp / 1000),
target_user_id = getattr(ev, "state_key", None),
**fields
)
@dataclass
class TimelineMessageReceived(TimelineEventReceived):
show_name_line: bool = True
translatable: Union[bool, Sequence[str]] = False

View File

@ -1,32 +0,0 @@
from datetime import datetime
from enum import auto
from dataclasses import dataclass, field
from .event import AutoStrEnum, Event
class EventType(AutoStrEnum):
text = auto()
html = auto()
file = auto()
image = auto()
audio = auto()
video = auto()
location = auto()
notice = auto()
@dataclass
class TimelineEvent(Event):
type: EventType = field()
room_id: str = field()
event_id: str = field()
sender_id: str = field()
date: datetime = field()
is_local_echo: bool = field()
@dataclass
class HtmlMessageReceived(TimelineEvent):
content: str = field()

View File

@ -1,9 +1,10 @@
import asyncio import asyncio
import html
import inspect import inspect
import json
import logging as log import logging as log
import platform import platform
from contextlib import suppress from contextlib import suppress
from datetime import datetime
from types import ModuleType from types import ModuleType
from typing import Dict, Optional, Type from typing import Dict, Optional, Type
@ -12,7 +13,7 @@ from nio.rooms import MatrixRoom
from . import __about__ from . import __about__
from .events import rooms, users from .events import rooms, users
from .events.rooms_timeline import EventType, HtmlMessageReceived from .events.rooms import TimelineEventReceived, TimelineMessageReceived
from .html_filter import HTML_FILTER from .html_filter import HTML_FILTER
@ -162,17 +163,157 @@ class MatrixClient(nio.AsyncClient):
# Callbacks for nio events # Callbacks for nio events
async def onRoomMessageText(self, room: MatrixRoom, ev: nio.RoomMessageText # Special %tokens for event contents:
) -> None: # %S = sender's displayname
is_html = ev.format == "org.matrix.custom.html" # %T = target (ev.state_key)'s displayname
filter_ = HTML_FILTER.filter
HtmlMessageReceived( async def onRoomMessageText(self, room, ev) -> None:
type = EventType.html if is_html else EventType.text, co = HTML_FILTER.filter(
room_id = room.room_id, ev.formatted_body
event_id = ev.event_id, if ev.format == "org.matrix.custom.html" else html.escape(ev.body)
sender_id = ev.sender,
date = datetime.fromtimestamp(ev.server_timestamp / 1000),
is_local_echo = False,
content = filter_(ev.formatted_body) if is_html else ev.body,
) )
TimelineMessageReceived.from_nio(room, ev, content=co)
async def onRoomCreateEvent(self, room, ev) -> None:
co = "%S allowed users on other matrix servers to join this room." \
if ev.federate else \
"%S blocked users on other matrix servers from joining this room."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomGuestAccessEvent(self, room, ev) -> None:
allowed = "allowed" if ev.guest_access else "forbad"
co = f"%S {allowed} guests to join the room."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomJoinRulesEvent(self, room, ev) -> None:
access = "public" if ev.join_rule == "public" else "invite-only"
co = f"%S made the room {access}."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomHistoryVisibilityEvent(self, room, ev) -> None:
if ev.history_visibility == "shared":
to = "all room members"
elif ev.history_visibility == "world_readable":
to = "any member or outsider"
elif ev.history_visibility == "joined":
to = "all room members, since the time they joined"
elif ev.history_visibility == "invited":
to = "all room members, since the time they were invited"
else:
to = "???"
log.warning("Invalid visibility - %s",
json.dumps(ev.__dict__, indent=4))
co = f"%S made future room history visible to {to}."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onPowerLevelsEvent(self, room, ev) -> None:
co = "%S changed the room's permissions." # TODO: improve
TimelineEventReceived.from_nio(room, ev, content=co)
async def _get_room_member_event_content(self, ev) -> str:
prev = ev.prev_content
prev_membership = prev["membership"] if prev else None
now = ev.content
membership = now["membership"]
if not prev or membership != prev_membership:
reason = f" Reason: {now['reason']}" if now.get("reason") else ""
if membership == "join":
did = "accepted" if prev and prev_membership == "invite" else \
"declined"
return f"%S {did} their invitation."
if membership == "invite":
return f"%S invited %T to the room."
if membership == "leave":
if ev.state_key == ev.sender:
return (
f"%S declined their invitation.{reason}"
if prev and prev_membership == "invite" else
f"%S left the room.{reason}"
)
return (
f"%S withdrew %T's invitation.{reason}"
if prev and prev_membership == "invite" else
f"%S unbanned %T from the room.{reason}"
if prev and prev_membership == "ban" else
f"%S kicked out %T from the room.{reason}"
)
if membership == "ban":
return f"%S banned %T from the room.{reason}"
changed = []
if prev and now["avatar_url"] != prev["avatar_url"]:
changed.append("profile picture") # TODO: <img>s
if prev and now["displayname"] != prev["displayname"]:
changed.append('display name from "{}" to "{}"'.format(
prev["displayname"] or ev.state_key,
now["displayname"] or ev.state_key,
))
if changed:
return "%S changed their {}.".format(" and ".join(changed))
log.warning("Invalid member event - %s",
json.dumps(ev.__dict__, indent=4))
return "%S ???"
async def onRoomMemberEvent(self, room, ev) -> None:
co = await self._get_room_member_event_content(ev)
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomAliasEvent(self, room, ev) -> None:
co = f"%S set the room's main address to {ev.canonical_alias}."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomNameEvent(self, room, ev) -> None:
co = f"%S changed the room's name to \"{ev.name}\"."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomTopicEvent(self, room, ev) -> None:
co = f"%S changed the room's topic to \"{ev.topic}\"."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onRoomEncryptionEvent(self, room, ev) -> None:
co = f"%S turned on encryption for this room."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onOlmEvent(self, room, ev) -> None:
co = f"%S hasn't sent your device the keys to decrypt this message."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onMegolmEvent(self, room, ev) -> None:
await self.onOlmEvent(room, ev)
async def onBadEvent(self, room, ev) -> None:
co = f"%S sent a malformed event."
TimelineEventReceived.from_nio(room, ev, content=co)
async def onUnknownBadEvent(self, room, ev) -> None:
co = f"%S sent an event this client doesn't understand."
TimelineEventReceived.from_nio(room, ev, content=co)

View File

@ -55,27 +55,27 @@ HColumnLayout {
Layout.fillHeight: true Layout.fillHeight: true
} }
TypingMembersBar {} //TypingMembersBar {}
InviteBanner { // InviteBanner {
visible: category === "Invites" //visible: category === "Invites"
inviter: roomInfo.inviter //inviter: roomInfo.inviter
} //}
UnknownDevicesBanner { //UnknownDevicesBanner {
visible: category == "Rooms" && hasUnknownDevices //visible: category == "Rooms" && hasUnknownDevices
} //}
SendBox { SendBox {
id: sendBox id: sendBox
visible: category == "Rooms" && ! hasUnknownDevices visible: category == "Rooms" && ! hasUnknownDevices
} }
LeftBanner { //LeftBanner {
visible: category === "Left" //visible: category === "Left"
leftEvent: roomInfo.leftEvent //leftEvent: roomInfo.leftEvent
} //}
} //}
// RoomSidePane { // RoomSidePane {
//id: roomSidePane //id: roomSidePane
@ -144,5 +144,6 @@ HColumnLayout {
//Layout.minimumWidth: HStyle.avatar.size //Layout.minimumWidth: HStyle.avatar.size
//Layout.maximumWidth: parent.width //Layout.maximumWidth: parent.width
//} //}
}
} }
} }

View File

@ -7,11 +7,18 @@ Row {
spacing: standardSpacing / 2 spacing: standardSpacing / 2
layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight layoutDirection: isOwn ? Qt.RightToLeft : Qt.LeftToRight
function textHueForName(name) { // TODO: move
return Qt.hsla(avatar.hueFromName(name),
HStyle.displayName.saturation,
HStyle.displayName.lightness,
1)
}
HAvatar { HAvatar {
id: avatar id: avatar
hidden: combine hidden: combine
name: senderInfo.displayName || stripUserId(model.senderId) name: senderInfo.displayName || stripUserId(model.senderId)
dimension: 48 dimension: model.showNameLine ? 48 : 28
} }
Rectangle { Rectangle {
@ -33,16 +40,13 @@ Row {
anchors.fill: parent anchors.fill: parent
HLabel { HLabel {
height: combine ? 0 : implicitHeight
width: parent.width width: parent.width
height: model.showNameLine && ! combine ? implicitHeight : 0
visible: height > 0 visible: height > 0
id: nameLabel id: nameLabel
text: senderInfo.displayName || model.senderId text: senderInfo.displayName || model.senderId
color: Qt.hsla(avatar.hueFromName(avatar.name), color: textHueForName(avatar.name)
HStyle.displayName.saturation,
HStyle.displayName.lightness,
1)
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1
horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft
@ -53,10 +57,49 @@ Row {
} }
HRichLabel { HRichLabel {
function escapeHtml(text) { // TODO: move this
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#039;")
}
function translate(text) {
if (model.translatable == false) { return text }
text = text.replace(
"%S",
"<font color='" + nameLabel.color + "'>" +
escapeHtml(senderInfo.displayName || model.senderId) +
"</font>"
)
var name = models.users.getUser(
chatPage.userId, model.targetUserId
).displayName
var sid = avatar.stripUserId(model.targetUserId || "")
text = text.replace(
"%T",
"<font color='" + textHueForName(name || sid) + "'>" +
escapeHtml(name || model.targetUserId) +
"</font>"
)
text = qsTr(text)
if (model.translatable == true) { return text }
// Else, model.translatable should be an array of args
for (var i = 0; model.translatable.length; i++) {
text = text.arg(model.translatable[i])
}
}
width: parent.width width: parent.width
id: contentLabel id: contentLabel
text: model.content + text: translate(model.content) +
"&nbsp;&nbsp;<font size=" + HStyle.fontSize.small + "&nbsp;&nbsp;<font size=" + HStyle.fontSize.small +
"px color=" + HStyle.chat.message.date + ">" + "px color=" + HStyle.chat.message.date + ">" +
Qt.formatDateTime(model.date, "hh:mm:ss") + Qt.formatDateTime(model.date, "hh:mm:ss") +
@ -64,8 +107,6 @@ Row {
(model.isLocalEcho ? (model.isLocalEcho ?
"&nbsp;<font size=" + HStyle.fontSize.small + "&nbsp;<font size=" + HStyle.fontSize.small +
"px>⏳</font>" : "") "px>⏳</font>" : "")
textFormat: model.type == "text" ?
Text.PlainText : Text.RichText
color: HStyle.chat.message.body color: HStyle.chat.message.body
wrapMode: Text.Wrap wrapMode: Text.Wrap

View File

@ -14,10 +14,6 @@ Column {
roomEventListView.model.get(index + 1) : null roomEventListView.model.get(index + 1) : null
} }
function getIsMessage(type) {
return true
}
property var previousItem: getPreviousItem() property var previousItem: getPreviousItem()
signal reloadPreviousItem() signal reloadPreviousItem()
onReloadPreviousItem: previousItem = getPreviousItem() onReloadPreviousItem: previousItem = getPreviousItem()
@ -26,18 +22,14 @@ Column {
Component.onCompleted: Component.onCompleted:
senderInfo = models.users.getUser(chatPage.userId, senderId) senderInfo = models.users.getUser(chatPage.userId, senderId)
//readonly property bool isMessage: ! model.type.match(/^event.*/)
readonly property bool isMessage: getIsMessage(model.type)
readonly property bool isOwn: chatPage.userId === senderId readonly property bool isOwn: chatPage.userId === senderId
readonly property bool isFirstEvent: model.type == "eventCreate" readonly property bool isFirstEvent: model.event_type == "RoomCreateEvent"
readonly property bool combine: readonly property bool combine:
previousItem && previousItem &&
! talkBreak && ! talkBreak &&
! dayBreak && ! dayBreak &&
getIsMessage(previousItem.type) === isMessage &&
previousItem.senderId === senderId && previousItem.senderId === senderId &&
minsBetween(previousItem.date, model.date) <= 5 minsBetween(previousItem.date, model.date) <= 5
@ -75,14 +67,13 @@ Column {
width: roomEventDelegate.width width: roomEventDelegate.width
} }
Item { Item { // TODO: put this in Daybreak.qml?
visible: dayBreak visible: dayBreak
width: parent.width width: parent.width
height: topPadding height: topPadding
} }
Loader { MessageContent {
source: isMessage ? "MessageContent.qml" : "EventContent.qml"
anchors.right: isOwn ? parent.right : undefined anchors.right: isOwn ? parent.right : undefined
} }
} }

View File

@ -1,154 +1,3 @@
function getEventText(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 getHistoryVisibilityEventText(dict)
break
case "PowerLevelsEvent":
return "changed the room's permissions."
case "RoomMemberEvent":
return getMemberEventText(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
case "OlmEvent":
case "MegolmEvent":
return "hasn't sent your device the keys to decrypt this message."
default:
console.log(type + "\n" + JSON.stringify(dict, null, 4) + "\n")
return "did something this client does not understand."
//case "CallEvent": TODO
}
}
function getHistoryVisibilityEventText(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 the point they joined."
break
case "invited":
var end = "all room members, since the point they were invited."
break
}
return "made future history visible to " + end
}
function getStateDisplayName(dict) {
// The dict.content.displayname may be outdated, prefer
// retrieving it fresh
return Backend.users.get(dict.state_key).displayName.value
}
function getMemberEventText(dict) {
var info = dict.content, prev = dict.prev_content
if (! prev || (info.membership != prev.membership)) {
var reason = info.reason ? (" Reason: " + info.reason) : ""
switch (info.membership) {
case "join":
return prev && prev.membership === "invite" ?
"accepted the invitation." : "joined the room."
break
case "invite":
return "invited " + getStateDisplayName(dict) + " to the room."
break
case "leave":
if (dict.state_key === dict.sender) {
return (prev && prev.membership === "invite" ?
"declined the invitation." : "left the room.") +
reason
}
var name = getStateDisplayName(dict)
return (prev && prev.membership === "invite" ?
"withdrew " + name + "'s invitation." :
prev && prev.membership == "ban" ?
"unbanned " + name + " from the room." :
"kicked out " + name + " from the room.") +
reason
break
case "ban":
var name = getStateDisplayName(dict)
return "banned " + name + " from the room." + reason
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 ""
}
function getLeftBannerText(leftEvent) { function getLeftBannerText(leftEvent) {
if (! leftEvent) { if (! leftEvent) {
return "You are not member of this room." return "You are not member of this room."

View File

@ -2,4 +2,3 @@
Qt.include("app.js") Qt.include("app.js")
Qt.include("users.js") Qt.include("users.js")
Qt.include("rooms.js") Qt.include("rooms.js")
Qt.include("rooms_timeline.js")

View File

@ -59,3 +59,25 @@ function onRoomMemberUpdated(room_id, user_id, typing) {
function onRoomMemberDeleted(room_id, user_id) { function onRoomMemberDeleted(room_id, user_id) {
} }
function onTimelineEventReceived(
event_type, room_id, event_id, sender_id, date, content,
content_type, is_local_echo, show_name_line, translatable, target_user_id
) {
models.timelines.upsert({"eventId": event_id}, {
"eventType": py.getattr(event_type, "__name__"),
"roomId": room_id,
"eventId": event_id,
"senderId": sender_id,
"date": date,
"content": content,
"contentType": content,
"isLocalEcho": is_local_echo,
"showNameLine": show_name_line,
"translatable": translatable,
"targetUserId": target_user_id
}, true, 1000)
}
var onTimelineMessageReceived = onTimelineEventReceived

View File

@ -1,12 +0,0 @@
function onHtmlMessageReceived(type, room_id, event_id, sender_id, date,
is_local_echo, content) {
models.timelines.upsert({"eventId": event_id}, {
"type": type,
"roomId": room_id,
"eventId": event_id,
"senderId": sender_id,
"date": date,
"isLocalEcho": is_local_echo,
"content": content,
}, true, 1000)
}