Support for polls (can be created by room admins)
This commit is contained in:
parent
2a064f4a06
commit
0d1ac1d441
11 changed files with 676 additions and 6 deletions
|
|
@ -15,3 +15,5 @@ $chat-button-height: 50px;
|
||||||
$voice-recorder-color: #6f6f6f;
|
$voice-recorder-color: #6f6f6f;
|
||||||
$voice-recording-color: red;
|
$voice-recording-color: red;
|
||||||
$voice-recorded-color: #3ae17d;
|
$voice-recorded-color: #3ae17d;
|
||||||
|
|
||||||
|
$poll-hilite-color: $very-very-purple;
|
||||||
61
src/assets/css/components/_poll.scss
Normal file
61
src/assets/css/components/_poll.scss
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -233,5 +233,25 @@
|
||||||
"video_file": "Video file",
|
"video_file": "Video file",
|
||||||
"original_text": "<original text>",
|
"original_text": "<original text>",
|
||||||
"download_name": "Download"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,9 +73,7 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()"
|
||||||
!event.isRelation() && !event.isRedacted() && !event.isRedaction()
|
|
||||||
"
|
|
||||||
:ref="event.getId()"
|
:ref="event.getId()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -303,6 +301,23 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</v-col>
|
</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>
|
</v-row>
|
||||||
<VoiceRecorder
|
<VoiceRecorder
|
||||||
:micButtonRef="$refs.mic_button"
|
:micButtonRef="$refs.mic_button"
|
||||||
|
|
@ -458,6 +473,11 @@
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<CreatePollDialog
|
||||||
|
:show="showCreatePollDialog"
|
||||||
|
@close="showCreatePollDialog = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -470,12 +490,14 @@ import MessageIncomingImage from "./messages/MessageIncomingImage.vue";
|
||||||
import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
||||||
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
||||||
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
||||||
|
import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
|
||||||
import MessageOutgoingText from "./messages/MessageOutgoingText";
|
import MessageOutgoingText from "./messages/MessageOutgoingText";
|
||||||
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
||||||
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
||||||
import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue";
|
import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue";
|
||||||
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
|
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
|
||||||
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
|
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
|
||||||
|
import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.vue";
|
||||||
import ContactJoin from "./messages/ContactJoin.vue";
|
import ContactJoin from "./messages/ContactJoin.vue";
|
||||||
import ContactLeave from "./messages/ContactLeave.vue";
|
import ContactLeave from "./messages/ContactLeave.vue";
|
||||||
import ContactInvited from "./messages/ContactInvited.vue";
|
import ContactInvited from "./messages/ContactInvited.vue";
|
||||||
|
|
@ -505,6 +527,8 @@ import stickers from "../plugins/stickers";
|
||||||
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
|
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
|
||||||
import BottomSheet from "./BottomSheet.vue";
|
import BottomSheet from "./BottomSheet.vue";
|
||||||
import ImageResize from "image-resize";
|
import ImageResize from "image-resize";
|
||||||
|
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||||
|
|
||||||
const sizeOf = require("image-size");
|
const sizeOf = require("image-size");
|
||||||
const dataUriToBuffer = require("data-uri-to-buffer");
|
const dataUriToBuffer = require("data-uri-to-buffer");
|
||||||
const prettyBytes = require("pretty-bytes");
|
const prettyBytes = require("pretty-bytes");
|
||||||
|
|
@ -555,6 +579,7 @@ export default {
|
||||||
MessageOutgoingAudio,
|
MessageOutgoingAudio,
|
||||||
MessageOutgoingVideo,
|
MessageOutgoingVideo,
|
||||||
MessageOutgoingSticker,
|
MessageOutgoingSticker,
|
||||||
|
MessageOutgoingPoll,
|
||||||
ContactJoin,
|
ContactJoin,
|
||||||
ContactLeave,
|
ContactLeave,
|
||||||
ContactInvited,
|
ContactInvited,
|
||||||
|
|
@ -580,6 +605,7 @@ export default {
|
||||||
StickerPickerBottomSheet,
|
StickerPickerBottomSheet,
|
||||||
BottomSheet,
|
BottomSheet,
|
||||||
AvatarOperations,
|
AvatarOperations,
|
||||||
|
CreatePollDialog
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -606,6 +632,7 @@ export default {
|
||||||
replyToEvent: null,
|
replyToEvent: null,
|
||||||
replyToImg: null,
|
replyToImg: null,
|
||||||
replyToContentType: null,
|
replyToContentType: null,
|
||||||
|
showCreatePollDialog: false,
|
||||||
showNoRecordingAvailableDialog: false,
|
showNoRecordingAvailableDialog: false,
|
||||||
showContextMenu: false,
|
showContextMenu: false,
|
||||||
showContextMenuAnchor: null,
|
showContextMenuAnchor: null,
|
||||||
|
|
@ -765,6 +792,12 @@ export default {
|
||||||
},
|
},
|
||||||
invitationCount() {
|
invitationCount() {
|
||||||
return this.$matrix.invites.length;
|
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":
|
case "m.room.encryption":
|
||||||
return RoomEncrypted;
|
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": {
|
case "im.keanu.room_deletion_notice": {
|
||||||
// Custom event for notice 30 seconds before a room is deleted/purged.
|
// Custom event for notice 30 seconds before a room is deleted/purged.
|
||||||
const deletionNotices = this.room.currentState.getStateEvents(
|
const deletionNotices = this.room.currentState.getStateEvents(
|
||||||
|
|
@ -1815,7 +1856,7 @@ export default {
|
||||||
},
|
},
|
||||||
onInvitationsClick() {
|
onInvitationsClick() {
|
||||||
this.$navigation.push({ name: "Home" }, -1);
|
this.$navigation.push({ name: "Home" }, -1);
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
195
src/components/CreatePollDialog.vue
Normal file
195
src/components/CreatePollDialog.vue
Normal 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>
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</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
|
<DeviceList
|
||||||
v-if="expandedMembers.includes(member)"
|
v-if="expandedMembers.includes(member)"
|
||||||
:member="member"
|
:member="member"
|
||||||
|
|
|
||||||
60
src/components/messages/MessageIncomingPoll.vue
Normal file
60
src/components/messages/MessageIncomingPoll.vue
Normal 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>
|
||||||
60
src/components/messages/MessageOutgoingPoll.vue
Normal file
60
src/components/messages/MessageOutgoingPoll.vue
Normal 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>
|
||||||
185
src/components/messages/pollMixin.js
Normal file
185
src/components/messages/pollMixin.js
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -180,6 +180,51 @@ class Util {
|
||||||
return this.sendMessage(matrixClient, roomId, "m.reaction", content);
|
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) {
|
sendMessage(matrixClient, roomId, eventType, content) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
matrixClient.sendEvent(roomId, eventType, content, undefined, undefined)
|
matrixClient.sendEvent(roomId, eventType, content, undefined, undefined)
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@ export default {
|
||||||
|
|
||||||
addMatrixClientListeners(client) {
|
addMatrixClientListeners(client) {
|
||||||
if (client) {
|
if (client) {
|
||||||
|
client.setMaxListeners(100); // Increate max number of listeners.
|
||||||
client.on("event", this.onEvent);
|
client.on("event", this.onEvent);
|
||||||
client.on("Room", this.onRoom);
|
client.on("Room", this.onRoom);
|
||||||
client.on("Session.logged_out", this.onSessionLoggedOut);
|
client.on("Session.logged_out", this.onSessionLoggedOut);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue