diff --git a/TODO.md b/TODO.md index 95441728..0f82857f 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,7 @@ - Graphic bug when resizing window vertically for side pane? - Fix tooltip hide() - ![A picture](https://picsum.photos/256/256) not clickable? + - Icons aren't reloaded - UI - Use HRowLayout and its totalSpacing wherever possible @@ -48,6 +49,7 @@ it should be the peer's display name instead. - Missing nio support + - Left room events - `org.matrix.room.preview_urls` event - `m.room.aliases` event - Avatars diff --git a/harmonyqml/backend/client.py b/harmonyqml/backend/client.py index 5e33cb14..a5363f57 100644 --- a/harmonyqml/backend/client.py +++ b/harmonyqml/backend/client.py @@ -22,15 +22,16 @@ _POOLS: DefaultDict[str, ThreadPoolExecutor] = \ class Client(QObject): - roomInvited = pyqtSignal(str) - roomInvited = pyqtSignal(str, dict) - roomJoined = pyqtSignal(str) - roomLeft = pyqtSignal(str) + roomInvited = pyqtSignal([str, dict], [str]) + roomJoined = pyqtSignal(str) + roomLeft = pyqtSignal([str, dict], [str]) + roomSyncPrevBatchTokenReceived = pyqtSignal(str, str) roomPastPrevBatchTokenReceived = pyqtSignal(str, str) roomEventReceived = pyqtSignal(str, str, dict) roomTypingUsersUpdated = pyqtSignal(str, list) - messageAboutToBeSent = pyqtSignal(str, dict) + + messageAboutToBeSent = pyqtSignal(str, dict) def __init__(self, @@ -120,13 +121,13 @@ class Client(QObject): for room_id, room_info in response.rooms.invite.items(): for ev in room_info.invite_state: - member_event = isinstance(ev, ne.InviteMemberEvent) + member_ev = isinstance(ev, ne.InviteMemberEvent) - if member_event and ev.content["membership"] == "join": + if member_ev and ev.content["membership"] == "join": self.roomInvited.emit(room_id, ev.content) break else: - self.roomInvited.emit(room_id) + self.roomInvited[str].emit(room_id) for room_id, room_info in response.rooms.join.items(): self.roomJoined.emit(room_id) @@ -146,8 +147,15 @@ class Client(QObject): else: print("ephemeral event: ", ev) - for room_id in response.rooms.leave: - self.roomLeft.emit(room_id) + for room_id, room_info in response.rooms.leave.items(): + for ev in room_info.timeline.events: + member_ev = isinstance(ev, ne.RoomMemberEvent) + + if member_ev and ev.content["membership"] in ("leave", "ban"): + self.roomLeft.emit(room_id, ev.__dict__) + break + else: + self.roomLeft[str].emit(room_id) @futurize() diff --git a/harmonyqml/backend/model/items.py b/harmonyqml/backend/model/items.py index 1f5c5bfe..31fb934a 100644 --- a/harmonyqml/backend/model/items.py +++ b/harmonyqml/backend/model/items.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Optional, Tuple, Union +from typing import Any, Callable, Optional, Sequence, Tuple, Union from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal @@ -6,8 +6,9 @@ from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal class ListItem(QObject): roles: Tuple[str, ...] = () - def __init__(self, *args, **kwargs): + def __init__(self, *args, no_update: Sequence[str] = (), **kwargs): super().__init__() + self.no_update = no_update for role, value in zip(self.roles, args): setattr(self, role, value) @@ -17,8 +18,9 @@ class ListItem(QObject): def __repr__(self) -> str: - return "%s(%s)" % ( + return "%s(no_update=%s, %s)" % ( type(self).__name__, + self.no_update, ", ".join((f"{r}={getattr(self, r)!r}" for r in self.roles)), ) @@ -63,7 +65,7 @@ class User(ListItem): class Room(ListItem): roles = ("roomId", "category", "displayName", "topic", "typingUsers", - "inviter") + "inviter", "leftEvent") categoryChanged = pyqtSignal(str) displayNameChanged = pyqtSignal("QVariant") @@ -75,7 +77,8 @@ class Room(ListItem): displayName = prop(str, "displayName", displayNameChanged) topic = prop(str, "topic", topicChanged, "") typingUsers = prop(list, "typingUsers", typingUsersChanged, []) - inviter = prop("QVariantMap", "inviter") + inviter = prop("QVariant", "inviter") + leftEvent = prop("QVariant", "leftEvent") class RoomEvent(ListItem): diff --git a/harmonyqml/backend/model/list_model.py b/harmonyqml/backend/model/list_model.py index 4d7e7d6e..a283476a 100644 --- a/harmonyqml/backend/model/list_model.py +++ b/harmonyqml/backend/model/list_model.py @@ -148,6 +148,9 @@ class ListModel(QAbstractListModel): value = self._convert_new_value(value) for role in self.roles: + if role in value.no_update: + continue + setattr(self._data[index], role, getattr(value, role)) qidx = QAbstractListModel.index(self, index, 0) diff --git a/harmonyqml/backend/signal_manager.py b/harmonyqml/backend/signal_manager.py index 06d11c67..20e1d1dd 100644 --- a/harmonyqml/backend/signal_manager.py +++ b/harmonyqml/backend/signal_manager.py @@ -13,7 +13,8 @@ from .backend import Backend from .client import Client from .model.items import Room, RoomEvent, User -Inviter = Optional[Dict[str, str]] +Inviter = Optional[Dict[str, str]] +LeftEvent = Optional[Dict[str, str]] class SignalManager(QObject): @@ -23,8 +24,8 @@ class SignalManager(QObject): super().__init__(parent=backend) self.backend = backend - self.last_room_events: Deque[str] = Deque(maxlen=1000) - self._events_in_transfer: int = 0 + self.last_room_events: Deque[str] = Deque(maxlen=1000) + self._events_in_transfer: int = 0 cm = self.backend.clientManager cm.clientAdded.connect(self.onClientAdded) @@ -60,45 +61,60 @@ class SignalManager(QObject): client: Client, room_id: str, inviter: Inviter = None) -> None: - self._add_room( - client, client.nio.invited_rooms[room_id], "Invites", inviter - ) + + self._add_room(client, room_id, client.nio.invited_rooms[room_id], + "Invites", inviter=inviter) def onRoomJoined(self, client: Client, room_id: str) -> None: - self._add_room(client, client.nio.rooms[room_id], "Rooms") + self._add_room(client, room_id, client.nio.rooms[room_id], "Rooms") + + + def onRoomLeft(self, + client: Client, + room_id: str, + left_event: LeftEvent = None) -> None: + + self._add_room(client, room_id, client.nio.rooms.get(room_id), "Left", + left_event=left_event) def _add_room(self, - client: Client, - room: MatrixRoom, - category: str, - inviter: Inviter = None) -> None: - model = self.backend.models.rooms[client.userId] + client: Client, + room_id: str, + room: MatrixRoom, + category: str, + inviter: Inviter = None, + left_event: LeftEvent = None) -> None: + + assert not (inviter and left_event) + + model = self.backend.models.rooms[client.userId] + no_update = [] + + def get_displayname() -> Optional[str]: + if not room: + no_update.append("displayName") + return room_id + + name = room.name or room.canonical_alias + if name: + return name - def group_name() -> Optional[str]: name = room.group_name() return None if name == "Empty room?" else name item = Room( - roomId = room.room_id, + roomId = room_id, category = category, - displayName = room.name or room.canonical_alias or group_name(), - topic = room.topic, + displayName = get_displayname(), + topic = room.topic if room else "", inviter = inviter, + leftEvent = left_event, + no_update = no_update, ) - model.updateOrAppendWhere("roomId", room.room_id, item) - - - def onRoomLeft(self, client: Client, room_id: str) -> None: - rooms = self.backend.models.rooms[client.userId] - try: - index = rooms.indexWhere("roomId", room_id) - except ValueError: - pass - else: - del rooms[index] + model.updateOrAppendWhere("roomId", room_id, item) def onRoomSyncPrevBatchTokenReceived( diff --git a/harmonyqml/components/UI.qml b/harmonyqml/components/UI.qml index 738dd2c1..b391ee78 100644 --- a/harmonyqml/components/UI.qml +++ b/harmonyqml/components/UI.qml @@ -15,7 +15,7 @@ Controls1.SplitView { } StackView { - function showRoom(userId, roomId, isInvite) { + function showRoom(userId, roomId) { pageStack.replace( "chat/Root.qml", { userId: userId, roomId: roomId } ) @@ -29,6 +29,7 @@ Controls1.SplitView { initialItem: Item { // TODO: (test, remove) Keys.onPressed: pageStack.showRoom( "@test_mary:matrix.org", "!TSXGsbBbdwsdylIOJZ:matrix.org" + //"@test_mary:matrix.org", "!TEXkdeErtVCMqClNfb:matrix.org" ) } diff --git a/harmonyqml/components/chat/Banner.qml b/harmonyqml/components/chat/Banner.qml new file mode 100644 index 00000000..597a1d4a --- /dev/null +++ b/harmonyqml/components/chat/Banner.qml @@ -0,0 +1,89 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.4 +import "../base" as Base + +Rectangle { + id: banner + Layout.fillWidth: true + Layout.preferredHeight: 32 + color: "#BBB" + + property alias avatarName: bannerAvatar.name + property alias avatarSource: bannerAvatar.imageSource + property alias labelText: bannerLabel.text + property alias buttonModel: bannerRepeater.model + + Base.HRowLayout { + id: bannerRow + anchors.fill: parent + + Base.Avatar { + id: bannerAvatar + dimmension: banner.Layout.preferredHeight + } + + Base.HLabel { + id: bannerLabel + textFormat: Text.StyledText + maximumLineCount: 1 + elide: Text.ElideRight + + visible: + bannerRow.width - bannerAvatar.width - bannerButtons.width > 30 + + Layout.maximumWidth: + bannerRow.width - + bannerAvatar.width - bannerButtons.width - + Layout.leftMargin - Layout.rightMargin + + Layout.leftMargin: 10 + Layout.rightMargin: Layout.leftMargin + } + + Item { Layout.fillWidth: true } + + Base.HRowLayout { + id: bannerButtons + spacing: 0 + + function getButtonsWidth() { + var total = 0 + + for (var i = 0; i < bannerRepeater.count; i++) { + total += bannerRepeater.itemAt(i).implicitWidth + } + + return total + } + + property bool compact: + bannerRow.width < + bannerAvatar.width + + bannerLabel.implicitWidth + + bannerLabel.Layout.leftMargin + + bannerLabel.Layout.rightMargin + + getButtonsWidth() + + property int displayMode: + compact ? Button.IconOnly : Button.TextBesideIcon + + Repeater { + id: bannerRepeater + model: [] + + Base.HButton { + id: declineButton + text: modelData.text + iconName: modelData.iconName + icon.color: modelData.iconColor + icon.width: 32 + display: bannerButtons.displayMode + + Layout.maximumWidth: bannerButtons.compact ? height : -1 + Layout.fillHeight: true + } + } + } + } +} diff --git a/harmonyqml/components/chat/InviteBanner.qml b/harmonyqml/components/chat/InviteBanner.qml new file mode 100644 index 00000000..ff17d659 --- /dev/null +++ b/harmonyqml/components/chat/InviteBanner.qml @@ -0,0 +1,29 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.4 +import "../base" as Base + +Banner { + property var inviter: null + + avatarName: inviter ? inviter.displayname : "" + //avatarSource: inviter ? inviter.avatar_url : "" + + labelText: + (inviter ? + ("" + inviter.displayname + "") : qsTr("Someone")) + + " " + qsTr("invited you to join the room.") + + buttonModel: [ + { + text: "Accept", + iconName: "accept", + iconColor: Qt.hsla(0.45, 0.9, 0.3, 1), + }, + { + text: "Decline", + iconName: "decline", + iconColor: Qt.hsla(0.95, 0.9, 0.35, 1), + } + ] +} diff --git a/harmonyqml/components/chat/InviteOffer.qml b/harmonyqml/components/chat/InviteOffer.qml deleted file mode 100644 index 3a548198..00000000 --- a/harmonyqml/components/chat/InviteOffer.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick 2.7 -import QtQuick.Controls 2.2 -import QtQuick.Layouts 1.4 -import "../base" as Base - -Rectangle { - id: inviteOffer - Layout.fillWidth: true - Layout.preferredHeight: 32 - color: "#BBB" - - property var inviter: null - - Base.HRowLayout { - id: inviteRow - anchors.fill: parent - - Base.Avatar { - id: inviteAvatar - name: inviter ? inviter.displayname : "" - dimmension: inviteOffer.Layout.preferredHeight - //imageSource: inviter ? inviter.avatar_url : "" - } - - Base.HLabel { - id: inviteLabel - text: (inviter ? - ("" + inviter.displayname + "") : qsTr("Someone")) + - " " + qsTr("invited you to join the room.") - textFormat: Text.StyledText - maximumLineCount: 1 - elide: Text.ElideRight - - visible: - inviteRow.width - inviteAvatar.width - inviteButtons.width > 30 - - Layout.maximumWidth: - inviteRow.width - - inviteAvatar.width - inviteButtons.width - - Layout.leftMargin - Layout.rightMargin - - Layout.leftMargin: 10 - Layout.rightMargin: Layout.leftMargin - } - - Item { Layout.fillWidth: true } - - Base.HRowLayout { - id: inviteButtons - spacing: 0 - - property bool compact: - inviteRow.width < - inviteAvatar.width + inviteLabel.implicitWidth + - acceptButton.implicitWidth + declineButton.implicitWidth - - property int displayMode: - compact ? Button.IconOnly : Button.TextBesideIcon - - Base.HButton { - id: acceptButton - text: qsTr("Accept") - iconName: "accept" - icon.color: Qt.hsla(0.45, 0.9, 0.3, 1) - display: inviteButtons.displayMode - - Layout.maximumWidth: inviteButtons.compact ? height : -1 - Layout.fillHeight: true - } - - Base.HButton { - id: declineButton - text: qsTr("Decline") - iconName: "decline" - icon.color: Qt.hsla(0.95, 0.9, 0.35, 1) - icon.width: 32 - display: inviteButtons.displayMode - - Layout.maximumWidth: inviteButtons.compact ? height : -1 - Layout.fillHeight: true - } - } - } -} diff --git a/harmonyqml/components/chat/LeftBanner.qml b/harmonyqml/components/chat/LeftBanner.qml new file mode 100644 index 00000000..65351d12 --- /dev/null +++ b/harmonyqml/components/chat/LeftBanner.qml @@ -0,0 +1,25 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.2 +import QtQuick.Layouts 1.4 +import "../base" as Base +import "utils.js" as ChatJS + +Banner { + property var leftEvent: null + + avatarName: ChatJS.getLeftBannerAvatarName(leftEvent, chatPage.userId) + labelText: ChatJS.getLeftBannerText(leftEvent) + + buttonModel: [ + { + text: "Rejoin", + iconName: "join", + iconColor: Qt.hsla(0.13, 0.9, 0.35, 1), + }, + { + text: "Forget", + iconName: "trash_can", + iconColor: Qt.hsla(0.95, 0.9, 0.35, 1), + } + ] +} diff --git a/harmonyqml/components/chat/Root.qml b/harmonyqml/components/chat/Root.qml index cb75df4a..70206c51 100644 --- a/harmonyqml/components/chat/Root.qml +++ b/harmonyqml/components/chat/Root.qml @@ -9,8 +9,6 @@ ColumnLayout { readonly property var roomInfo: Backend.models.rooms.get(userId).getWhere("roomId", roomId) - property bool isInvite: roomInfo.category === "Invites" - id: chatPage spacing: 0 onFocusChanged: sendBox.setFocus() @@ -20,19 +18,22 @@ ColumnLayout { topic: roomInfo.topic } - MessageList {} - TypingUsersBar {} - InviteOffer { - visible: isInvite + InviteBanner { + visible: roomInfo.category === "Invites" inviter: roomInfo.inviter } SendBox { id: sendBox - visible: ! isInvite + visible: roomInfo.category === "Rooms" + } + + LeftBanner { + visible: roomInfo.category === "Left" + leftEvent: roomInfo.leftEvent } } diff --git a/harmonyqml/components/chat/utils.js b/harmonyqml/components/chat/utils.js index 81e65bc7..50aab78d 100644 --- a/harmonyqml/components/chat/utils.js +++ b/harmonyqml/components/chat/utils.js @@ -151,6 +151,51 @@ function getMemberEventText(dict) { } +function getLeftBannerText(leftEvent) { + if (! leftEvent) { + return "You are not member of this room." + } + + console.log(JSON.stringify(leftEvent, null, 4)) + + var info = leftEvent.content + var prev = leftEvent.prev_content + var reason = info.reason ? (" Reason: " + info.reason) : "" + + if (leftEvent.state_key === leftEvent.sender) { + return (prev && prev.membership === "invite" ? + "You declined to join this room." : "You left the room.") + + reason + } + + if (info.membership) + + var name = Backend.getUserDisplayName(leftEvent.sender, false).result() + + return "" + name + " " + + (info.membership == "ban" ? + "banned you from the room." : + + prev && prev.membership === "invite" ? + "canceled your invitation." : + + prev && prev.membership == "ban" ? + "unbanned you from the room." : + + "kicked you out of the room.") + + reason +} + + +function getLeftBannerAvatarName(leftEvent, accountId) { + if (! leftEvent || leftEvent.state_key == leftEvent.sender) { + return Backend.getUserDisplayName(accountId, false).result() + } + + return Backend.getUserDisplayName(leftEvent.sender, false).result() +} + + function getTypingUsersText(users, ourAccountId) { var names = [] diff --git a/harmonyqml/icons/join.svg b/harmonyqml/icons/join.svg new file mode 100644 index 00000000..c1224e13 --- /dev/null +++ b/harmonyqml/icons/join.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/harmonyqml/icons/trash_can.svg b/harmonyqml/icons/trash_can.svg new file mode 100644 index 00000000..107485c1 --- /dev/null +++ b/harmonyqml/icons/trash_can.svg @@ -0,0 +1 @@ + \ No newline at end of file