Support for polls (can be created by room admins)

This commit is contained in:
N Pex 2022-05-03 09:40:02 +00:00 committed by n8fr8
parent 8c4e1b86a3
commit b4c98833fe
11 changed files with 676 additions and 6 deletions

View file

@ -14,4 +14,6 @@ $chat-button-height: 50px;
$voice-recorder-color: #6f6f6f;
$voice-recording-color: red;
$voice-recorded-color: #3ae17d;
$voice-recorded-color: #3ae17d;
$poll-hilite-color: $very-very-purple;

View file

@ -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;
}

View file

@ -233,5 +233,25 @@
"video_file": "Video file",
"original_text": "<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"
}
}

View file

@ -73,9 +73,7 @@
/>
<div
v-if="
!event.isRelation() && !event.isRedacted() && !event.isRedaction()
"
v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()"
:ref="event.getId()"
>
<div
@ -303,6 +301,23 @@
/>
</label>
</v-col>
<v-col
v-if="!showRecorder && canCreatePoll"
ref="sendOptions"
class="input-area-button text-center flex-grow-0 flex-shrink-1"
>
<v-menu close-on-click>
<template v-slot:activator="{ on, attrs }">
<v-btn icon v-bind="attrs" v-on="on">
<v-icon>more_vert</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="showCreatePollDialog = true"><v-list-item-title>{{ $t("poll_create.create_poll_menu_option") }}</v-list-item-title></v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
<VoiceRecorder
:micButtonRef="$refs.mic_button"
@ -458,6 +473,11 @@
</v-card-actions>
</v-card>
</v-dialog>
<CreatePollDialog
:show="showCreatePollDialog"
@close="showCreatePollDialog = false"
/>
</div>
</template>
@ -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);
}
},
},
};
</script>

View file

@ -0,0 +1,195 @@
<template>
<v-dialog v-model="showDialog" v-show="room" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '688px' : '95%'">
<div class="dialog-content text-center">
<template>
<v-icon color="black" size="30">poll</v-icon>
<h2 class="dialog-title">
{{ $t("poll_create.title") }}
</h2>
<div class="dialog-text">{{ $t("poll_create.intro") }}</div>
</template>
<v-form v-model="isValid" ref="pollcreateform">
<v-container fluid class="poll-create-dialog-content">
<v-row cols="12">
<v-col cols="12">
<v-switch
v-model="pollIsDisclosed"
inset
:label="pollIsDisclosed ? $t('poll_create.poll_disclosed') : $t('poll_create.poll_undisclosed')"
></v-switch>
</v-col>
</v-row>
<v-row cols="12">
<v-col cols="12">
<v-text-field
outlined
:disabled="isCreating"
v-model="pollQuestion"
:label="$t('poll_create.question_label')"
:rules="[(v) => !!v || $t('poll_create.question_required')]"
></v-text-field>
</v-col>
</v-row>
<v-row v-for="(answer, index) in pollAnswers" cols="12" :key="answer.id">
<v-col cols="12">
<v-text-field
outlined
:disabled="isCreating"
v-model="answer.text"
:label="$t('poll_create.answer_label', { index: index + 1 })"
:append-icon="pollAnswers.length > 1 ? 'delete' : null"
@click:append="removeAnswer(index)"
:rules="[(v) => !!v || $t('poll_create.answer_required')]"
></v-text-field>
</v-col>
</v-row>
<v-row
><v-col
><v-btn @click.stop="addAnswer">{{ $t("poll_create.add_answer") }}</v-btn></v-col
></v-row
>
</v-container>
</v-form>
<div class="poll-create-status">
{{ status }}
<v-progress-circular v-if="isCreating" indeterminate color="primary" size="14"></v-progress-circular>
</div>
<v-container fluid>
<v-row cols="12">
<v-col cols="6">
<v-btn
id="btn-create-poll-cancel"
depressed
text
block
class="text-button"
@click="showDialog = false"
:disabled="isCreating"
>{{ $t("menu.cancel") }}</v-btn
>
</v-col>
<v-col cols="6" align="center">
<v-btn
id="btn-create-poll"
color="red"
depressed
block
class="filled-button"
@click.stop="onCreatePoll()"
:disabled="isCreating"
>{{ $t("poll_create.create") }}</v-btn
>
</v-col>
</v-row>
</v-container>
</div>
</v-dialog>
</template>
<script>
import roomInfoMixin from "./roomInfoMixin";
import util from "../plugins/utils";
export default {
name: "CreatePollDialog",
mixins: [roomInfoMixin],
props: {
show: {
type: Boolean,
default: function() {
return false;
},
},
},
data() {
return this.defaultData();
},
watch: {
show: {
handler(newVal, ignoredOldVal) {
this.showDialog = newVal;
},
},
showDialog() {
if (!this.showDialog) {
this.$emit("close");
} else {
// Reset values
let defaults = this.defaultData();
this.isValid = defaults.isValid;
this.isCreating = defaults.isCreating;
this.status = defaults.status;
this.pollIsDisclosed = defaults.pollIsDisclosed;
this.pollQuestion = defaults.pollQuestion;
this.pollAnswers = defaults.pollAnswers;
this.autoIncrementId = defaults.autoIncrementId;
if (this.$refs.pollcreateform) {
this.$refs.pollcreateform.resetValidation();
}
}
},
},
methods: {
defaultData() {
return {
showDialog: false,
isValid: true,
isCreating: false,
status: String.fromCharCode(160),
pollIsDisclosed: true,
pollQuestion: "",
pollAnswers: [
{ id: "1", text: "" },
{ id: "2", text: "" },
],
autoIncrementId: 2,
};
},
addAnswer() {
if (this.pollAnswers.length < 20) {
// MAX length according to spec
this.autoIncrementId += 1;
this.pollAnswers.push({ id: "" + this.autoIncrementId, text: "" });
}
},
removeAnswer(index) {
this.pollAnswers.splice(index, 1);
},
onCreatePoll() {
if (this.$refs.pollcreateform.validate()) {
this.isCreating = true;
this.status = this.$t("poll_create.creating");
util
.createPoll(
this.$matrix.matrixClient,
this.room.roomId,
this.pollQuestion,
this.pollAnswers,
this.pollIsDisclosed
)
.then(() => {
console.log("Sent message");
this.isCreating = false;
this.showDialog = false;
})
.catch((err) => {
console.log("Failed to send:", err);
this.isCreating = false;
this.status = this.$t("poll_create.failed");
});
setTimeout(() => {}, 3000);
} else {
this.isValid = false;
}
},
},
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
@import "@/assets/css/components/poll.scss";
</style>

View file

@ -153,7 +153,7 @@
})
}}
</span>
<div v-if="member.userId != $matrix.currentUserId && !$matrix.isDirectRoomWith(room, member.userId) && expandedMembers.includes(member)" class="start-private-chat clickable" @click="startPrivateChat(member.userId)">Start private chat</div>
<div v-if="member.userId != $matrix.currentUserId && !$matrix.isDirectRoomWith(room, member.userId) && expandedMembers.includes(member)" class="start-private-chat clickable" @click="startPrivateChat(member.userId)">{{ $t("menu.start_private_chat") }}</div>
<DeviceList
v-if="expandedMembers.includes(member)"
:member="member"

View file

@ -0,0 +1,60 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble poll-bubble">
{{ pollQuestion }}
<v-container fluid>
<v-row
v-for="answer in pollAnswers"
:key="answer.id"
@click="pollAnswer(answer.id)"
:class="{ 'poll-answer': true, selected: answer.hasMyVote }"
>
<v-col cols="auto" class="ma-0 pa-0 poll-answer-title">{{ answer.text }}</v-col>
<v-col
v-if="pollIsClosed || (pollIsDisclosed && userHasVoted) || pollIsAdmin"
cols="auto"
class="ma-0 pa-0 poll-answer-num-votes"
>{{ answer.numVotes }}</v-col
>
<div v-if="pollIsClosed || (pollIsDisclosed && userHasVoted) || pollIsAdmin" class="poll-percent-indicator">
<div class="bar" :style="{ width: `${answer.percentage}%` }"></div>
</div>
</v-row>
<v-row class="poll-status">
<v-col cols="auto" class="ma-0 pa-0 poll-status-title">{{
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")
}}</v-col>
<v-col
cols="auto"
class="ma-0 pa-0 poll-status-close clickable"
v-if="!pollIsClosed && userCanClosePoll"
@click.stop="pollClose"
>{{ $t("poll_create.close_poll") }}</v-col
>
</v-row>
</v-container>
</div>
</message-incoming>
</template>
<script>
import pollMixin from "./pollMixin";
import MessageIncoming from "./MessageIncoming.vue";
export default {
extends: MessageIncoming,
mixins: [pollMixin],
components: { MessageIncoming },
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
@import "@/assets/css/components/poll.scss";
</style>

View file

@ -0,0 +1,60 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble poll-bubble">
{{ pollQuestion }}
<v-container fluid>
<v-row
v-for="answer in pollAnswers"
:key="answer.id"
@click="pollAnswer(answer.id)"
:class="{ 'poll-answer': true, selected: answer.hasMyVote }"
>
<v-col cols="auto" class="ma-0 pa-0 poll-answer-title">{{ answer.text }}</v-col>
<v-col
v-if="pollIsClosed || (pollIsDisclosed && userHasVoted) || pollIsAdmin"
cols="auto"
class="ma-0 pa-0 poll-answer-num-votes"
>{{ answer.numVotes }}</v-col
>
<div v-if="pollIsClosed || (pollIsDisclosed && userHasVoted) || pollIsAdmin" class="poll-percent-indicator">
<div class="bar" :style="{ width: `${answer.percentage}%` }"></div>
</div>
</v-row>
<v-row class="poll-status">
<v-col cols="auto" class="ma-0 pa-0 poll-status-title">{{
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")
}}</v-col>
<v-col
cols="auto"
class="ma-0 pa-0 poll-status-close clickable"
v-if="!pollIsClosed && userCanClosePoll"
@click.stop="pollClose"
>{{ $t("poll_create.close_poll") }}</v-col
>
</v-row>
</v-container>
</div>
</message-outgoing>
</template>
<script>
import pollMixin from "./pollMixin";
import MessageOutgoing from "./MessageOutgoing.vue";
export default {
extends: MessageOutgoing,
mixins: [pollMixin],
components: { MessageOutgoing },
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
@import "@/assets/css/components/poll.scss";
</style>

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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);