From be8f11d3a6f328c60cb90381a71bee5a2ab09078 Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Sun, 22 Jan 2023 11:12:31 +0200 Subject: [PATCH 01/17] polling inputText clickable issue in firefox --- src/components/InputControl.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/InputControl.vue b/src/components/InputControl.vue index a0a077c..b6d75e4 100644 --- a/src/components/InputControl.vue +++ b/src/components/InputControl.vue @@ -140,12 +140,14 @@ export default { letter-spacing: 0.4px; color: #000000; position: absolute; - top: 12px; + top: 20px; left: 16px; right: 16px; bottom: 0; + width: 100%; + &:focus { - top: 12px; + top: 20px; } &:focus, &:focus-visible { From 11e544b1c5472398051058d2ef668b694639f07a Mon Sep 17 00:00:00 2001 From: N-Pex Date: Sun, 22 Jan 2023 14:41:35 +0100 Subject: [PATCH 02/17] Add kick,ban,make admin and make moderator operations --- src/assets/css/chat.scss | 6 ++ src/assets/translations/en.json | 17 ++- src/components/RoomInfo.vue | 100 ++++++++++++++++++ src/components/chatMixin.js | 10 ++ src/components/messages/ContactBanned.vue | 25 +++++ src/components/messages/ContactChanged.vue | 2 +- src/components/messages/ContactInvited.vue | 2 +- src/components/messages/ContactJoin.vue | 2 +- src/components/messages/ContactKicked.vue | 25 +++++ src/components/messages/ContactLeave.vue | 2 +- src/components/messages/MessageIncoming.vue | 4 +- src/components/messages/RoomAliased.vue | 2 +- src/components/messages/RoomAvatarChanged.vue | 2 +- src/components/messages/RoomCreated.vue | 2 +- .../messages/RoomDeletionNotice.vue | 2 +- src/components/messages/RoomEncrypted.vue | 2 +- .../messages/RoomGuestAccessChanged.vue | 4 +- .../messages/RoomHistoryVisibility.vue | 2 +- src/components/messages/RoomJoinRules.vue | 2 +- src/components/messages/RoomNameChanged.vue | 2 +- src/components/messages/RoomTopicChanged.vue | 2 +- src/components/messages/messageMixin.js | 25 ++++- src/services/matrix.service.js | 66 +++++++++++- 23 files changed, 281 insertions(+), 27 deletions(-) create mode 100644 src/components/messages/ContactBanned.vue create mode 100644 src/components/messages/ContactKicked.vue diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index f949b52..ecb8ad3 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -863,6 +863,12 @@ $admin-fg: white; margin-left: 6px; } + .member .user-power { + margin-left: 6px; + color: #aaa; + font-size: 0.8rem; + } + .member .start-private-chat { margin-left: 38px; } diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 02d95a0..7c711db 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -26,7 +26,12 @@ "undo": "Undo", "join": "Join", "ignore": "Ignore", - "loading": "Loading {appName}" + "loading": "Loading {appName}", + "user_kick": "Kick this user", + "user_kick_and_ban": "Kick and ban this user", + "user_make_admin": "Make administrator", + "user_make_moderator": "Make moderator", + "user_revoke_moderator": "Revoke moderator" }, "message": { "you": "You", @@ -37,6 +42,12 @@ "user_changed_room_avatar": "{user} changed the room avatar", "user_encrypted_room": "{user} made the room encrypted", "user_was_invited": "{user} was invited to the chat...", + "user_was_kicked": "{user} was kicked from the chat.", + "user_was_kicked_by_you": "You kicked {user} from the chat.", + "user_was_kicked_you": "You were kicked from the chat.", + "user_was_banned": "{user} was kicked and banned from the chat.", + "user_was_banned_by_you": "You kicked and banned {user} from the chat.", + "user_was_banned_you": "You were kicked and banned from the chat.", "user_joined": "{user} joined the chat", "user_left": "{user} left the chat", "user_said": "{user} said:", @@ -229,7 +240,9 @@ "leave_room": "Leave", "version_info": "Powered by Guardian Project. Version: {version}", "scan_code": "Scan to join the room", - "export_room": "Export chat" + "export_room": "Export chat", + "user_admin": "Administrator", + "user_moderator": "Moderator" }, "room_info_sheet": { "this_room": "This room", diff --git a/src/components/RoomInfo.vue b/src/components/RoomInfo.vue index 52fb29a..93e9557 100644 --- a/src/components/RoomInfo.vue +++ b/src/components/RoomInfo.vue @@ -195,7 +195,22 @@ }) }} + + {{ $t("room_info.user_admin") }} + + + {{ $t("room_info.user_moderator") }} +
{{ $t("menu.start_private_chat") }}
+
+
{{ String.fromCharCode(160) }}
+
{{ $t("menu.user_kick") }}
+
{{ $t("menu.user_kick_and_ban") }}
+
+
{{ String.fromCharCode(160) }}
+
{{ $t("menu.user_make_admin") }}
+
{{ $t("menu.user_make_moderator") }}
+
{{ $t("menu.user_revoke_moderator") }}
member.powerLevel && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("kick", me.powerLevel); + } + return false; + }, + canBanUser(member) { + if (this.room) { + const myUserId = this.$matrix.currentUserId; + const me = this.room.getMember(myUserId); + return me && me.powerLevelNorm > member.powerLevelNorm && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("ban", me.powerLevel); + } + return false; + }, + // TODO - following power level comparisons assume that default power levels are used in the room! + isAdmin(member) { + return member.powerLevelNorm > 50; + }, + isModerator(member) { + return member.powerLevelNorm > 0 && member.powerLevelNorm <= 50; + }, + /** + * Return true if WE can make the member an admin + * @param member + */ + canMakeAdmin(ignoredmember) { + if (this.room) { + const myUserId = this.$matrix.currentUserId; + const me = this.room.getMember(myUserId); + return me && this.isAdmin(me); + } + return false; + }, + + /** + * Return true if WE can make the member a moderator + * @param member + */ + canMakeModerator(ignoredmember) { + if (this.room) { + const myUserId = this.$matrix.currentUserId; + const me = this.room.getMember(myUserId); + return me && this.isAdmin(me); + } + return false; + }, + /** + * Return true if WE can "unmake" the member a moderator + * @param member + */ + canRevokeModerator(member) { + if (this.room) { + const myUserId = this.$matrix.currentUserId; + const me = this.room.getMember(myUserId); + return me && this.isAdmin(me) && me.powerLevel > member.powerLevel; + } + return false; + }, + makeAdmin(member) { + if (this.room) { + this.$matrix.makeAdmin(this.room.roomId, member.userId) + } + }, + makeModerator(member) { + if (this.room) { + this.$matrix.makeModerator(this.room.roomId, member.userId) + } + }, + revokeModerator(member) { + if (this.room) { + this.$matrix.revokeModerator(this.room.roomId, member.userId) + } + }, + kickUser(member) { + if (this.room) { + this.$matrix.kickUser(this.room.roomId, member.userId) + } + }, + banUser(member) { + if (this.room) { + this.$matrix.banUser(this.room.roomId, member.userId) + } } }, }; diff --git a/src/components/chatMixin.js b/src/components/chatMixin.js index 970fe16..d87a548 100644 --- a/src/components/chatMixin.js +++ b/src/components/chatMixin.js @@ -22,6 +22,8 @@ import MessageOutgoingVideoExport from "./messages/export/MessageOutgoingVideoEx import ContactJoin from "./messages/ContactJoin.vue"; import ContactLeave from "./messages/ContactLeave.vue"; import ContactInvited from "./messages/ContactInvited.vue"; +import ContactKicked from "./messages/ContactKicked.vue"; +import ContactBanned from "./messages/ContactBanned.vue"; import ContactChanged from "./messages/ContactChanged.vue"; import RoomCreated from "./messages/RoomCreated.vue"; import RoomAliased from "./messages/RoomAliased.vue"; @@ -66,6 +68,8 @@ export default { ContactJoin, ContactLeave, ContactInvited, + ContactKicked, + ContactBanned, ContactChanged, RoomCreated, RoomAliased, @@ -123,9 +127,15 @@ export default { return ContactJoin; } } else if (event.getContent().membership == "leave") { + if ((event.getPrevContent() || {}).membership == "join" && + event.getStateKey() != event.getSender()) { + return ContactKicked; + } return ContactLeave; } else if (event.getContent().membership == "invite") { return ContactInvited; + } else if (event.getContent().membership == "ban") { + return ContactBanned; } break; diff --git a/src/components/messages/ContactBanned.vue b/src/components/messages/ContactBanned.vue new file mode 100644 index 0000000..37c2a45 --- /dev/null +++ b/src/components/messages/ContactBanned.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/ContactChanged.vue b/src/components/messages/ContactChanged.vue index 95622dc..e03d24a 100644 --- a/src/components/messages/ContactChanged.vue +++ b/src/components/messages/ContactChanged.vue @@ -33,7 +33,7 @@ export default { if (this.displayNameChange) { return this.event.getPrevContent().displayname; } - return this.stateEventDisplayName(this.event); + return this.eventStateKeyDisplayName(this.event); }, } }; diff --git a/src/components/messages/ContactInvited.vue b/src/components/messages/ContactInvited.vue index 85f96a8..110c43e 100644 --- a/src/components/messages/ContactInvited.vue +++ b/src/components/messages/ContactInvited.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/ContactJoin.vue b/src/components/messages/ContactJoin.vue index b8bce13..d8aa6d8 100644 --- a/src/components/messages/ContactJoin.vue +++ b/src/components/messages/ContactJoin.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/ContactKicked.vue b/src/components/messages/ContactKicked.vue new file mode 100644 index 0000000..b34da00 --- /dev/null +++ b/src/components/messages/ContactKicked.vue @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/ContactLeave.vue b/src/components/messages/ContactLeave.vue index 0b77831..1e40799 100644 --- a/src/components/messages/ContactLeave.vue +++ b/src/components/messages/ContactLeave.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/MessageIncoming.vue b/src/components/messages/MessageIncoming.vue index 9c31eb3..e1f1727 100644 --- a/src/components/messages/MessageIncoming.vue +++ b/src/components/messages/MessageIncoming.vue @@ -2,7 +2,7 @@
-
{{ messageEventDisplayName(event) }}
+
{{ eventSenderDisplayName(event) }}
{{ formatTime(event.event.origin_server_ts) }}
@@ -10,7 +10,7 @@ {{ - messageEventDisplayName(event).substring(0, 1).toUpperCase() + eventSenderDisplayName(event).substring(0, 1).toUpperCase() }} diff --git a/src/components/messages/RoomAliased.vue b/src/components/messages/RoomAliased.vue index 5a908e6..9c87f29 100644 --- a/src/components/messages/RoomAliased.vue +++ b/src/components/messages/RoomAliased.vue @@ -1,6 +1,6 @@ diff --git a/src/components/messages/RoomAvatarChanged.vue b/src/components/messages/RoomAvatarChanged.vue index 7645e80..cc68bca 100644 --- a/src/components/messages/RoomAvatarChanged.vue +++ b/src/components/messages/RoomAvatarChanged.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/RoomCreated.vue b/src/components/messages/RoomCreated.vue index 9603e0f..c83ad15 100644 --- a/src/components/messages/RoomCreated.vue +++ b/src/components/messages/RoomCreated.vue @@ -1,6 +1,6 @@ diff --git a/src/components/messages/RoomDeletionNotice.vue b/src/components/messages/RoomDeletionNotice.vue index 9a2f476..f8b7524 100644 --- a/src/components/messages/RoomDeletionNotice.vue +++ b/src/components/messages/RoomDeletionNotice.vue @@ -4,7 +4,7 @@ 👋  {{ $t("purge_room.room_deletion_notice", { - user: stateEventDisplayName(event), + user: eventSenderDisplayName(event), }) }}
diff --git a/src/components/messages/RoomEncrypted.vue b/src/components/messages/RoomEncrypted.vue index 669b6e7..874d989 100644 --- a/src/components/messages/RoomEncrypted.vue +++ b/src/components/messages/RoomEncrypted.vue @@ -1,6 +1,6 @@ diff --git a/src/components/messages/RoomGuestAccessChanged.vue b/src/components/messages/RoomGuestAccessChanged.vue index 13d7f27..b89501c 100644 --- a/src/components/messages/RoomGuestAccessChanged.vue +++ b/src/components/messages/RoomGuestAccessChanged.vue @@ -3,10 +3,10 @@ {{ openToGuests ? $t("message.user_changed_guest_access_open", { - user: stateEventDisplayName(event), + user: eventSenderDisplayName(event), }) : $t("message.user_changed_guest_access_closed", { - user: stateEventDisplayName(event), + user: eventSenderDisplayName(event), }) }}
diff --git a/src/components/messages/RoomHistoryVisibility.vue b/src/components/messages/RoomHistoryVisibility.vue index 19892f4..a6e1592 100644 --- a/src/components/messages/RoomHistoryVisibility.vue +++ b/src/components/messages/RoomHistoryVisibility.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/RoomJoinRules.vue b/src/components/messages/RoomJoinRules.vue index f80dbff..0ee433b 100644 --- a/src/components/messages/RoomJoinRules.vue +++ b/src/components/messages/RoomJoinRules.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/RoomNameChanged.vue b/src/components/messages/RoomNameChanged.vue index ba152b8..1e844c5 100644 --- a/src/components/messages/RoomNameChanged.vue +++ b/src/components/messages/RoomNameChanged.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/RoomTopicChanged.vue b/src/components/messages/RoomTopicChanged.vue index 6ac9992..9268756 100644 --- a/src/components/messages/RoomTopicChanged.vue +++ b/src/components/messages/RoomTopicChanged.vue @@ -1,7 +1,7 @@ diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index 1820781..7aea0ba 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -50,7 +50,7 @@ export default { const originalEvent = this.timelineSet.findEventById(originalEventId); if (originalEvent) { this.inReplyToEvent = originalEvent; - this.inReplyToSender = this.messageEventDisplayName(originalEvent); + this.inReplyToSender = this.eventSenderDisplayName(originalEvent); } } } @@ -157,7 +157,7 @@ export default { /** * Get a display name given an event. */ - stateEventDisplayName(event) { + eventSenderDisplayName(event) { if (event.getSender() == this.$matrix.currentUserId) { return this.$t('message.you'); } @@ -167,11 +167,26 @@ export default { return member.name; } } - return event.getContent().displayname || event.event.state_key; + return event.getContent().displayname || event.getSender(); }, - messageEventDisplayName(event) { - return this.stateEventDisplayName(event); + /** + * In the case where the state_key points out a userId for an operation (e.g. membership events) + * return the display name of the affected user. + * @param event + * @returns + */ + eventStateKeyDisplayName(event) { + if (event.getStateKey() == this.$matrix.currentUserId) { + return this.$t('message.you'); + } + if (this.room) { + const member = this.room.getMember(event.getStateKey()); + if (member) { + return member.name; + } + } + return event.getStateKey(); }, messageEventAvatar(event) { diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 158c971..ffe78fd 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -356,19 +356,22 @@ export default { event.getRoomId() == this.currentRoom.roomId ) { // Don't use this.currentRoomId, may be an alias. We need the real id! - if ( + if (( event.getContent().membership == "leave" && (event.getPrevContent() || {}).membership == "join" && event.getStateKey() == this.currentUserId && event.getSender() != this.currentUserId - ) { - // We were kicked + ) || (event.getContent().membership == "ban" && event.getStateKey() == this.currentUserId)) { + // We were kicked or banned + // If this is a live event (not just backpaging) then redirect to goodbye! + if (this.matrixClientReady) { const wasPurged = event.getContent().reason == "Room Deleted"; this.$navigation.push( { name: "Goodbye", params: { roomWasPurged: wasPurged } }, -1 ); + } } } } @@ -546,6 +549,63 @@ export default { }); }, + kickUser(roomId, userId) { + if (this.matrixClient && roomId && userId) { + this.matrixClient.kick( + roomId, + userId, + "" + ) + } + }, + + banUser(roomId, userId) { + if (this.matrixClient && roomId && userId) { + this.matrixClient.ban( + roomId, + userId, + "" + ) + } + }, + + makeAdmin(roomId, userId) { + if (this.matrixClient && roomId && userId) { + const room = this.getRoom(roomId); + if (room && room.currentState) { + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (powerLevelEvent) { + this.matrixClient.setPowerLevel(roomId, userId, 100, powerLevelEvent); + } + } + } + }, + + makeModerator(roomId, userId) { + if (this.matrixClient && roomId && userId) { + const room = this.getRoom(roomId); + console.log("Room", room); + if (room && room.currentState) { + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (powerLevelEvent) { + this.matrixClient.setPowerLevel(roomId, userId, 50, powerLevelEvent); + } + } + } + }, + + revokeModerator(roomId, userId) { + if (this.matrixClient && roomId && userId) { + const room = this.getRoom(roomId); + if (room && room.currentState) { + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (powerLevelEvent) { + this.matrixClient.setPowerLevel(roomId, userId, 0, powerLevelEvent); + } + } + } + }, + /** * Purge the room with the given id! This means: * - Make room invite only From b6dfe2298e68aebf71ce265dceb494ed983365a3 Mon Sep 17 00:00:00 2001 From: N Pex Date: Mon, 23 Jan 2023 12:09:15 +0000 Subject: [PATCH 03/17] Sort member list in room info --- src/components/RoomInfo.vue | 63 +++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/src/components/RoomInfo.vue b/src/components/RoomInfo.vue index 93e9557..1461443 100644 --- a/src/components/RoomInfo.vue +++ b/src/components/RoomInfo.vue @@ -168,12 +168,12 @@ {{ $t("room_info.members") }} -
{{ memberCount }}
{{ members.length }}
-
+
{{ showAllMembers ? $t("room_info.hide_all") : $t("room_info.show_all") }} @@ -294,7 +294,7 @@ export default { }, data() { return { - memberCount: null, + members: [], user: null, displayName: "", showAllMembers: false, @@ -323,7 +323,7 @@ export default { }, mounted() { this.$matrix.on("Room.timeline", this.onEvent); - this.updateMemberCount(); + this.updateMembers(); this.user = this.$matrix.matrixClient.getUser(this.$matrix.currentUserId); this.displayName = this.user.displayName; @@ -362,30 +362,13 @@ export default { } return ""; }, - - joinedMembers() { - if (!this.room) { - return []; - } - const myUserId = this.$matrix.currentUserId; - return this.room.getJoinedMembers().sort((a, b) => { - if (a.userId == myUserId) { - return -1; - } else if (b.userId == myUserId) { - return 1; - } - const aName = a.user ? a.user.displayName : a.name; - const bName = b.user ? b.user.displayName : b.name; - return aName.localeCompare(bName); - }); - }, }, watch: { room: { handler(ignoredNewVal, ignoredOldVal) { console.log("RoomInfo: Current room changed"); - this.updateMemberCount(); + this.updateMembers(); this.updateQRCode(); }, }, @@ -393,19 +376,39 @@ export default { methods: { onEvent(event) { - if (event.getRoomId() !== this.roomId) { - return; // Not for this room + if (this.room && this.room.roomId == event.getRoomId()) { + // For this room + if (event.getType() == "m.room.member") { + this.updateMembers(); } - if (event.getType() == "m.room.member") { - this.updateMemberCount(); } }, - updateMemberCount() { + updateMembers() { if (this.room) { - this.memberCount = this.room.getJoinedMemberCount(); + const myUserId = this.$matrix.currentUserId; + this.members = this.room.getJoinedMembers().sort((a, b) => { + // Place ourselves at the top! + if (a.userId == myUserId) { + return -1; + } else if (b.userId == myUserId) { + return 1; + } + + // Then sort by power level + if (a.powerLevel > b.powerLevel) { + return -1; + } else if (b.powerLevel > a.powerLevel) { + return 1; + } + + // Then by name + const aName = a.user ? a.user.displayName : a.name; + const bName = b.user ? b.user.displayName : b.name; + return aName.localeCompare(bName); + }); } else { - this.memberCount = null; + this.members = []; } }, From 7ac548d76c1c37e17c4702a876f92ca4f16147c6 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 24 Jan 2023 20:57:01 +0100 Subject: [PATCH 04/17] New logotype --- public/favicon.ico | Bin 6458 -> 361102 bytes src/App.vue | 2 +- src/assets/logo.png | Bin 6849 -> 0 bytes src/assets/logo.svg | 6 ++---- src/components/Home.vue | 2 +- src/components/Join.vue | 2 +- src/components/Login.vue | 1 + 7 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 src/assets/logo.png diff --git a/public/favicon.ico b/public/favicon.ico index 028025277dba5a847da68dd95a6b655fc46c06ac..90a2eabf507d207175725a2a103483560f61854f 100644 GIT binary patch literal 361102 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 diff --git a/src/App.vue b/src/App.vue index 3e6596f..2b1c114 100644 --- a/src/App.vue +++ b/src/App.vue @@ -145,7 +145,7 @@ export default { }, favicon() { - var favicon = undefined; + var favicon = 'favicon.ico'; if (this.$route.meta.includeFavicon) { if (this.$matrix.currentRoom) { favicon = this.$matrix.currentRoom.avatar || 'favicon.ico'; diff --git a/src/assets/logo.png b/src/assets/logo.png deleted file mode 100644 index f3d2503fc2a44b5053b0837ebea6e87a2d339a43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6849 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 - - - + + diff --git a/src/components/Home.vue b/src/components/Home.vue index 52b27e2..872db70 100644 --- a/src/components/Home.vue +++ b/src/components/Home.vue @@ -4,7 +4,7 @@ - + diff --git a/src/components/Join.vue b/src/components/Join.vue index 2020fa3..8e0d464 100644 --- a/src/components/Join.vue +++ b/src/components/Join.vue @@ -121,7 +121,7 @@
- +
{{ $t("project.name") }} diff --git a/src/components/Login.vue b/src/components/Login.vue index 8bdc836..59b8702 100644 --- a/src/components/Login.vue +++ b/src/components/Login.vue @@ -8,6 +8,7 @@ src="@/assets/logo.svg" width="32" height="32" + contain xclass="d-inline-block header-button-left" /> From 09173a65f11669d2cf3aec398fdfd412bc30dba7 Mon Sep 17 00:00:00 2001 From: N Pex Date: Mon, 30 Jan 2023 08:36:02 +0000 Subject: [PATCH 05/17] Initial implementation of "audio mode" --- src/assets/css/chat.scss | 132 +++++- src/assets/icons/forward.vue | 12 + src/assets/icons/pause_circle.vue | 7 + src/assets/icons/play_circle.vue | 7 + src/assets/icons/rewind.vue | 12 + src/assets/translations/en.json | 4 +- src/components/AudioLayout.vue | 406 +++++++++++++++++ src/components/Chat.vue | 507 +++++++++------------ src/components/RoomExport.vue | 2 +- src/components/RoomInfo.vue | 32 +- src/components/messages/AudioPlayer.vue | 3 +- src/components/messages/attachmentMixin.js | 54 ++- src/components/messages/messageMixin.js | 134 +++--- src/plugins/vuetify.js | 42 +- 14 files changed, 944 insertions(+), 410 deletions(-) create mode 100644 src/assets/icons/forward.vue create mode 100644 src/assets/icons/pause_circle.vue create mode 100644 src/assets/icons/play_circle.vue create mode 100644 src/assets/icons/rewind.vue create mode 100644 src/components/AudioLayout.vue diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index ecb8ad3..175cd41 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -5,6 +5,17 @@ $admin-bg: black; $admin-fg: white; +body { + --v-background-color: white; + --v-foreground-color: black; + --v-divider-color: #eeeeee; + &.dark { + --v-background-color: black; + --v-foreground-color: white; + --v-divider-color: rgba(221, 221, 221, 0.1); + } +} + .home { .v-card { background-color: white; @@ -30,8 +41,8 @@ $admin-fg: white; margin: 0; padding: 0; height: 72px; - background-color: #ffffff; - border-bottom: 1px solid #eeeeee; + background-color: var(--v-background-color); + border-bottom: 1px solid var(--v-divider-color); .chat-header-row { margin: 0; padding: 4px 10px; @@ -47,15 +58,15 @@ $admin-fg: white; font-family: "Inter", sans-serif; font-weight: 400; font-size: 12 * $chat-text-size; - color: black; + color: var(--v-foreground-color); } .v-btn.leave-button { font-family: "Inter", sans-serif; font-weight: 700; font-size: 11 * $chat-text-size; - color: black; - background-color: white !important; - border: 1px solid black; + color: var(--v-foreground-color); + background-color: var(--v-background-color) !important; + border: 1px solid var(--v-foreground-color); border-radius: $chat-standard-padding / 2; height: $chat-standard-padding; margin-top: $chat-standard-padding-xs; @@ -65,9 +76,9 @@ $admin-fg: white; font-family: "Inter", sans-serif; font-weight: 700; font-size: 11 * $chat-text-size; - color: black; - background-color: white !important; - border: 1px solid black; + color: var(--v-foreground-color); + background-color: var(--v-background-color) !important; + border: 1px solid var(--v-foreground-color); border-radius: $chat-standard-padding / 2; height: $chat-standard-padding; margin-top: $chat-standard-padding-xs; @@ -699,7 +710,7 @@ $admin-fg: white; font-weight: 700; font-size: 18 * $chat-text-size; text-transform: uppercase; - color: black; + color: var(--v-foreground-color); text-align: center; } @@ -1217,3 +1228,104 @@ $admin-fg: white; opacity: 1; transition: opacity 0.3s linear; } + +.auto-audio-player-root { + position: absolute; + top: 72px; + left: 0; + right: 0; + bottom: 0; + margin: 0; + background-color: var(--v-background-color); + color: var(--v-foreground-color); + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + .load-earlier { + flex: 1 0 auto; + padding: 20px; + } + .load-later { + flex: 1 0 auto; + padding: 20px; + display: flex; + flex-direction: column; + align-items:center; + justify-content: flex-end; + width: 100%; + } + .mic-button { + align-self: flex-end; + } + .senderAndTime { + .sender { + margin-left: 0; + color: inherit; + } + .time { + color: inherit; + } + text-align: center; + color: inherit; + } + .sound-wave-view { + width: 80%; + max-width: 40vh; + aspect-ratio: 1/1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + .volume-container { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + div { + width: 0px; + height: 0px; + background-color: transparent; + border-radius: 50%; + } + } + canvas { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 100%; + height: 100%; + } + } + .avatar { + width: 103px !important; + height: 103px !important; + margin: 0 !important; + } + #btn-play, #btn-pause { + margin: 26px; + } + .mic-button { + z-index: 0; + } +} + +.audio-layout.voice-recorder { + left: 20px; + right: 20px; + bottom: 20px; + position: absolute; +} \ No newline at end of file diff --git a/src/assets/icons/forward.vue b/src/assets/icons/forward.vue new file mode 100644 index 0000000..e38a979 --- /dev/null +++ b/src/assets/icons/forward.vue @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/src/assets/icons/pause_circle.vue b/src/assets/icons/pause_circle.vue new file mode 100644 index 0000000..3de8c1d --- /dev/null +++ b/src/assets/icons/pause_circle.vue @@ -0,0 +1,7 @@ + diff --git a/src/assets/icons/play_circle.vue b/src/assets/icons/play_circle.vue new file mode 100644 index 0000000..dad7db5 --- /dev/null +++ b/src/assets/icons/play_circle.vue @@ -0,0 +1,7 @@ + diff --git a/src/assets/icons/rewind.vue b/src/assets/icons/rewind.vue new file mode 100644 index 0000000..6c0b2e8 --- /dev/null +++ b/src/assets/icons/rewind.vue @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 7c711db..0110511 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -242,7 +242,9 @@ "scan_code": "Scan to join the room", "export_room": "Export chat", "user_admin": "Administrator", - "user_moderator": "Moderator" + "user_moderator": "Moderator", + "ui_options": "UI Options", + "audio_layout": "Use audio layout" }, "room_info_sheet": { "this_room": "This room", diff --git a/src/components/AudioLayout.vue b/src/components/AudioLayout.vue new file mode 100644 index 0000000..ff47abd --- /dev/null +++ b/src/components/AudioLayout.vue @@ -0,0 +1,406 @@ + + + + + \ No newline at end of file diff --git a/src/components/Chat.vue b/src/components/Chat.vue index da95b28..acecb6c 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -4,49 +4,39 @@ {{ $tc("room.invitations", invitationCount) }}
-
+ + + + +
- + " :originalEvent="selectedEvent" />
- +
@@ -59,55 +49,31 @@
-
- +
+ -
+
- +
- + arrow_downward @@ -121,10 +87,11 @@
{{ $t("message.reply_image") }}
{{ $t("message.reply_audio_message") }}
{{ $t("message.reply_video") }}
-
{{ $t("message.reply_poll") }}
+
{{ $t("message.reply_poll") }}
- + $vuetify.icons.poll @@ -143,24 +110,13 @@ - + " /> @@ -169,150 +125,82 @@ - + $vuetify.icons.poll - - + + mic - + mic - + {{ editedEvent ? "save" : "arrow_upward" }} - - + + $vuetify.icons.addReaction - + face - +
+ +
- +
file: {{ currentImageInputPath.name }} - {{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }} + {{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }} - {{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }} + {{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }} - ({{ formatBytes(currentImageInput.scaledSize) }}) + ({{ formatBytes(currentImageInput.scaledSize) }}) ({{ formatBytes(currentImageInputPath.size) }}) - +
{{ currentSendError }}
{{ currentSendProgress }}
@@ -321,17 +209,10 @@ {{ - $t("menu.cancel") + $t("menu.cancel") }} - {{ $t("menu.send") }} + {{ $t("menu.send") }}
@@ -363,7 +244,7 @@ {{ - $t("menu.ok") + $t("menu.ok") }} @@ -389,6 +270,7 @@ import BottomSheet from "./BottomSheet.vue"; import ImageResize from "image-resize"; import CreatePollDialog from "./CreatePollDialog.vue"; import chatMixin from "./chatMixin"; +import AudioLayout from "./AudioLayout.vue"; const sizeOf = require("image-size"); const dataUriToBuffer = require("data-uri-to-buffer"); @@ -405,7 +287,7 @@ function ScrollPosition(node) { this.readyFor = "up"; } -ScrollPosition.prototype.restore = function() { +ScrollPosition.prototype.restore = function () { if (this.readyFor === "up") { this.node.scrollTop = this.node.scrollHeight - this.previousScrollHeightMinusTop; } else { @@ -413,7 +295,7 @@ ScrollPosition.prototype.restore = function() { } }; -ScrollPosition.prototype.prepareFor = function(direction) { +ScrollPosition.prototype.prepareFor = function (direction) { this.readyFor = direction || "up"; if (this.readyFor === "up") { this.previousScrollHeightMinusTop = this.node.scrollHeight - this.node.scrollTop; @@ -436,6 +318,7 @@ export default { BottomSheet, AvatarOperations, CreatePollDialog, + AudioLayout }, data() { @@ -516,9 +399,13 @@ export default { }, mounted() { - const container = this.$refs.chatContainer; - this.scrollPosition = new ScrollPosition(container); - this.chatContainerSize = this.$refs.chatContainerResizer.$el.clientHeight; + const container = this.chatContainer; + if (container) { + this.scrollPosition = new ScrollPosition(container); + if (this.$refs.chatContainerResizer) { + this.chatContainerSize = this.$refs.chatContainerResizer.$el.clientHeight; + } + } }, beforeDestroy() { @@ -531,6 +418,14 @@ export default { }, computed: { + chatContainer() { + const container = this.$refs.chatContainer; + console.log("GOT CONTAINER", container); + if (this.useAudioLayout) { + return container.$el; + } + return container; + }, senderDisplayName() { return this.room.getMember(this.replyToEvent.sender.userId).name; }, @@ -627,9 +522,28 @@ export default { me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel); return isAdmin; }, + useAudioLayout: { + get: function () { + if (this.room) { + const tags = this.room.tags; + if (tags && tags["ui_options"]) { + return tags["ui_options"]["audio_layout"] === 1; + } + } + return false; + }, + } }, watch: { + initialLoadDone: { + immediate: true, + handler(value, oldValue) { + if (value && !oldValue) { + console.log("Loading finished!"); + } + } + }, roomId: { immediate: true, handler(value, oldValue) { @@ -729,6 +643,7 @@ export default { const getMoreIfNeeded = function _getMoreIfNeeded() { const container = self.$refs.chatContainer; if ( + container && container.scrollHeight <= (1 + 2 * WINDOW_BUFFER_SIZE) * container.clientHeight && self.timelineWindow && self.timelineWindow.canPaginate(EventTimeline.BACKWARDS) @@ -867,22 +782,22 @@ export default { handleChatContainerResize({ ignoredWidth, height }) { const delta = height - this.chatContainerSize; this.chatContainerSize = height; - const container = this.$refs.chatContainer; - if (delta < 0) { + const container = this.chatContainer; + if (container && delta < 0) { container.scrollTop -= delta; } }, paginateBackIfNeeded() { this.$nextTick(() => { - const container = this.$refs.chatContainer; - if (container.scrollHeight <= container.clientHeight) { + const container = this.chatContainer; + if (container && container.scrollHeight <= container.clientHeight) { this.handleScrolledToTop(); } }); }, onScroll(ignoredevent) { - const container = this.$refs.chatContainer; + const container = this.chatContainer; if (!container) { return; } @@ -898,7 +813,7 @@ export default { container.scrollHeight === container.clientHeight ? false : container.scrollHeight - container.scrollTop.toFixed(0) > container.clientHeight || - (this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS)); + (this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS)); this.restartRRTimer(); }, @@ -908,19 +823,20 @@ export default { return; // Not for this room } + const loadingDone = this.initialLoadDone; this.$matrix.matrixClient.decryptEventIfNeeded(event, {}); - if (this.initialLoadDone) { + if (this.initialLoadDone && !this.useAudioLayout) { this.paginateBackIfNeeded(); } - // If we are at bottom, scroll to see new events... - const container = this.$refs.chatContainer; - var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll - if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) { - scrollToSeeNew = true; - } - if (this.initialLoadDone && event.forwardLooking && !event.isRelation()) { + if (loadingDone && event.forwardLooking && !event.isRelation()) { + // If we are at bottom, scroll to see new events... + var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll + const container = this.chatContainer; + if (container && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) { + scrollToSeeNew = true; + } this.handleScrolledToBottom(scrollToSeeNew); } }, @@ -1140,16 +1056,18 @@ export default { .paginate(EventTimeline.FORWARDS, 10, true) .then((success) => { if (success) { - this.scrollPosition.prepareFor("down"); this.events = this.timelineWindow.getEvents(); - this.$nextTick(() => { - // restore scroll position! - console.log("Restore scroll!"); - this.scrollPosition.restore(); - if (scrollToEnd) { - this.smoothScrollToEnd(); - } - }); + if (!this.useAudioLayout) { + this.scrollPosition.prepareFor("down"); + this.$nextTick(() => { + // restore scroll position! + console.log("Restore scroll!"); + this.scrollPosition.restore(); + if (scrollToEnd) { + this.smoothScrollToEnd(); + } + }); + } } }) .finally(() => { @@ -1162,7 +1080,7 @@ export default { * Scroll so that the given event is at the middle of the chat view (if more events) or else at the bottom. */ scrollToEvent(eventId) { - const container = this.$refs.chatContainer; + const container = this.chatContainer; const ref = this.$refs[eventId]; if (container && ref) { const targetY = container.clientHeight / 2; @@ -1172,9 +1090,9 @@ export default { }, smoothScrollToEnd() { - this.$nextTick(function() { - const container = this.$refs.chatContainer; - if (container.children.length > 0) { + this.$nextTick(function () { + const container = this.chatContainer; + if (container && container.children.length > 0) { const lastChild = container.children[container.children.length - 1]; console.log("Scroll into view", lastChild); window.requestAnimationFrame(() => { @@ -1251,7 +1169,7 @@ export default { document.body.appendChild(link); link.click(); - setTimeout(function() { + setTimeout(function () { document.body.removeChild(link); URL.revokeObjectURL(url); }, 200); @@ -1268,8 +1186,8 @@ export default { }, emojiSelected(e) { - if(this.isEmojiQuickReaction) { - // When quick emoji picker is clicked + if (this.isEmojiQuickReaction) { + // When quick emoji picker is clicked if (this.selectedEvent) { const event = this.selectedEvent; this.selectedEvent = null; @@ -1369,65 +1287,78 @@ export default { * Start/restart the timer to Read Receipts. */ restartRRTimer() { + console.log("Restart RR timer"); this.stopRRTimer(); - this.rrTimer = setTimeout(this.rrTimerElapsed, READ_RECEIPT_TIMEOUT); + + let eventIdFirst = null; + let eventIdLast = null; + if (!this.useAudioLayout) { + const container = this.chatContainer; + const elFirst = util.getFirstVisibleElement(container); + const elLast = util.getLastVisibleElement(container); + if (elFirst && elLast) { + eventIdFirst = elFirst.getAttribute("eventId"); + eventIdLast = elLast.getAttribute("eventId"); + } + } + if (eventIdFirst && eventIdLast) { + this.rrTimer = setTimeout(() => { this.rrTimerElapsed(eventIdFirst, eventIdLast) }, READ_RECEIPT_TIMEOUT); + } }, - rrTimerElapsed() { + rrTimerElapsed(eventIdFirst, eventIdLast) { + console.log("RR timer elapsed", eventIdFirst, eventIdLast); this.rrTimer = null; + this.sendRR(eventIdFirst, eventIdLast); + this.restartRRTimer(); + }, - const container = this.$refs.chatContainer; - const elFirst = util.getFirstVisibleElement(container); - const elLast = util.getLastVisibleElement(container); - if (elFirst && elLast) { - const eventIdFirst = elFirst.getAttribute("eventId"); - const eventIdLast = elLast.getAttribute("eventId"); - if (eventIdLast && this.room) { - var event = this.room.findEventById(eventIdLast); - const index = this.events.indexOf(event); + sendRR(eventIdFirst, eventIdLast) { + console.log("SEND RR", eventIdFirst, eventIdLast); + if (eventIdLast && this.room) { + var event = this.room.findEventById(eventIdLast); + const index = this.events.indexOf(event); - // Walk backwards through visible events to the first one that is incoming - // - var lastTimestamp = 0; - if (this.lastRR) { - lastTimestamp = this.lastRR.getTs(); + // Walk backwards through visible events to the first one that is incoming + // + var lastTimestamp = 0; + if (this.lastRR) { + lastTimestamp = this.lastRR.getTs(); + } + + for (var i = index; i >= 0; i--) { + event = this.events[i]; + if (event == this.lastRR || event.getTs() <= lastTimestamp) { + // Already sent this or too old... + break; + } + // Make sure it's not a local echo event... + if (!event.getId().startsWith("~")) { + // Send read receipt + this.$matrix.matrixClient + .sendReadReceipt(event) + .then(() => { + this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, event.getId()); + }) + .then(() => { + console.log("RR sent for event: " + event.getId()); + this.lastRR = event; + }) + .catch((err) => { + console.log("Failed to update read marker: ", err); + }) + .finally(() => { + this.restartRRTimer(); + }); + return; // Bail out here } - for (var i = index; i >= 0; i--) { - event = this.events[i]; - if (event == this.lastRR || event.getTs() <= lastTimestamp) { - // Already sent this or too old... - break; - } - // Make sure it's not a local echo event... - if (!event.getId().startsWith("~")) { - // Send read receipt - this.$matrix.matrixClient - .sendReadReceipt(event) - .then(() => { - this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, event.getId()); - }) - .then(() => { - console.log("RR sent for event: " + event.getId()); - this.lastRR = event; - }) - .catch((err) => { - console.log("Failed to update read marker: ", err); - }) - .finally(() => { - this.restartRRTimer(); - }); - return; // Bail out here - } - - // Stop iterating at first visible - if (event.getId() == eventIdFirst) { - break; - } + // Stop iterating at first visible + if (event.getId() == eventIdFirst) { + break; } } } - this.restartRRTimer(); }, showRecordingUI() { @@ -1499,9 +1430,9 @@ export default { let div = document.createElement("div"); div.classList.add("toast"); div.innerText = this.$t("poll_create.results_shared"); - this.$refs.chatContainer.parentElement.appendChild(div); + this.chatContainer.parentElement.appendChild(div); setTimeout(() => { - this.$refs.chatContainer.parentElement.removeChild(div); + this.chatContainer.parentElement.removeChild(div); }, 3000); } }, diff --git a/src/components/RoomExport.vue b/src/components/RoomExport.vue index 0010efb..7c082d3 100644 --- a/src/components/RoomExport.vue +++ b/src/components/RoomExport.vue @@ -31,7 +31,7 @@ + + {{ $t("room_info.members") }} @@ -319,7 +329,7 @@ export default { ], SHOW_MEMBER_LIMIT: 5, exporting: false, - }; + }; }, mounted() { this.$matrix.on("Room.timeline", this.onEvent); @@ -362,6 +372,26 @@ export default { } return ""; }, + + useAudioLayout: { + get: function () { + if (this.room) { + const tags = this.room.tags; + if (tags && tags["ui_options"]) { + return tags["ui_options"]["audio_layout"] === 1; + } + } + return false; + }, + set: function (audioLayout) { + if (this.room && this.room.tags) { + let options = this.room.tags["ui_options"] || {} + options["audio_layout"] = (audioLayout ? 1 : 0); + this.room.tags["ui_options"] = options; + this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options); + } + }, + } }, watch: { diff --git a/src/components/messages/AudioPlayer.vue b/src/components/messages/AudioPlayer.vue index 00157aa..b5c0815 100644 --- a/src/components/messages/AudioPlayer.vue +++ b/src/components/messages/AudioPlayer.vue @@ -54,9 +54,10 @@ export default { this.player.addEventListener("pause", () => { this.playing = false; }); - this.player.addEventListener("ended", function () { + this.player.addEventListener("ended", () => { this.pause(); this.playing = false; + this.$emit("playback-ended"); }); }, beforeDestroy() { diff --git a/src/components/messages/attachmentMixin.js b/src/components/messages/attachmentMixin.js index 6a5a1fa..06b39c5 100644 --- a/src/components/messages/attachmentMixin.js +++ b/src/components/messages/attachmentMixin.js @@ -7,25 +7,41 @@ export default { downloadProgress: null } }, - mounted() { - console.log("Mounted with event:", JSON.stringify(this.event.getContent())) - util - .getAttachment(this.$matrix.matrixClient, this.event, (progress) => { - this.downloadProgress = progress; - console.log("Progress: " + progress); - }) - .then((url) => { - this.src = url; - }) - .catch((err) => { - console.log("Failed to fetch attachment: ", err); - }); - }, - beforeDestroy() { - if (this.src) { - const objectUrl = this.src; - this.src = null; - URL.revokeObjectURL(objectUrl); + watch: { + event: { + immediate: false, + handler(value, ignoredOldValue) { + this.loadAttachmentSource(value); + } } }, + mounted() { + this.loadAttachmentSource(this.event); + }, + beforeDestroy() { + this.loadAttachmentSource(null); // Release + }, + methods: { + loadAttachmentSource(event) { + if (this.src) { + const objectUrl = this.src; + this.src = null; + URL.revokeObjectURL(objectUrl); + } + if (event) { + util + .getAttachment(this.$matrix.matrixClient, event, (progress) => { + this.downloadProgress = progress; + console.log("Progress: " + progress); + }) + .then((url) => { + this.src = url; + }) + .catch((err) => { + console.log("Failed to fetch attachment: ", err); + }); + + } + } + } } \ No newline at end of file diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index 7aea0ba..d36bbc5 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -1,51 +1,51 @@ -import QuickReactions from './QuickReactions.vue'; -var linkify = require('linkifyjs'); -var linkifyHtml = require('linkifyjs/html'); +import QuickReactions from "./QuickReactions.vue"; +var linkify = require("linkifyjs"); +var linkifyHtml = require("linkifyjs/html"); linkify.options.defaults.className = "link"; -linkify.options.defaults.target = { url: '_blank' }; +linkify.options.defaults.target = { url: "_blank" }; export default { components: { - QuickReactions + QuickReactions, }, props: { room: { type: Object, default: function () { - return null - } + return null; + }, }, - event: { + originalEvent: { type: Object, default: function () { - return {} - } + return {}; + }, }, nextEvent: { type: Object, default: function () { - return null - } + return null; + }, }, timelineSet: { type: Object, default: function () { - return null - } + return null; + }, }, }, data() { return { + event: {}, inReplyToEvent: null, - inReplyToSender: null - } + inReplyToSender: null, + }; }, mounted() { - const relatesTo = this.event.getWireContent()['m.relates_to']; - if (relatesTo && relatesTo['m.in_reply_to']) - { + const relatesTo = this.validEvent && this.event.getWireContent()["m.relates_to"]; + if (relatesTo && relatesTo["m.in_reply_to"]) { // Can we find the original message? - const originalEventId = relatesTo['m.in_reply_to'].event_id; + const originalEventId = relatesTo["m.in_reply_to"].event_id; if (originalEventId && this.timelineSet) { const originalEvent = this.timelineSet.findEventById(originalEventId); if (originalEvent) { @@ -55,7 +55,29 @@ export default { } } }, + watch: { + originalEvent: { + immediate: true, + handler(originalEvent, ignoredOldValue) { + this.event = originalEvent; + // Check not null and not {} + if (originalEvent && originalEvent.isBeingDecrypted && originalEvent.isBeingDecrypted()) { + this.originalEvent.getDecryptionPromise().then(() => { + this.event = originalEvent; + }); + } + }, + }, + }, computed: { + /** + * + * @returns true if event is non-null and contains data + */ + validEvent() { + return this.event && Object.keys(this.event).length !== 0; + }, + incoming() { return this.event && this.event.getSender() != this.$matrix.currentUserId; }, @@ -67,21 +89,20 @@ export default { if (this.nextEvent && this.nextEvent.getSender() == this.event.getSender()) { const ts1 = this.nextEvent.event.origin_server_ts; const ts2 = this.event.event.origin_server_ts; - return (ts1 - ts2) < (2 * 60 * 1000); // less than 2 minutes + return ts1 - ts2 < 2 * 60 * 1000; // less than 2 minutes } return true; }, inReplyToText() { - const relatesTo = this.event.getWireContent()['m.relates_to']; - if (relatesTo && relatesTo['m.in_reply_to']) - { + const relatesTo = this.event.getWireContent()["m.relates_to"]; + if (relatesTo && relatesTo["m.in_reply_to"]) { const content = this.event.getContent(); - const lines = content.body.split('\n').reverse(); - while (lines.length && !lines[0].startsWith('> ')) lines.shift(); + const lines = content.body.split("\n").reverse(); + while (lines.length && !lines[0].startsWith("> ")) lines.shift(); // Reply fallback has a blank line after it, so remove it to prevent leading newline - if (lines[0] === '') lines.shift(); - const text = lines[0] && lines[0].replace(/^> (<.*> )?/g, ''); + if (lines[0] === "") lines.shift(); + const text = lines[0] && lines[0].replace(/^> (<.*> )?/g, ""); if (text) { return text; } @@ -92,23 +113,22 @@ export default { } // We don't have the original text (at the moment at least) - return this.$t('fallbacks.original_text'); + return this.$t("fallbacks.original_text"); } return null; }, messageText() { - const relatesTo = this.event.getWireContent()['m.relates_to']; - if (relatesTo && relatesTo['m.in_reply_to']) - { + const relatesTo = this.event.getWireContent()["m.relates_to"]; + if (relatesTo && relatesTo["m.in_reply_to"]) { const content = this.event.getContent(); // Remove the new text and strip "> " from the old original text - const lines = content.body.split('\n'); - while (lines.length && lines[0].startsWith('> ')) lines.shift(); + const lines = content.body.split("\n"); + while (lines.length && lines[0].startsWith("> ")) lines.shift(); // Reply fallback has a blank line after it, so remove it to prevent leading newline - if (lines[0] === '') lines.shift(); - return lines.join('\n'); + if (lines[0] === "") lines.shift(); + return lines.join("\n"); } return this.event.getContent().body; }, @@ -118,40 +138,36 @@ export default { */ messageClasses() { - return {'messageIn':true,'from-admin':this.senderIsAdminOrModerator(this.event)} + return { messageIn: true, "from-admin": this.senderIsAdminOrModerator(this.event) }; }, userAvatar() { if (!this.$matrix.userAvatar) { return null; } - return this.$matrix.matrixClient.mxcUrlToHttp( - this.$matrix.userAvatar, - 80, - 80, - "scale", - true - ); + return this.$matrix.matrixClient.mxcUrlToHttp(this.$matrix.userAvatar, 80, 80, "scale", true); }, userAvatarLetter() { if (!this.$matrix.currentUser) { return null; } - return (this.$matrix.currentUserDisplayName || this.$matrix.currentUserId.substring(1)).substring(0, 1).toUpperCase(); - } + return (this.$matrix.currentUserDisplayName || this.$matrix.currentUserId.substring(1)) + .substring(0, 1) + .toUpperCase(); + }, }, methods: { ownAvatarClicked() { - this.$emit("own-avatar-clicked", {event: this.event}); + this.$emit("own-avatar-clicked", { event: this.event }); }, otherAvatarClicked(avatarRef) { - this.$emit("other-avatar-clicked", {event: this.event, anchor: avatarRef}); + this.$emit("other-avatar-clicked", { event: this.event, anchor: avatarRef }); }, showContextMenu(buttonRef) { - this.$emit("context-menu", {event: this.event,anchor: buttonRef}); + this.$emit("context-menu", { event: this.event, anchor: buttonRef }); }, /** @@ -159,7 +175,7 @@ export default { */ eventSenderDisplayName(event) { if (event.getSender() == this.$matrix.currentUserId) { - return this.$t('message.you'); + return this.$t("message.you"); } if (this.room) { const member = this.room.getMember(event.getSender()); @@ -173,12 +189,12 @@ export default { /** * In the case where the state_key points out a userId for an operation (e.g. membership events) * return the display name of the affected user. - * @param event - * @returns + * @param event + * @returns */ eventStateKeyDisplayName(event) { if (event.getStateKey() == this.$matrix.currentUserId) { - return this.$t('message.you'); + return this.$t("message.you"); } if (this.room) { const member = this.room.getMember(event.getStateKey()); @@ -193,13 +209,7 @@ export default { if (this.room) { const member = this.room.getMember(event.getSender()); if (member) { - return member.getAvatarUrl( - this.$matrix.matrixClient.getHomeserverUrl(), - 40, - 40, - "scale", - true - ); + return member.getAvatarUrl(this.$matrix.matrixClient.getHomeserverUrl(), 40, 40, "scale", true); } } return null; @@ -236,6 +246,6 @@ export default { linkify(text) { return linkifyHtml(text); - } + }, }, -} \ No newline at end of file +}; diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js index 2614652..2f802b3 100644 --- a/src/plugins/vuetify.js +++ b/src/plugins/vuetify.js @@ -1,37 +1,25 @@ import Vue from 'vue'; import Vuetify from 'vuetify/lib'; -import icUser from '@/assets/icons/user.vue'; -import icPassword from '@/assets/icons/password.vue'; -import icEdit from '@/assets/icons/edit.vue'; -import icGlobe from '@/assets/icons/globe.vue'; -import icAddReaction from '@/assets/icons/addReaction.vue'; -import icPoll from '@/assets/icons/poll.vue'; + +// Import all .vue icons and process them, so they can be used +// as $vuetify.icons. +var icons = {} +function importAll(r) { + return r.keys().map(res => { + // Remove"./" + const parts = res.split("/"); + const iconName = parts[1].split(".")[0]; + icons[iconName] = { component: r(res).default }; + }); +} +importAll(require.context('@/assets/icons/', true, /\.vue$/)); + Vue.use(Vuetify); export default new Vuetify({ icons: { iconfont: 'md', - values: { - user: { - component: icUser - }, - password: { - component: icPassword - }, - edit: { - component: icEdit - }, - globe: { - component: icGlobe - }, - addReaction: { - component: icAddReaction - }, - poll: { - component: icPoll - }, - }, - user: icUser + values: icons, }, }); From 22aacd22ab42975c4f2ffb43f2024cf1308587be Mon Sep 17 00:00:00 2001 From: N-Pex Date: Mon, 30 Jan 2023 11:51:26 +0100 Subject: [PATCH 06/17] Update linkify to fix errors in message handling --- package-lock.json | 125 +++++------------------- package.json | 3 +- src/components/messages/messageMixin.js | 4 +- 3 files changed, 29 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62053a7..15674cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "js-sha256": "^0.9.0", "json-web-key": "^0.4.0", "jszip": "^3.9.1", - "linkifyjs": "3.0.0-beta.3", + "linkify-html": "^4.1.0", + "linkifyjs": "^4.1.0", "material-design-icons-iconfont": "^6.1", "matrix-js-sdk": "^19.7.0", "md-gum-polyfill": "^1.0.0", @@ -9291,7 +9292,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "3.14.1", @@ -9713,19 +9715,19 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "node_modules/linkifyjs": { - "version": "3.0.0-beta.3", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-3.0.0-beta.3.tgz", - "integrity": "sha512-aXq4WJs91NsETo5f9dQrt8Vx+OxAvzJAtR8lLgpum8PDjtCgstycwYbIkAjDGRV/YF1LlKKdbWyOpgMYgwgOvQ==", - "engines": { - "node": ">=8" - }, + "node_modules/linkify-html": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/linkify-html/-/linkify-html-4.1.0.tgz", + "integrity": "sha512-cQSNN4i5V1xRjdSUEnXgn855xsl+usD7zBSsNyMSFBf4NlaZFocn7cExJA217azxODeqea79b6fDPXLa7jdkcA==", "peerDependencies": { - "jquery": ">= 1.11.0", - "react": ">= 0.14.0", - "react-dom": ">= 0.14.0" + "linkifyjs": "^4.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.0.tgz", + "integrity": "sha512-Ffv8VoY3+ixI1b3aZ3O+jM6x17cOsgwfB1Wq7pkytbo1WlyRp6ZO0YDMqiWT/gQPY/CmtiGuKfzDIVqxh1aCTA==" + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -10024,18 +10026,6 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -12378,31 +12368,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - }, - "peerDependencies": { - "react": "^18.2.0" - } - }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -13034,15 +12999,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "node_modules/schema-utils": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", @@ -23765,7 +23721,8 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "js-yaml": { "version": "3.14.1", @@ -24146,12 +24103,17 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, - "linkifyjs": { - "version": "3.0.0-beta.3", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-3.0.0-beta.3.tgz", - "integrity": "sha512-aXq4WJs91NsETo5f9dQrt8Vx+OxAvzJAtR8lLgpum8PDjtCgstycwYbIkAjDGRV/YF1LlKKdbWyOpgMYgwgOvQ==", + "linkify-html": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/linkify-html/-/linkify-html-4.1.0.tgz", + "integrity": "sha512-cQSNN4i5V1xRjdSUEnXgn855xsl+usD7zBSsNyMSFBf4NlaZFocn7cExJA217azxODeqea79b6fDPXLa7jdkcA==", "requires": {} }, + "linkifyjs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.0.tgz", + "integrity": "sha512-Ffv8VoY3+ixI1b3aZ3O+jM6x17cOsgwfB1Wq7pkytbo1WlyRp6ZO0YDMqiWT/gQPY/CmtiGuKfzDIVqxh1aCTA==" + }, "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -24384,15 +24346,6 @@ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.7.1.tgz", "integrity": "sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==" }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, "lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -26158,25 +26111,6 @@ "schema-utils": "^3.0.0" } }, - "react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0" - } - }, - "react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" - } - }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -26651,15 +26585,6 @@ } } }, - "scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "peer": true, - "requires": { - "loose-envify": "^1.1.0" - } - }, "schema-utils": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", diff --git a/package.json b/package.json index a458fb8..380654a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "js-sha256": "^0.9.0", "json-web-key": "^0.4.0", "jszip": "^3.9.1", - "linkifyjs": "3.0.0-beta.3", + "linkify-html": "^4.1.0", + "linkifyjs": "^4.1.0", "material-design-icons-iconfont": "^6.1", "matrix-js-sdk": "^19.7.0", "md-gum-polyfill": "^1.0.0", diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index d36bbc5..e1c9046 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -1,6 +1,6 @@ import QuickReactions from "./QuickReactions.vue"; -var linkify = require("linkifyjs"); -var linkifyHtml = require("linkifyjs/html"); +import * as linkify from 'linkifyjs'; +import linkifyHtml from 'linkify-html'; linkify.options.defaults.className = "link"; linkify.options.defaults.target = { url: "_blank" }; From f34144557ab3ff73ba80f583b1716e424a3b2c58 Mon Sep 17 00:00:00 2001 From: N Pex Date: Tue, 31 Jan 2023 09:39:30 +0000 Subject: [PATCH 07/17] Room purge fixes --- src/components/Chat.vue | 4 - src/services/matrix.service.js | 269 ++++++++++++--------------------- 2 files changed, 93 insertions(+), 180 deletions(-) diff --git a/src/components/Chat.vue b/src/components/Chat.vue index acecb6c..0469baa 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -1018,7 +1018,6 @@ export default { }, handleScrolledToTop() { - console.log("@top"); if ( this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.BACKWARDS) && @@ -1045,7 +1044,6 @@ export default { }, handleScrolledToBottom(scrollToEnd) { - console.log("@bottom"); if ( this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS) && @@ -1287,7 +1285,6 @@ export default { * Start/restart the timer to Read Receipts. */ restartRRTimer() { - console.log("Restart RR timer"); this.stopRRTimer(); let eventIdFirst = null; @@ -1307,7 +1304,6 @@ export default { }, rrTimerElapsed(eventIdFirst, eventIdLast) { - console.log("RR timer elapsed", eventIdFirst, eventIdLast); this.rrTimer = null; this.sendRR(eventIdFirst, eventIdLast); this.restartRRTimer(); diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index ffe78fd..4c1a592 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -5,8 +5,8 @@ import { TimelineWindow, EventTimeline } from "matrix-js-sdk"; import util from "../plugins/utils"; import User from "../models/user"; -const LocalStorageCryptoStore = require("matrix-js-sdk/lib/crypto/store/localStorage-crypto-store") - .LocalStorageCryptoStore; +const LocalStorageCryptoStore = + require("matrix-js-sdk/lib/crypto/store/localStorage-crypto-store").LocalStorageCryptoStore; export default { install(Vue, options) { @@ -67,7 +67,7 @@ export default { }, currentUserHomeServer() { - return this.$config.homeServer ? this.$config.homeServer : User.serverName(this.currentUserId); + return this.$config.homeServer ? this.$config.homeServer : User.serverName(this.currentUserId); }, currentRoomId() { @@ -141,18 +141,16 @@ export default { if (user.device_id) { data.device_id = user.device_id; } - promiseLogin = tempMatrixClient - .login("m.login.password", data) - .then((response) => { - var u = Object.assign({}, response); - if (user.is_guest) { - // Copy over needed properties - u = Object.assign(user, response); - } - u.home_server = tempMatrixClient.baseUrl; // Don't use deprecated field from response. - this.$store.commit("setUser", u); - return u; - }); + promiseLogin = tempMatrixClient.login("m.login.password", data).then((response) => { + var u = Object.assign({}, response); + if (user.is_guest) { + // Copy over needed properties + u = Object.assign(user, response); + } + u.home_server = tempMatrixClient.baseUrl; // Don't use deprecated field from response. + this.$store.commit("setUser", u); + return u; + }); } return promiseLogin.then((user) => { @@ -261,11 +259,7 @@ export default { return matrixClient; } else { return new Promise((resolve, reject) => { - matrixClient.once("sync", function( - state, - ignoredprevState, - ignoredres - ) { + matrixClient.once("sync", function (state, ignoredprevState, ignoredres) { console.log(state); // state will be 'PREPARED' when the client is ready to use if (state == "PREPARED") { resolve(matrixClient); @@ -293,11 +287,7 @@ export default { if (this.ready) { return Promise.resolve(this.currentUser); } - return this.$store.dispatch( - "login", - this.currentUser || - new User(this.$config.defaultServer, "", "", true) - ); + return this.$store.dispatch("login", this.currentUser || new User(this.$config.defaultServer, "", "", true)); }, addMatrixClientListeners(client) { @@ -337,13 +327,7 @@ export default { Vue.set( room, "avatar", - room.getAvatarUrl( - this.matrixClient.getHomeserverUrl(), - 80, - 80, - "scale", - true - ) + room.getAvatarUrl(this.matrixClient.getHomeserverUrl(), 80, 80, "scale", true) ); } } @@ -351,26 +335,20 @@ export default { case "m.room.member": { - if ( - this.currentRoom && - event.getRoomId() == this.currentRoom.roomId - ) { + if (this.currentRoom && event.getRoomId() == this.currentRoom.roomId) { // Don't use this.currentRoomId, may be an alias. We need the real id! - if (( - event.getContent().membership == "leave" && - (event.getPrevContent() || {}).membership == "join" && - event.getStateKey() == this.currentUserId && - event.getSender() != this.currentUserId - ) || (event.getContent().membership == "ban" && event.getStateKey() == this.currentUserId)) { + if ( + (event.getContent().membership == "leave" && + (event.getPrevContent() || {}).membership == "join" && + event.getStateKey() == this.currentUserId && + event.getSender() != this.currentUserId) || + (event.getContent().membership == "ban" && event.getStateKey() == this.currentUserId) + ) { // We were kicked or banned // If this is a live event (not just backpaging) then redirect to goodbye! if (this.matrixClientReady) { - const wasPurged = - event.getContent().reason == "Room Deleted"; - this.$navigation.push( - { name: "Goodbye", params: { roomWasPurged: wasPurged } }, - -1 - ); + const wasPurged = event.getContent().reason == "Room Deleted"; + this.$navigation.push({ name: "Goodbye", params: { roomWasPurged: wasPurged } }, -1); } } } @@ -414,15 +392,15 @@ export default { this.$store.commit("setUser", user); // Login again - this.login(user).catch(error => { - if (error.data.errcode ==='M_FORBIDDEN' && this.currentUser.is_guest) { + this.login(user).catch((error) => { + if (error.data.errcode === "M_FORBIDDEN" && this.currentUser.is_guest) { // Guest account and password don't work. We are in a strange state, probably because // of server cleanup of accounts or similar. Wipe account and restart... this.$store.commit("setUser", null); } this.$store.commit("setCurrentRoomId", null); this.$navigation.push({ path: "/login" }, -1); - }) + }); } else { this.$store.commit("setUser", null); this.$store.commit("setCurrentRoomId", null); @@ -435,24 +413,11 @@ export default { // each time! var updatedRooms = this.matrixClient.getVisibleRooms(); updatedRooms = updatedRooms.filter((room) => { - return ( - room.selfMembership && - (room.selfMembership == "invite" || room.selfMembership == "join") - ); + return room.selfMembership && (room.selfMembership == "invite" || room.selfMembership == "join"); }); updatedRooms.forEach((room) => { if (!room.avatar) { - Vue.set( - room, - "avatar", - room.getAvatarUrl( - this.matrixClient.getHomeserverUrl(), - 80, - 80, - "scale", - true - ) - ); + Vue.set(room, "avatar", room.getAvatarUrl(this.matrixClient.getHomeserverUrl(), 80, 80, "scale", true)); } }); Vue.set(this, "rooms", updatedRooms); @@ -491,15 +456,9 @@ export default { var ids = {}; const ret = []; for (const room of this.rooms) { - if ( - room.selfMembership == "join" && - this.getRoomJoinRule(room) == "invite" - ) { + if (room.selfMembership == "join" && this.getRoomJoinRule(room) == "invite") { for (const member of room.getJoinedMembers()) { - if ( - member.userId != this.currentUserId && - !ids[member.userId] - ) { + if (member.userId != this.currentUserId && !ids[member.userId]) { ids[member.userId] = member; ret.push(member); } @@ -516,10 +475,7 @@ export default { getRoomJoinRule(room) { if (room) { - const joinRules = room.currentState.getStateEvents( - "m.room.join_rules", - "" - ); + const joinRules = room.currentState.getStateEvents("m.room.join_rules", ""); return joinRules && joinRules.getContent().join_rule; } return null; @@ -527,14 +483,8 @@ export default { getRoomHistoryVisibility(room) { if (room) { - const historyVisibility = room.currentState.getStateEvents( - "m.room.history_visibility", - "" - ); - return ( - historyVisibility && - historyVisibility.getContent().history_visibility - ); + const historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); + return historyVisibility && historyVisibility.getContent().history_visibility; } return null; }, @@ -551,21 +501,13 @@ export default { kickUser(roomId, userId) { if (this.matrixClient && roomId && userId) { - this.matrixClient.kick( - roomId, - userId, - "" - ) + this.matrixClient.kick(roomId, userId, ""); } }, banUser(roomId, userId) { if (this.matrixClient && roomId && userId) { - this.matrixClient.ban( - roomId, - userId, - "" - ) + this.matrixClient.ban(roomId, userId, ""); } }, @@ -577,20 +519,20 @@ export default { if (powerLevelEvent) { this.matrixClient.setPowerLevel(roomId, userId, 100, powerLevelEvent); } - } + } } }, makeModerator(roomId, userId) { if (this.matrixClient && roomId && userId) { const room = this.getRoom(roomId); - console.log("Room", room); + console.log("Room", room); if (room && room.currentState) { const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); if (powerLevelEvent) { this.matrixClient.setPowerLevel(roomId, userId, 50, powerLevelEvent); } - } + } } }, @@ -602,7 +544,7 @@ export default { if (powerLevelEvent) { this.matrixClient.setPowerLevel(roomId, userId, 0, powerLevelEvent); } - } + } } }, @@ -624,22 +566,13 @@ export default { return; } - const timelineWindow = new TimelineWindow( - this.matrixClient, - room.getUnfilteredTimelineSet(), - {} - ); + const timelineWindow = new TimelineWindow(this.matrixClient, room.getUnfilteredTimelineSet(), {}); const self = this; //console.log("Purge: set invite only"); statusCallback(this.$t("room.purge_set_room_state")); this.matrixClient - .sendStateEvent( - roomId, - "m.room.join_rules", - { join_rule: "invite" }, - "" - ) + .sendStateEvent(roomId, "m.room.join_rules", { join_rule: "invite" }, "") .then(() => { //console.log("Purge: forbid guest access"); return this.matrixClient.sendStateEvent( @@ -651,13 +584,9 @@ export default { }) .then(() => { //console.log("Purge: set history visibility to 'joined'"); - return this.matrixClient.sendStateEvent( - roomId, - "m.room.history_visibility", - { - history_visibility: "joined", - } - ); + return this.matrixClient.sendStateEvent(roomId, "m.room.history_visibility", { + history_visibility: "joined", + }); }) .then(() => { //console.log("Purge: create timeline"); @@ -667,11 +596,12 @@ export default { const getMoreIfAvailable = function _getMoreIfAvailable() { if (timelineWindow.canPaginate(EventTimeline.BACKWARDS)) { //console.log("Purge: page back"); - return timelineWindow - .paginate(EventTimeline.BACKWARDS, 100, true, 5) - .then((ignoredsuccess) => { + return timelineWindow.paginate(EventTimeline.BACKWARDS, 100, true, 5).then((gotmore) => { + if (gotmore) { return _getMoreIfAvailable.call(self); - }); + } + return Promise.resolve("Done"); + }); } else { return Promise.resolve("Done"); } @@ -685,18 +615,9 @@ export default { this.matrixClient.setGlobalErrorOnUnknownDevices(false); var redactionPromises = []; timelineWindow.getEvents().forEach((event) => { - if ( - !event.isRedacted() && - !event.isRedaction() && - !event.isState() - ) { + if (!event.isRedacted() && !event.isRedaction() && !event.isState()) { // Redact! - redactionPromises.push( - this.matrixClient.redactEvent( - event.getRoomId(), - event.getId() - ) - ); + redactionPromises.push(this.matrixClient.redactEvent(event.getRoomId(), event.getId())); } }); return Promise.all(redactionPromises); @@ -706,27 +627,44 @@ export default { statusCallback(this.$t("room.purge_removing_members")); var joined = room.getMembersWithMembership("join"); var invited = room.getMembersWithMembership("invite"); - var members = joined.concat(invited); + var allMembers = joined.concat(invited); + + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } - var kickPromises = []; - members.forEach((member) => { - if (member.userId != self.currentUserId) { - kickPromises.push( - this.matrixClient.kick( - roomId, - member.userId, - "Room Deleted" - ) - ); + const kickFirstMember = (members) => { + //console.log(`Kicking ${members.length} members`); + if (members.length == 0) { + return Promise.resolve(true); } - }); - return Promise.all(kickPromises); + const member = members[0]; + if (member.userId == self.currentUserId) { + return kickFirstMember(members.slice(1)); + } else { + // Slight pause to avoid rate limiting. + return sleep(0.1) + .then(() => this.matrixClient.kick(roomId, member.userId, "Room Deleted")) + .catch((error) => { + if (error && error.errcode == "M_LIMIT_EXCEEDED") { + var retry = 1000; + if (error.data) { + const retryIn = error.data.retry_after_ms; + retry = Math.max(retry, retryIn ? retryIn : 0); + } + //console.log("Rate limited, retry in", retry); + return sleep(retry).then(() => kickFirstMember(members)); + } + }) + .finally(() => kickFirstMember(members.slice(1))) + } + }; + + return kickFirstMember(allMembers); }) .then(() => { statusCallback(null); - this.matrixClient.setGlobalErrorOnUnknownDevices( - oldGlobalErrorSetting - ); + this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); return this.leaveRoom(roomId); }) .then(() => { @@ -734,9 +672,7 @@ export default { }) .catch((err) => { console.error("Error purging room", err); - this.matrixClient.setGlobalErrorOnUnknownDevices( - oldGlobalErrorSetting - ); + this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); reject(err); }); }); @@ -814,10 +750,7 @@ export default { * @param {*} userId */ isDirectRoomWith(room, userId) { - if ( - room.getJoinRule() == "invite" && - room.getMembers().length == 2 - ) { + if (room.getJoinRule() == "invite" && room.getMembers().length == 2) { let other = room.getMember(userId); if (other) { if (room.getMyMembership() == "invite" && other.membership == "join") { @@ -889,9 +822,7 @@ export default { return this.matrixClient; }); } else { - const tempMatrixClient = sdk.createClient( - this.$config.defaultServer - ); + const tempMatrixClient = sdk.createClient(this.$config.defaultServer); var tempUserString = this.$store.state.tempuser; var tempUser = null; if (tempUserString) { @@ -971,13 +902,7 @@ export default { }) .then((response) => { if (response.avatar_url) { - response.avatar = matrixClient.mxcUrlToHttp( - response.avatar_url, - 80, - 80, - "scale", - true - ); + response.avatar = matrixClient.mxcUrlToHttp(response.avatar_url, 80, 80, "scale", true); } return Promise.resolve(response); }) @@ -1006,13 +931,7 @@ export default { (roomId.startsWith("!") && room.room_id == roomId) ) { if (room.avatar_url) { - room.avatar = client.mxcUrlToHttp( - room.avatar_url, - 80, - 80, - "scale", - true - ); + room.avatar = client.mxcUrlToHttp(room.avatar_url, 80, 80, "scale", true); } return Promise.resolve(room); } @@ -1059,9 +978,7 @@ export default { }, }); - sdk.setCryptoStoreFactory( - matrixService.createCryptoStore.bind(matrixService) - ); + sdk.setCryptoStoreFactory(matrixService.createCryptoStore.bind(matrixService)); Vue.prototype.$matrix = matrixService; }, From 434c0fb48cf3dfc043ebd53cbec737ad2770881d Mon Sep 17 00:00:00 2001 From: N-Pex Date: Wed, 8 Feb 2023 11:22:12 +0100 Subject: [PATCH 08/17] Make sure to handle 429:s (rate limiting) on message redactions and final room leave! --- src/assets/translations/en.json | 4 +- src/components/Chat.vue | 4 ++ src/services/matrix.service.js | 84 +++++++++++++++++++++++---------- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 0110511..fa3116e 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -89,8 +89,8 @@ "members": "no members | 1 member | {count} members", "leave": "Leave", "purge_set_room_state": "Setting room state", - "purge_redacting_events": "Redacting events", - "purge_removing_members": "Removing members", + "purge_redacting_events": "Redacting events ({count} of {total})", + "purge_removing_members": "Removing members ({count} of {total})", "purge_failed": "Failed to purge room!", "room_list_invites": "Invites", "room_list_rooms": "Rooms", diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 0469baa..4d85aea 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -1287,6 +1287,10 @@ export default { restartRRTimer() { this.stopRRTimer(); + if (this.$matrix.currentRoomBeingPurged) { + return; + } + let eventIdFirst = null; let eventIdLast = null; if (!this.useAudioLayout) { diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 4c1a592..7411211 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -37,6 +37,7 @@ export default { userDisplayName: null, userAvatar: null, currentRoom: null, + currentRoomBeingPurged: false, notificationCount: 0, }; }, @@ -558,6 +559,28 @@ export default { * @param roomId */ purgeRoom(roomId, statusCallback) { + this.currentRoomBeingPurged = true; + + const sleep = (ms) => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }; + + const withRetry = (codeBlock) => { + return codeBlock().catch((error) => { + if (error && error.errcode == "M_LIMIT_EXCEEDED") { + var retry = 1000; + if (error.data) { + const retryIn = error.data.retry_after_ms; + retry = Math.max(retry, retryIn ? retryIn : 0); + } + console.log("Rate limited, retry in", retry); + return sleep(retry).then(() => withRetry(codeBlock)); + } else { + return Promise.resolve(); + } + }); + }; + const oldGlobalErrorSetting = this.matrixClient.getGlobalErrorOnUnknownDevices(); return new Promise((resolve, reject) => { const room = this.getRoom(roomId); @@ -613,14 +636,32 @@ export default { statusCallback(this.$t("room.purge_redacting_events")); // First ignore unknown device errors this.matrixClient.setGlobalErrorOnUnknownDevices(false); - var redactionPromises = []; - timelineWindow.getEvents().forEach((event) => { - if (!event.isRedacted() && !event.isRedaction() && !event.isState()) { - // Redact! - redactionPromises.push(this.matrixClient.redactEvent(event.getRoomId(), event.getId())); - } + const allEvents = timelineWindow.getEvents().filter((event) => { + return ( + !event.isRedacted() && + !event.isRedaction() && + !event.isState() && + (!room.currentState || room.currentState.maySendRedactionForEvent(event, this.currentUserId)) + ); }); - return Promise.all(redactionPromises); + + const redactFirstEvent = (events) => { + statusCallback( + this.$t("room.purge_redacting_events", { + count: allEvents.length - events.length + 1, + total: allEvents.length, + }) + ); + if (events.length == 0) { + return Promise.resolve(true); + } + const event = events[0]; + return withRetry(() => this.matrixClient.redactEvent(event.getRoomId(), event.getId())).then(() => + redactFirstEvent(events.slice(1)) + ); + }; + + return redactFirstEvent(allEvents); }) .then(() => { //console.log("Purge: kick members"); @@ -628,13 +669,15 @@ export default { var joined = room.getMembersWithMembership("join"); var invited = room.getMembersWithMembership("invite"); var allMembers = joined.concat(invited); - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } const kickFirstMember = (members) => { //console.log(`Kicking ${members.length} members`); + statusCallback( + this.$t("room.purge_removing_members", { + count: allMembers.length - members.length + 1, + total: allMembers.length, + }) + ); if (members.length == 0) { return Promise.resolve(true); } @@ -644,19 +687,8 @@ export default { } else { // Slight pause to avoid rate limiting. return sleep(0.1) - .then(() => this.matrixClient.kick(roomId, member.userId, "Room Deleted")) - .catch((error) => { - if (error && error.errcode == "M_LIMIT_EXCEEDED") { - var retry = 1000; - if (error.data) { - const retryIn = error.data.retry_after_ms; - retry = Math.max(retry, retryIn ? retryIn : 0); - } - //console.log("Rate limited, retry in", retry); - return sleep(retry).then(() => kickFirstMember(members)); - } - }) - .finally(() => kickFirstMember(members.slice(1))) + .then(() => withRetry(() => this.matrixClient.kick(roomId, member.userId, "Room Deleted"))) + .then(() => kickFirstMember(members.slice(1))); } }; @@ -665,13 +697,15 @@ export default { .then(() => { statusCallback(null); this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); - return this.leaveRoom(roomId); + return withRetry(() => this.leaveRoom(roomId)); }) .then(() => { + this.currentRoomBeingPurged = false; resolve(true); // Done! }) .catch((err) => { console.error("Error purging room", err); + this.currentRoomBeingPurged = false; this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting); reject(err); }); From 7ac777d9716d9b93f6390fcde2fb22267bc275cd Mon Sep 17 00:00:00 2001 From: N Pex Date: Wed, 8 Feb 2023 14:02:08 +0000 Subject: [PATCH 09/17] When sending reaction, redact if already send --- src/components/Chat.vue | 23 ++++++++++++++++++++-- src/components/messages/QuickReactions.vue | 17 ++++++++++++++-- src/main.js | 12 +++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 4d85aea..e3e48c2 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -55,7 +55,7 @@ } " v-on:touchend="touchEnd" v-on:touchcancel="touchCancel" v-on:touchmove="touchMove"> @@ -510,7 +510,7 @@ export default { return util.browserCanRecordAudio(); }, debugging() { - return (window.location.host || "").startsWith("localhost"); + return false; //(window.location.host || "").startsWith("localhost"); }, invitationCount() { return this.$matrix.invites.length; @@ -1202,6 +1202,24 @@ export default { }, sendQuickReaction(e) { + let previousReaction = null; + + // Figure out if we have already sent this emoji, in that case redact it again (toggle) + // + const reactions = this.timelineSet.relations.getChildEventsForEvent(e.event.getId(), 'm.annotation', 'm.reaction'); + if (reactions && reactions._eventsCount > 0) { + const relations = reactions.getRelations(); + for (const r of relations) { + const emoji = r.getRelation().key; + const sender = r.getSender(); + if (emoji == e.reaction && sender == this.$matrix.currentUserId) { + previousReaction = r.isRedacted() ? null : r; + } + } + } + if (previousReaction) { + this.redact(previousReaction); + } else { util .sendQuickReaction(this.$matrix.matrixClient, this.roomId, e.reaction, e.event) .then(() => { @@ -1210,6 +1228,7 @@ export default { .catch((err) => { console.log("Failed to send quick reaction:", err); }); + } }, sendSticker(stickerShortCode) { diff --git a/src/components/messages/QuickReactions.vue b/src/components/messages/QuickReactions.vue index baa69ce..63dc6be 100644 --- a/src/components/messages/QuickReactions.vue +++ b/src/components/messages/QuickReactions.vue @@ -1,6 +1,6 @@