From 0d1ac1d44145191efdec1ff5cf0e98eee6d357ce Mon Sep 17 00:00:00 2001 From: N Pex Date: Tue, 3 May 2022 09:40:02 +0000 Subject: [PATCH] Support for polls (can be created by room admins) --- src/assets/css/_variables.scss | 4 +- src/assets/css/components/_poll.scss | 61 ++++++ src/assets/translations/en.json | 20 ++ src/components/Chat.vue | 49 ++++- src/components/CreatePollDialog.vue | 195 ++++++++++++++++++ src/components/RoomInfo.vue | 2 +- .../messages/MessageIncomingPoll.vue | 60 ++++++ .../messages/MessageOutgoingPoll.vue | 60 ++++++ src/components/messages/pollMixin.js | 185 +++++++++++++++++ src/plugins/utils.js | 45 ++++ src/services/matrix.service.js | 1 + 11 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 src/assets/css/components/_poll.scss create mode 100644 src/components/CreatePollDialog.vue create mode 100644 src/components/messages/MessageIncomingPoll.vue create mode 100644 src/components/messages/MessageOutgoingPoll.vue create mode 100644 src/components/messages/pollMixin.js diff --git a/src/assets/css/_variables.scss b/src/assets/css/_variables.scss index 8e3f8cb..fad1a0c 100644 --- a/src/assets/css/_variables.scss +++ b/src/assets/css/_variables.scss @@ -14,4 +14,6 @@ $chat-button-height: 50px; $voice-recorder-color: #6f6f6f; $voice-recording-color: red; -$voice-recorded-color: #3ae17d; \ No newline at end of file +$voice-recorded-color: #3ae17d; + +$poll-hilite-color: $very-very-purple; \ No newline at end of file diff --git a/src/assets/css/components/_poll.scss b/src/assets/css/components/_poll.scss new file mode 100644 index 0000000..f809e9b --- /dev/null +++ b/src/assets/css/components/_poll.scss @@ -0,0 +1,61 @@ +.poll-bubble { + width: 70%; +} + +.poll-answer { + border: 1px solid #666; + border-radius: 5px; + padding: 10px; + margin: 10px; + &.selected { + border: 1px solid $poll-hilite-color; + } + .poll-answer-title { + color: #444; + } + .poll-answer-num-votes { + font-size: 0.7rem; + } + justify-content: space-between; + position: relative; +} + +.poll-percent-indicator { + position: absolute; + bottom: 2px; + left: 2px; + right: 2px; + height: 4px; + .bar { + background-color: $poll-hilite-color; + position: absolute; + bottom: 0px; + left: 0px; + top: 0px; + border-radius: 3px; + } +} + +.poll-status { + margin: 10px; + justify-content: space-between; + .poll-status-title { + font-size: 0.7rem; + } + + .poll-status-close { + font-size: 0.7rem; + color: $poll-hilite-color; + } +} + +// Creation dialog +// +.poll-create-dialog-content { + max-height: 50vh; + overflow-x: hidden; + overflow-y: auto; +} +.poll-create-status { + font-size: 0.7rem; +} diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 574c776..5ba50a9 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -233,5 +233,25 @@ "video_file": "Video file", "original_text": "", "download_name": "Download" + }, + "poll_create": { + "title": "Create poll", + "intro": "Please fill in details below.", + "create": "Create", + "creating": "Creating poll", + "poll_disclosed": "Open - current results are shown at all times.", + "poll_undisclosed": "Closed - users will see the results when poll is closed.", + "add_answer": "Add answer", + "failed": "Failed to create poll, please try again later.", + "question_label": "Type your question here", + "question_required": "You need to enter a question!", + "answer_label": "Answer no {index}", + "answer_required": "Answer can't be empty. Please enter some text or remove this option.", + "create_poll_menu_option": "Create poll", + "poll_status_closed": "Poll is closed", + "poll_status_disclosed": "Results will be shown when poll is closed.", + "poll_status_open": "Poll is open", + "poll_status_open_not_voted": "Poll is open - vote to see the results", + "close_poll": "Close poll" } } \ No newline at end of file diff --git a/src/components/Chat.vue b/src/components/Chat.vue index a86caf2..bc4cdbf 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -73,9 +73,7 @@ />
+ + + + + + {{ $t("poll_create.create_poll_menu_option") }} + + + + +
@@ -470,12 +490,14 @@ import MessageIncomingImage from "./messages/MessageIncomingImage.vue"; import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue"; import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue"; import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue"; +import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue"; import MessageOutgoingText from "./messages/MessageOutgoingText"; import MessageOutgoingFile from "./messages/MessageOutgoingFile"; import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue"; import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue"; import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue"; import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue"; +import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.vue"; import ContactJoin from "./messages/ContactJoin.vue"; import ContactLeave from "./messages/ContactLeave.vue"; import ContactInvited from "./messages/ContactInvited.vue"; @@ -505,6 +527,8 @@ import stickers from "../plugins/stickers"; import StickerPickerBottomSheet from "./StickerPickerBottomSheet"; import BottomSheet from "./BottomSheet.vue"; import ImageResize from "image-resize"; +import CreatePollDialog from "./CreatePollDialog.vue"; + const sizeOf = require("image-size"); const dataUriToBuffer = require("data-uri-to-buffer"); const prettyBytes = require("pretty-bytes"); @@ -555,6 +579,7 @@ export default { MessageOutgoingAudio, MessageOutgoingVideo, MessageOutgoingSticker, + MessageOutgoingPoll, ContactJoin, ContactLeave, ContactInvited, @@ -580,6 +605,7 @@ export default { StickerPickerBottomSheet, BottomSheet, AvatarOperations, + CreatePollDialog }, data() { @@ -606,6 +632,7 @@ export default { replyToEvent: null, replyToImg: null, replyToContentType: null, + showCreatePollDialog: false, showNoRecordingAvailableDialog: false, showContextMenu: false, showContextMenuAnchor: null, @@ -765,6 +792,12 @@ export default { }, invitationCount() { return this.$matrix.invites.length; + }, + canCreatePoll() { + // We say that if you can redact events, you are allowed to create polls. + const me = this.room && this.room.getMember(this.$matrix.currentUserId); + let isAdmin = me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel); + return isAdmin; } }, @@ -1133,6 +1166,14 @@ export default { case "m.room.encryption": return RoomEncrypted; + case "m.poll.start": + case "org.matrix.msc3381.poll.start": + if (event.getSender() != this.$matrix.currentUserId) { + return MessageIncomingPoll; + } else { + return MessageOutgoingPoll; + } + case "im.keanu.room_deletion_notice": { // Custom event for notice 30 seconds before a room is deleted/purged. const deletionNotices = this.room.currentState.getStateEvents( @@ -1815,7 +1856,7 @@ export default { }, onInvitationsClick() { this.$navigation.push({ name: "Home" }, -1); - } + }, }, }; diff --git a/src/components/CreatePollDialog.vue b/src/components/CreatePollDialog.vue new file mode 100644 index 0000000..8decf7b --- /dev/null +++ b/src/components/CreatePollDialog.vue @@ -0,0 +1,195 @@ + + + + diff --git a/src/components/RoomInfo.vue b/src/components/RoomInfo.vue index 556907b..1cf74cb 100644 --- a/src/components/RoomInfo.vue +++ b/src/components/RoomInfo.vue @@ -153,7 +153,7 @@ }) }} -
Start private chat
+
{{ $t("menu.start_private_chat") }}
+ +
+ {{ pollQuestion }} + + + {{ answer.text }} + {{ answer.numVotes }} +
+
+
+
+ + {{ + pollIsClosed + ? $t("poll_create.poll_status_closed") + : pollIsDisclosed + ? userHasVoted + ? $t("poll_create.poll_status_open") + : $t("poll_create.poll_status_open_not_voted") + : $t("poll_create.poll_status_disclosed") + }} + {{ $t("poll_create.close_poll") }} + +
+
+
+ + + + + diff --git a/src/components/messages/MessageOutgoingPoll.vue b/src/components/messages/MessageOutgoingPoll.vue new file mode 100644 index 0000000..300698b --- /dev/null +++ b/src/components/messages/MessageOutgoingPoll.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/messages/pollMixin.js b/src/components/messages/pollMixin.js new file mode 100644 index 0000000..a978bd1 --- /dev/null +++ b/src/components/messages/pollMixin.js @@ -0,0 +1,185 @@ +import util from "../../plugins/utils"; + +export default { + data() { + return { + pollQuestion: "", + pollAnswers: [], + pollResponseRelations: null, + pollEndRelations: null, + pollEndTs: null, + pollIsDisclosed: true, + } + }, + mounted() { + this.$matrix.on("Room.timeline", this.pollMixinOnEvent); + this.pollQuestion = (this.event && this.event.getContent()["org.matrix.msc3381.poll.start"]["question"]["body"]) || ""; + this.updateAnswers(); + }, + destroyed() { + this.$matrix.off("Room.timeline", this.pollMixinOnEvent); + }, + beforeDestroy() { + if (this.pollResponseRelations) { + this.pollResponseRelations.off('Relations.add', this.onAddRelation); + this.pollResponseRelations = null; + } + if (this.pollEndRelations) { + this.pollEndRelations.off('Relations.add', this.onAddRelation); + this.pollEndRelations = null; + } + }, + methods: { + updateAnswers() { + let answers = (this.event && this.event.getContent()["org.matrix.msc3381.poll.start"]["answers"]) || []; + var answerMap = {}; + var answerArray = []; + answers.forEach(a => { + let text = a["org.matrix.msc1767.text"]; + let answer = {id: a.id, text: text, numVotes: 0, percentage: 0} + answerMap[a.id] = answer; + answerArray.push(answer); + }); + + // Kind of poll + this.pollIsDisclosed = (this.event && this.event.getContent()["org.matrix.msc3381.poll.start"]["kind"] != "org.matrix.msc3381.poll.undisclosed") || false; + + // Look for poll end + this.pollEndRelations = this.timelineSet.getRelationsForEvent( + this.event.getId(), + 'm.reference', + 'org.matrix.msc3381.poll.end' + ); + if (this.pollEndRelations) { + const endMessages = this.pollEndRelations.getRelations() || []; + if (endMessages.length > 0) { + this.pollEndTs = endMessages[endMessages.length - 1].getTs(); + } + } + + // Process votes + this.pollResponseRelations = this.timelineSet.getRelationsForEvent( + this.event.getId(), + 'm.reference', + 'org.matrix.msc3381.poll.response' + ); + var userVotes = {}; + if (this.pollResponseRelations) { + const votes = this.pollResponseRelations.getRelations() || []; + for (const vote of votes) { + //const emoji = r.getRelation().key; + if (this.pollEndTs && vote.getTs() > this.pollEndTs) { + continue; // Invalid vote, after poll was closed. + } + const sender = vote.getSender(); + const answersFromThisUser = vote.getContent()["org.matrix.msc3381.poll.response"]["answers"] || []; + if (answersFromThisUser.length == 0) { + delete userVotes[sender]; + } else { + userVotes[sender] = answersFromThisUser; + } + } + } + var totalVotes = 0; + for (const [user, answersFromThisUser] of Object.entries(userVotes)) { + for (const a of answersFromThisUser) { + if (answerMap[a]) { + answerMap[a].numVotes += 1; + totalVotes += 1; + if (user == this.$matrix.currentUserId) { + answerMap[a].hasMyVote = true; + } + } + } + } + + // Update percentage + answerArray.forEach(a => { + a.percentage = parseInt(((100 * a.numVotes) / totalVotes).toFixed(0)); + }); + this.pollAnswers = answerArray; + }, + pollAnswer(id) { + if (this.pollIsClosed) { + return; + } + util + .sendPollAnswer( + this.$matrix.matrixClient, + this.room.roomId, + [id], + this.event + ) + .catch((err) => { + console.log("Failed to send:", err); + }); + }, + pollClose() { + util + .closePoll( + this.$matrix.matrixClient, + this.room.roomId, + this.event + ) + .catch((err) => { + console.log("Failed to send:", err); + }); + }, + onAddRelation(ignoredevent) { + this.updateAnswers(); + }, + pollMixinOnEvent(event) { + if (event.getRoomId() !== this.room.roomId) { + return; // Not for this room + } + if ( + event.getType().startsWith("org.matrix.msc3381.poll.")) { + this.updateAnswers(); + } + }, + }, + computed: { + pollIsClosed() { + return this.pollEndTs != null && this.pollEndTs !== undefined; + }, + userCanClosePoll() { + return this.room && this.room.currentState && this.room.currentState.maySendRedactionForEvent(this.event, this.$matrix.currentUserId); + }, + userHasVoted() { + return this.pollAnswers.some(a => a.hasMyVote); + }, + pollIsAdmin() { + // Admins can view results of not-yet-closed undisclosed polls. + const me = this.room && this.room.getMember(this.$matrix.currentUserId); + let isAdmin = me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel); + return isAdmin; + } + }, + watch: { + pollResponseRelations: { + handler(newValue, oldValue) { + if (oldValue) { + oldValue.off('Relations.add', this.onAddRelation); + } + if (newValue) { + newValue.on('Relations.add', this.onAddRelation); + } + this.updateAnswers(); + }, + immediate: true + }, + pollEndRelations: { + handler(newValue, oldValue) { + if (oldValue) { + oldValue.off('Relations.add', this.onAddRelation); + } + if (newValue) { + newValue.on('Relations.add', this.onAddRelation); + } + this.updateAnswers(); + }, + immediate: true + } + + } +} \ No newline at end of file diff --git a/src/plugins/utils.js b/src/plugins/utils.js index ef40596..3c41c2b 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -180,6 +180,51 @@ class Util { return this.sendMessage(matrixClient, roomId, "m.reaction", content); } + createPoll(matrixClient, roomId, question, answers, isDisclosed) { + var idx = 0; + let answerData = answers.map(a => { + idx++; + return {id: "" + idx, 'org.matrix.msc1767.text': a.text } + }); + const content = { + 'org.matrix.msc3381.poll.start': { + question: { + 'org.matrix.msc1767.text': question, + body: question + }, + kind: isDisclosed ? "org.matrix.msc3381.poll.disclosed" : "org.matrix.msc3381.poll.undisclosed", + max_selections: 1, + answers: answerData + }, + }; + return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.start", content); + } + + closePoll(matrixClient, roomId, event) { + const content = { + 'm.relates_to': { + rel_type: 'm.reference', + event_id: event.getId() + }, + 'org.matrix.msc3381.poll.end': { + }, + }; + return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.end", content); + } + + sendPollAnswer(matrixClient, roomId, answers, event) { + const content = { + 'm.relates_to': { + rel_type: 'm.reference', + event_id: event.getId() + }, + 'org.matrix.msc3381.poll.response': { + answers: answers + }, + }; + return this.sendMessage(matrixClient, roomId, "org.matrix.msc3381.poll.response", content); + } + sendMessage(matrixClient, roomId, eventType, content) { return new Promise((resolve, reject) => { matrixClient.sendEvent(roomId, eventType, content, undefined, undefined) diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index dd205ee..1265a0c 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -306,6 +306,7 @@ export default { addMatrixClientListeners(client) { if (client) { + client.setMaxListeners(100); // Increate max number of listeners. client.on("event", this.onEvent); client.on("Room", this.onRoom); client.on("Session.logged_out", this.onSessionLoggedOut);