diff --git a/Makefile b/Makefile index 8f137c2c..ed33854b 100644 --- a/Makefile +++ b/Makefile @@ -576,7 +576,7 @@ distdir: FORCE clean: compiler_clean -$(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 diff --git a/src/python/events/rooms.py b/src/python/events/rooms.py index d1f53413..d82a9c44 100644 --- a/src/python/events/rooms.py +++ b/src/python/events/rooms.py @@ -1,9 +1,12 @@ 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 .event import Event +import nio + +from .event import AutoStrEnum, Event @dataclass @@ -38,3 +41,52 @@ class RoomMemberUpdated(Event): class RoomMemberDeleted(Event): room_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 diff --git a/src/python/events/rooms_timeline.py b/src/python/events/rooms_timeline.py deleted file mode 100644 index 6679b647..00000000 --- a/src/python/events/rooms_timeline.py +++ /dev/null @@ -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() diff --git a/src/python/matrix_client.py b/src/python/matrix_client.py index d7034dbe..8ced55b3 100644 --- a/src/python/matrix_client.py +++ b/src/python/matrix_client.py @@ -1,9 +1,10 @@ import asyncio +import html import inspect +import json import logging as log import platform from contextlib import suppress -from datetime import datetime from types import ModuleType from typing import Dict, Optional, Type @@ -12,7 +13,7 @@ from nio.rooms import MatrixRoom from . import __about__ from .events import rooms, users -from .events.rooms_timeline import EventType, HtmlMessageReceived +from .events.rooms import TimelineEventReceived, TimelineMessageReceived from .html_filter import HTML_FILTER @@ -162,17 +163,157 @@ class MatrixClient(nio.AsyncClient): # Callbacks for nio events - async def onRoomMessageText(self, room: MatrixRoom, ev: nio.RoomMessageText - ) -> None: - is_html = ev.format == "org.matrix.custom.html" - filter_ = HTML_FILTER.filter + # Special %tokens for event contents: + # %S = sender's displayname + # %T = target (ev.state_key)'s displayname - HtmlMessageReceived( - type = EventType.html if is_html else EventType.text, - room_id = room.room_id, - event_id = ev.event_id, - 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, + async def onRoomMessageText(self, room, ev) -> None: + co = HTML_FILTER.filter( + ev.formatted_body + if ev.format == "org.matrix.custom.html" else html.escape(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: 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) diff --git a/src/qml/Chat/Chat.qml b/src/qml/Chat/Chat.qml index ccaec67d..c2324a9e 100644 --- a/src/qml/Chat/Chat.qml +++ b/src/qml/Chat/Chat.qml @@ -55,27 +55,27 @@ HColumnLayout { Layout.fillHeight: true } - TypingMembersBar {} + //TypingMembersBar {} - InviteBanner { - visible: category === "Invites" - inviter: roomInfo.inviter - } +// InviteBanner { + //visible: category === "Invites" + //inviter: roomInfo.inviter + //} - UnknownDevicesBanner { - visible: category == "Rooms" && hasUnknownDevices - } + //UnknownDevicesBanner { + //visible: category == "Rooms" && hasUnknownDevices + //} SendBox { id: sendBox visible: category == "Rooms" && ! hasUnknownDevices } - LeftBanner { - visible: category === "Left" - leftEvent: roomInfo.leftEvent - } - } + //LeftBanner { + //visible: category === "Left" + //leftEvent: roomInfo.leftEvent + //} + //} // RoomSidePane { //id: roomSidePane @@ -144,5 +144,6 @@ HColumnLayout { //Layout.minimumWidth: HStyle.avatar.size //Layout.maximumWidth: parent.width //} + } } } diff --git a/src/qml/Chat/RoomEventList/MessageContent.qml b/src/qml/Chat/RoomEventList/MessageContent.qml index c03f3579..b55ae6db 100644 --- a/src/qml/Chat/RoomEventList/MessageContent.qml +++ b/src/qml/Chat/RoomEventList/MessageContent.qml @@ -7,11 +7,18 @@ Row { spacing: standardSpacing / 2 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 { id: avatar hidden: combine name: senderInfo.displayName || stripUserId(model.senderId) - dimension: 48 + dimension: model.showNameLine ? 48 : 28 } Rectangle { @@ -33,16 +40,13 @@ Row { anchors.fill: parent HLabel { - height: combine ? 0 : implicitHeight width: parent.width + height: model.showNameLine && ! combine ? implicitHeight : 0 visible: height > 0 id: nameLabel text: senderInfo.displayName || model.senderId - color: Qt.hsla(avatar.hueFromName(avatar.name), - HStyle.displayName.saturation, - HStyle.displayName.lightness, - 1) + color: textHueForName(avatar.name) elide: Text.ElideRight maximumLineCount: 1 horizontalAlignment: isOwn ? Text.AlignRight : Text.AlignLeft @@ -53,10 +57,49 @@ Row { } HRichLabel { + function escapeHtml(text) { // TODO: move this + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + } + + function translate(text) { + if (model.translatable == false) { return text } + + text = text.replace( + "%S", + "" + + escapeHtml(senderInfo.displayName || model.senderId) + + "" + ) + + var name = models.users.getUser( + chatPage.userId, model.targetUserId + ).displayName + var sid = avatar.stripUserId(model.targetUserId || "") + + text = text.replace( + "%T", + "" + + escapeHtml(name || model.targetUserId) + + "" + ) + + 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 id: contentLabel - text: model.content + + text: translate(model.content) + "  " + Qt.formatDateTime(model.date, "hh:mm:ss") + @@ -64,8 +107,6 @@ Row { (model.isLocalEcho ? " " : "") - textFormat: model.type == "text" ? - Text.PlainText : Text.RichText color: HStyle.chat.message.body wrapMode: Text.Wrap diff --git a/src/qml/Chat/RoomEventList/RoomEventDelegate.qml b/src/qml/Chat/RoomEventList/RoomEventDelegate.qml index b81d4556..70d57c2b 100644 --- a/src/qml/Chat/RoomEventList/RoomEventDelegate.qml +++ b/src/qml/Chat/RoomEventList/RoomEventDelegate.qml @@ -14,10 +14,6 @@ Column { roomEventListView.model.get(index + 1) : null } - function getIsMessage(type) { - return true - } - property var previousItem: getPreviousItem() signal reloadPreviousItem() onReloadPreviousItem: previousItem = getPreviousItem() @@ -26,18 +22,14 @@ Column { Component.onCompleted: 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 isFirstEvent: model.type == "eventCreate" + readonly property bool isFirstEvent: model.event_type == "RoomCreateEvent" readonly property bool combine: previousItem && ! talkBreak && ! dayBreak && - getIsMessage(previousItem.type) === isMessage && previousItem.senderId === senderId && minsBetween(previousItem.date, model.date) <= 5 @@ -75,14 +67,13 @@ Column { width: roomEventDelegate.width } - Item { + Item { // TODO: put this in Daybreak.qml? visible: dayBreak width: parent.width height: topPadding } - Loader { - source: isMessage ? "MessageContent.qml" : "EventContent.qml" + MessageContent { anchors.right: isOwn ? parent.right : undefined } } diff --git a/src/qml/Chat/utils.js b/src/qml/Chat/utils.js index fec1b18f..8999f83c 100644 --- a/src/qml/Chat/utils.js +++ b/src/qml/Chat/utils.js @@ -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) { if (! leftEvent) { return "You are not member of this room." diff --git a/src/qml/EventHandlers/includes.js b/src/qml/EventHandlers/includes.js index a3466ada..773fe4b3 100644 --- a/src/qml/EventHandlers/includes.js +++ b/src/qml/EventHandlers/includes.js @@ -2,4 +2,3 @@ Qt.include("app.js") Qt.include("users.js") Qt.include("rooms.js") -Qt.include("rooms_timeline.js") diff --git a/src/qml/EventHandlers/rooms.js b/src/qml/EventHandlers/rooms.js index 1d19410f..93947629 100644 --- a/src/qml/EventHandlers/rooms.js +++ b/src/qml/EventHandlers/rooms.js @@ -59,3 +59,25 @@ function onRoomMemberUpdated(room_id, user_id, typing) { 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 diff --git a/src/qml/EventHandlers/rooms_timeline.js b/src/qml/EventHandlers/rooms_timeline.js deleted file mode 100644 index f06889c7..00000000 --- a/src/qml/EventHandlers/rooms_timeline.js +++ /dev/null @@ -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) -}