{showDetails && voters && voterCount > 0
&&
- {voters.map(voter =>
- - {voter?.name}
+ { voters.map(voter =>
+ - { voter.name }
)}
}
)
diff --git a/react/features/polls/constants.ts b/react/features/polls/constants.ts
index f4cc3fc0de..ff648104ad 100644
--- a/react/features/polls/constants.ts
+++ b/react/features/polls/constants.ts
@@ -1,6 +1,2 @@
-export const COMMAND_NEW_POLL = 'new-poll';
-export const COMMAND_ANSWER_POLL = 'answer-poll';
-export const COMMAND_OLD_POLLS = 'old-polls';
-
export const CHAR_LIMIT = 500;
export const ANSWERS_LIMIT = 255;
diff --git a/react/features/polls/middleware.ts b/react/features/polls/middleware.ts
index f6d253d6b4..9063580223 100644
--- a/react/features/polls/middleware.ts
+++ b/react/features/polls/middleware.ts
@@ -1,6 +1,7 @@
import { IStore } from '../app/types';
-import { ENDPOINT_MESSAGE_RECEIVED, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
+import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
+import { getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { playSound } from '../base/sounds/actions';
@@ -11,13 +12,7 @@ import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/c
import { RECEIVE_POLL } from './actionTypes';
import { clearPolls, receiveAnswer, receivePoll } from './actions';
-import {
- COMMAND_ANSWER_POLL,
- COMMAND_NEW_POLL,
- COMMAND_OLD_POLLS
-} from './constants';
-import logger from './logger';
-import { IAnswer, IPoll, IPollData } from './types';
+import { IIncomingAnswerData } from './types';
/**
* The maximum number of answers a poll can have.
@@ -28,75 +23,29 @@ const MAX_ANSWERS = 32;
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Clear messages or close the chat modal if it's left
* open.
+ * When joining new conference set up the listeners for polls.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
- (conference, { dispatch }, previousConference): void => {
+ (conference, { dispatch, getState }, previousConference): void => {
if (conference !== previousConference) {
dispatch(clearPolls());
+
+ if (conference && !previousConference) {
+ conference.on(JitsiConferenceEvents.POLL_RECEIVED, (data: any) => {
+ _handleReceivedPollsData(data, dispatch, getState);
+ });
+ conference.on(JitsiConferenceEvents.POLL_ANSWER_RECEIVED, (data: any) => {
+ _handleReceivedPollsAnswer(data, dispatch, getState);
+ });
+ }
}
});
-const parsePollData = (pollData: Partial
): IPoll | null => {
- if (typeof pollData !== 'object' || pollData === null) {
- return null;
- }
- const { id, senderId, question, answers } = pollData;
-
- if (typeof id !== 'string' || typeof senderId !== 'string'
- || typeof question !== 'string' || !(answers instanceof Array)) {
- logger.error('Malformed poll data received:', pollData);
-
- return null;
- }
-
- // Validate answers.
- if (answers.some(answer => typeof answer !== 'string')) {
- logger.error('Malformed answers data received:', answers);
-
- return null;
- }
-
- return {
- changingVote: false,
- senderId,
- question,
- showResults: true,
- lastVote: null,
- answers,
- saved: false,
- editing: false
- };
-};
-
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const result = next(action);
switch (action.type) {
- case ENDPOINT_MESSAGE_RECEIVED: {
- const { participant, data } = action;
- const isNewPoll = data.type === COMMAND_NEW_POLL;
-
- _handleReceivePollsMessage({
- ...data,
- senderId: isNewPoll ? participant.getId() : undefined,
- voterId: isNewPoll ? undefined : participant.getId()
- }, dispatch, getState);
-
- break;
- }
-
- case NON_PARTICIPANT_MESSAGE_RECEIVED: {
- const { id, json: data } = action;
- const isNewPoll = data.type === COMMAND_NEW_POLL;
-
- _handleReceivePollsMessage({
- ...data,
- senderId: isNewPoll ? id : undefined,
- voterId: isNewPoll ? undefined : id
- }, dispatch, getState);
- break;
- }
case RECEIVE_POLL: {
const state = getState();
@@ -120,7 +69,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
});
/**
- * Handles receiving of polls message command.
+ * Handles receiving of new or history polls to load.
*
* @param {Object} data - The json data carried by the polls message.
* @param {Function} dispatch - The dispatch function.
@@ -128,82 +77,58 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
*
* @returns {void}
*/
-function _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
+function _handleReceivedPollsData(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
if (arePollsDisabled(getState())) {
return;
}
- switch (data.type) {
+ const { pollId, answers, senderId, question, history } = data;
+ const poll = {
+ changingVote: false,
+ senderId,
+ showResults: false,
+ lastVote: null,
+ question,
+ answers: answers.slice(0, MAX_ANSWERS),
+ saved: false,
+ editing: false,
+ pollId
+ };
- case COMMAND_NEW_POLL: {
- const { pollId, answers, senderId, question } = data;
- const tmp = {
- id: pollId,
- answers,
- question,
- senderId
- };
+ dispatch(receivePoll(poll, !history));
- // Check integrity of the poll data.
- // TODO(saghul): we should move this to the server side, likely by storing the
- // poll data in the room metadata.
- if (parsePollData(tmp) === null) {
- return;
- }
-
- const poll = {
- changingVote: false,
- senderId,
- showResults: false,
- lastVote: null,
- question,
- answers: answers.map((answer: string) => {
- return {
- name: answer,
- voters: []
- };
- }).slice(0, MAX_ANSWERS),
- saved: false,
- editing: false
- };
-
- dispatch(receivePoll(pollId, poll, true));
+ if (!history) {
dispatch(showNotification({
appearance: NOTIFICATION_TYPE.NORMAL,
titleKey: 'polls.notification.title',
descriptionKey: 'polls.notification.description'
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
- break;
-
- }
-
- case COMMAND_ANSWER_POLL: {
- const { pollId, answers, voterId } = data;
-
- const receivedAnswer: IAnswer = {
- voterId,
- pollId,
- answers: answers.slice(0, MAX_ANSWERS).map(Boolean)
- };
-
- dispatch(receiveAnswer(pollId, receivedAnswer));
- break;
-
- }
-
- case COMMAND_OLD_POLLS: {
- const { polls } = data;
-
- for (const pollData of polls) {
- const poll = parsePollData(pollData);
-
- if (poll === null) {
- logger.warn('Malformed old poll data', pollData);
- } else {
- dispatch(receivePoll(pollData.id, poll, false));
- }
- }
- break;
- }
}
}
+
+/**
+ * Handles receiving of pools answers.
+ *
+ * @param {Object} data - The json data carried by the polls message.
+ * @param {Function} dispatch - The dispatch function.
+ * @param {Function} getState - The getState function.
+ *
+ * @returns {void}
+ */
+function _handleReceivedPollsAnswer(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
+ if (arePollsDisabled(getState())) {
+ return;
+ }
+
+ const { pollId, answers, senderId, senderName } = data;
+
+ const receivedAnswer: IIncomingAnswerData = {
+ answers: answers.slice(0, MAX_ANSWERS).map(Boolean),
+ pollId,
+ senderId,
+ voterName: getParticipantById(getState(), senderId)
+ ? getParticipantDisplayName(getState(), senderId) : senderName
+ };
+
+ dispatch(receiveAnswer(receivedAnswer));
+}
diff --git a/react/features/polls/reducer.ts b/react/features/polls/reducer.ts
index eaf7b52ae6..9a4d71dc30 100644
--- a/react/features/polls/reducer.ts
+++ b/react/features/polls/reducer.ts
@@ -11,7 +11,7 @@ import {
RESET_NB_UNREAD_POLLS,
SAVE_POLL
} from './actionTypes';
-import { IAnswer, IPoll } from './types';
+import { IIncomingAnswerData, IPollData } from './types';
const INITIAL_STATE = {
polls: {},
@@ -23,7 +23,7 @@ const INITIAL_STATE = {
export interface IPollsState {
nbUnreadPolls: number;
polls: {
- [pollId: string]: IPoll;
+ [pollId: string]: IPollData;
};
}
@@ -61,7 +61,7 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
...state,
polls: {
...state.polls,
- [action.pollId]: action.poll
+ [action.poll.pollId]: action.poll
},
nbUnreadPolls: state.nbUnreadPolls + 1
};
@@ -72,7 +72,7 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
...state,
polls: {
...state.polls,
- [action.pollId]: action.poll
+ [action.poll.pollId]: action.poll
}
};
}
@@ -81,7 +81,9 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
// The answer is added to an existing poll
case RECEIVE_ANSWER: {
- const { pollId, answer }: { answer: IAnswer; pollId: string; } = action;
+ const { answer }: { answer: IIncomingAnswerData; } = action;
+ const pollId = answer.pollId;
+ const poll = state.polls[pollId];
// if the poll doesn't exist
if (!(pollId in state.polls)) {
@@ -91,33 +93,22 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
}
// if the poll exists, we update it with the incoming answer
- const newAnswers = state.polls[pollId].answers
- .map(_answer => {
- // checking if the voters is an array for supporting old structure model
- const answerVoters = _answer.voters
- ? _answer.voters.length
- ? [ ..._answer.voters ] : Object.keys(_answer.voters) : [];
-
- return {
- name: _answer.name,
- voters: answerVoters
- };
- });
-
-
- for (let i = 0; i < newAnswers.length; i++) {
+ for (let i = 0; i < poll.answers.length; i++) {
// if the answer was chosen, we add the senderId to the array of voters of this answer
- const voters = newAnswers[i].voters as any;
+ let voters = poll.answers[i].voters || [];
- const index = voters.indexOf(answer.voterId);
-
- if (answer.answers[i]) {
- if (index === -1) {
- voters.push(answer.voterId);
+ if (voters.find(user => user.id === answer.senderId)) {
+ if (!answer.answers[i]) {
+ voters = voters.filter(user => user.id !== answer.senderId);
}
- } else if (index > -1) {
- voters.splice(index, 1);
+ } else if (answer.answers[i]) {
+ voters.push({
+ id: answer.senderId,
+ name: answer.voterName
+ });
}
+
+ poll.answers[i].voters = voters?.length ? voters : undefined;
}
// finally we update the state by returning the updated poll
@@ -126,8 +117,8 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
polls: {
...state.polls,
[pollId]: {
- ...state.polls[pollId],
- answers: newAnswers
+ ...poll,
+ answers: [ ...poll.answers ]
}
}
};
@@ -179,7 +170,7 @@ ReducerRegistry.register(STORE_NAME, (state = INITIAL_STATE, action
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { [action.pollId]: _removedPoll, ...newState } = state.polls;
+ const { [action.poll.pollId]: _removedPoll, ...newState } = state.polls;
return {
...state,
diff --git a/react/features/polls/types.ts b/react/features/polls/types.ts
index b59723b030..27f9040623 100644
--- a/react/features/polls/types.ts
+++ b/react/features/polls/types.ts
@@ -1,5 +1,7 @@
-export interface IAnswer {
-
+/**
+ * TODO: move to ljm.
+ */
+export interface IIncomingAnswer {
/**
* An array of boolean: true if the answer was chosen by the responder, else false.
*/
@@ -11,16 +13,24 @@ export interface IAnswer {
pollId: string;
/**
- * ID of the voter for this answer.
+ * ID of the sender of this answer.
*/
- voterId: string;
+ senderId: string;
+}
+/**
+ * Extension of IIncomingAnswer with UI only fields.
+ */
+export interface IIncomingAnswerData extends IIncomingAnswer {
/**
* Name of the voter for this answer.
*/
- voterName?: string;
+ voterName: string;
}
+/**
+ * TODO: move to ljm and use it from there.
+ */
export interface IPoll {
/**
@@ -30,7 +40,27 @@ export interface IPoll {
answers: Array;
/**
- * Whether the poll vote is being edited/changed.
+ * The unique ID of this poll.
+ */
+ pollId: string;
+
+ /**
+ * The question asked by this poll.
+ */
+ question: string;
+
+ /**
+ * ID of the sender of this poll.
+ */
+ senderId: string | undefined;
+}
+
+/**
+ * Extension of IPoll with UI only fields.
+ */
+export interface IPollData extends IPoll {
+ /**
+ * Whether the poll vote is being edited/changed. UI only, not stored on the backend.
*/
changingVote: boolean;
@@ -46,30 +76,35 @@ export interface IPoll {
lastVote: Array | null;
/**
- * The question asked by this poll.
- */
- question: string;
-
- /**
- * Whether poll is saved or not?.
+ * Whether poll is saved or not?. UI only, not stored on the backend.
*/
saved: boolean;
- /**
- * ID of the sender of this poll.
- */
- senderId: string | undefined;
-
/**
* Whether the results should be shown instead of the answer form.
+ * UI only, not stored on the backend.
*/
showResults: boolean;
}
-export interface IPollData extends IPoll {
+/**
+ * TODO: move to ljm and use it from there.
+ */
+export interface IVoterData {
+ /**
+ * The id of the voter.
+ */
id: string;
+
+ /**
+ * Voter name if voter is not in the meeting.
+ */
+ name: string;
}
+/**
+ * TODO: move to ljm and use it from there.
+ */
export interface IAnswerData {
/**
@@ -80,5 +115,5 @@ export interface IAnswerData {
/**
* An array of voters.
*/
- voters: Array;
+ voters?: Array;
}
diff --git a/resources/prosody-plugins/mod_polls.lua b/resources/prosody-plugins/mod_polls.lua
deleted file mode 100644
index be23144b97..0000000000
--- a/resources/prosody-plugins/mod_polls.lua
+++ /dev/null
@@ -1,207 +0,0 @@
--- This module provides persistence for the "polls" feature,
--- by keeping track of the state of polls in each room, and sending
--- that state to new participants when they join.
-
-local json = require 'cjson.safe';
-local st = require("util.stanza");
-local jid = require "util.jid";
-local util = module:require("util");
-local muc = module:depends("muc");
-
-local NS_NICK = 'http://jabber.org/protocol/nick';
-local is_healthcheck_room = util.is_healthcheck_room;
-
-local POLLS_LIMIT = 128;
-local POLL_PAYLOAD_LIMIT = 1024;
-
--- Logs a warning and returns true if a room does not
--- have poll data associated with it.
-local function check_polls(room)
- if room.polls == nil then
- module:log("warn", "no polls data in room");
- return true;
- end
- return false;
-end
-
---- Returns a table having occupant id and occupant name.
---- If the id cannot be extracted from nick a nil value is returned
---- if the occupant name cannot be extracted from presence the Fellow Jitster
---- name is used
-local function get_occupant_details(occupant)
- if not occupant then
- return nil
- end
- local presence = occupant:get_presence();
- local occupant_name;
- if presence then
- occupant_name = presence:get_child("nick", NS_NICK) and presence:get_child("nick", NS_NICK):get_text() or 'Fellow Jitster';
- else
- occupant_name = 'Fellow Jitster'
- end
- local _, _, occupant_id = jid.split(occupant.nick)
- if not occupant_id then
- return nil
- end
- return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
-end
-
--- Sets up poll data in new rooms.
-module:hook("muc-room-created", function(event)
- local room = event.room;
- if is_healthcheck_room(room.jid) then return end
- module:log("debug", "setting up polls in room %s", room.jid);
- room.polls = {
- by_id = {};
- order = {};
- count = 0;
- };
-end);
-
--- Keeps track of the current state of the polls in each room,
--- by listening to "new-poll" and "answer-poll" messages,
--- and updating the room poll data accordingly.
--- This mirrors the client-side poll update logic.
-module:hook('jitsi-endpoint-message-received', function(event)
- local data, error, occupant, room, origin, stanza
- = event.message, event.error, event.occupant, event.room, event.origin, event.stanza;
-
- if not data or (data.type ~= "new-poll" and data.type ~= "answer-poll") then
- return;
- end
-
- if string.len(event.raw_message) >= POLL_PAYLOAD_LIMIT then
- module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
- return true;
- end
-
- if data.type == "new-poll" then
- if check_polls(room) then return end
-
- local poll_creator = get_occupant_details(occupant)
- if not poll_creator then
- module:log("error", "Cannot retrieve poll creator id and name for %s from %s", occupant.jid, room.jid)
- return
- end
-
- if room.polls.count >= POLLS_LIMIT then
- module:log("error", "Too many polls created in %s", room.jid)
- return true;
- end
-
- if room.polls.by_id[data.pollId] ~= nil then
- module:log("error", "Poll already exists: %s", data.pollId);
- origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
- return true;
- end
-
- if room.jitsiMetadata and room.jitsiMetadata.permissions
- and room.jitsiMetadata.permissions.pollCreationRestricted
- and not is_feature_allowed('create-polls', origin.jitsi_meet_context_features) then
- origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
- return true;
- end
-
- local answers = {}
- local compact_answers = {}
- for i, name in ipairs(data.answers) do
- table.insert(answers, { name = name, voters = {} });
- table.insert(compact_answers, { key = i, name = name});
- end
-
- local poll = {
- id = data.pollId,
- sender_id = poll_creator.occupant_id,
- sender_name = poll_creator.occupant_name,
- question = data.question,
- answers = answers
- };
-
- room.polls.by_id[data.pollId] = poll
- table.insert(room.polls.order, poll)
- room.polls.count = room.polls.count + 1;
-
- local pollData = {
- event = event,
- room = room,
- poll = {
- pollId = data.pollId,
- senderId = poll_creator.occupant_id,
- senderName = poll_creator.occupant_name,
- question = data.question,
- answers = compact_answers
- }
- }
- module:fire_event("poll-created", pollData);
- elseif data.type == "answer-poll" then
- if check_polls(room) then return end
-
- local poll = room.polls.by_id[data.pollId];
- if poll == nil then
- module:log("warn", "answering inexistent poll");
- return;
- end
-
- local voter = get_occupant_details(occupant)
- if not voter then
- module:log("error", "Cannot retrieve voter id and name for %s from %s", occupant.jid, room.jid)
- return
- end
-
- local answers = {};
- for vote_option_idx, vote_flag in ipairs(data.answers) do
- table.insert(answers, {
- key = vote_option_idx,
- value = vote_flag,
- name = poll.answers[vote_option_idx].name,
- });
- poll.answers[vote_option_idx].voters[voter.occupant_id] = vote_flag and voter.occupant_name or nil;
- end
- local answerData = {
- event = event,
- room = room,
- pollId = poll.id,
- voterName = voter.occupant_name,
- voterId = voter.occupant_id,
- answers = answers
- }
- module:fire_event("answer-poll", answerData);
- end
-end);
-
--- Sends the current poll state to new occupants after joining a room.
-module:hook("muc-occupant-joined", function(event)
- local room = event.room;
- if is_healthcheck_room(room.jid) then return end
- if room.polls == nil or #room.polls.order == 0 then
- return
- end
-
- local data = {
- type = "old-polls",
- polls = {},
- };
- for i, poll in ipairs(room.polls.order) do
- data.polls[i] = {
- id = poll.id,
- senderId = poll.sender_id,
- senderName = poll.sender_name,
- question = poll.question,
- answers = poll.answers
- };
- end
-
- local json_msg_str, error = json.encode(data);
- if not json_msg_str then
- module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
- end
-
- local stanza = st.message({
- from = room.jid,
- to = event.occupant.jid
- })
- :tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
- :text(json_msg_str)
- :up();
- room:route_stanza(stanza);
-end);
diff --git a/resources/prosody-plugins/mod_polls_component.lua b/resources/prosody-plugins/mod_polls_component.lua
new file mode 100644
index 0000000000..3d9af49bf6
--- /dev/null
+++ b/resources/prosody-plugins/mod_polls_component.lua
@@ -0,0 +1,341 @@
+-- This module provides persistence for the "polls" feature,
+-- by keeping track of the state of polls in each room, and sending
+-- that state to new participants when they join.
+
+local json = require 'cjson.safe';
+local st = require("util.stanza");
+local jid = require "util.jid";
+local util = module:require("util");
+local muc = module:depends("muc");
+
+local NS_NICK = 'http://jabber.org/protocol/nick';
+local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
+local is_healthcheck_room = util.is_healthcheck_room;
+local room_jid_match_rewrite = util.room_jid_match_rewrite;
+
+local POLLS_LIMIT = 128;
+local POLL_PAYLOAD_LIMIT = 1024;
+
+local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
+if not main_virtual_host then
+ module:log('warn', 'No muc_mapper_domain_base option set.');
+ return;
+end
+local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
+
+-- Logs a warning and returns true if a room does not
+-- have poll data associated with it.
+local function check_polls(room)
+ if room.polls == nil then
+ module:log("warn", "no polls data in room");
+ return true;
+ end
+ return false;
+end
+
+local function validate_polls(data)
+ if type(data) ~= 'table' then
+ return false;
+ end
+ if data.type ~= 'polls' or type(data.pollId) ~= 'string' then
+ return false;
+ end
+ if data.command ~= 'new-poll' and data.command ~= 'answer-poll' then
+ return false;
+ end
+ if type(data.answers) ~= 'table' then
+ return false;
+ end
+
+ if data.command == "new-poll" then
+ if type(data.question) ~= 'string' then
+ return false;
+ end
+
+ for _, answer in ipairs(data.answers) do
+ if type(answer) ~= "table" or type(answer.name) ~= "string" then
+ return false;
+ end
+ end
+
+ return true;
+ elseif data.command == "answer-poll" then
+ for _, answer in ipairs(data.answers) do
+ if type(answer) ~= "boolean" then
+ return false;
+ end
+ end
+
+ return true;
+ end
+
+ return false;
+end
+
+--- Returns a table having occupant id and occupant name.
+--- If the id cannot be extracted from nick a nil value is returned same and for name
+local function get_occupant_details(occupant)
+ if not occupant then
+ return nil
+ end
+ local presence = occupant:get_presence();
+ local occupant_name;
+ if presence then
+ occupant_name = presence:get_child_text('nick', NS_NICK);
+ end
+ local _, _, occupant_id = jid.split(occupant.nick)
+ if not occupant_id then
+ return nil
+ end
+ return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
+end
+
+local function send_polls_message(room, data_str, to)
+ local stanza = st.message({
+ from = module.host,
+ to = to
+ })
+ :tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
+ :text(data_str)
+ :up();
+ room:route_stanza(stanza);
+end
+
+local function send_polls_message_to_all(room, data_str)
+ for _, room_occupant in room:each_occupant() do
+ send_polls_message(room, data_str, room_occupant.jid);
+ end
+end
+
+-- Keeps track of the current state of the polls in each room,
+-- by listening to "new-poll" and "answer-poll" messages,
+-- and updating the room poll data accordingly.
+-- This mirrors the client-side poll update logic.
+module:hook('message/host', function(event)
+ local session, stanza = event.origin, event.stanza;
+
+ -- we are interested in all messages without a body that are not groupchat
+ if stanza.attr.type == 'groupchat' or stanza:get_child('body') then
+ return;
+ end
+
+ local json_message = stanza:get_child('json-message', 'http://jitsi.org/jitmeet')
+ or stanza:get_child('json-message');
+ if not json_message then
+ return;
+ end
+
+ local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
+ if not room then
+ module:log('warn', 'No room found found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
+ return;
+ end
+
+ local occupant_jid = stanza.attr.from;
+ local occupant = room:get_occupant_by_real_jid(occupant_jid);
+ if not occupant then
+ module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid)
+ return;
+ end
+
+ local json_message_text = json_message:get_text();
+ if string.len(json_message_text) >= POLL_PAYLOAD_LIMIT then
+ module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
+ return true;
+ end
+
+ local data, error = json.decode(json_message_text);
+ if error then
+ module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
+ return true;
+ end
+
+ if not data or (data.command ~= "new-poll" and data.command ~= "answer-poll") then
+ return;
+ end
+
+ if not validate_polls(data) then
+ module:log('error', 'Invalid poll data. Sender: %s (%s)', stanza.attr.from, json_message_text);
+ return true;
+ end
+
+ if data.command == "new-poll" then
+ if check_polls(room) then return end
+
+ local poll_creator = get_occupant_details(occupant)
+ if not poll_creator then
+ module:log("error", "Cannot retrieve poll creator id and name for %s from %s", occupant.jid, room.jid)
+ return
+ end
+
+ if room.polls.count >= POLLS_LIMIT then
+ module:log("error", "Too many polls created in %s", room.jid)
+ return true;
+ end
+
+ if room.polls.by_id[data.pollId] ~= nil then
+ module:log("error", "Poll already exists: %s", data.pollId);
+ origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
+ return true;
+ end
+
+ if room.jitsiMetadata and room.jitsiMetadata.permissions
+ and room.jitsiMetadata.permissions.pollCreationRestricted
+ and not is_feature_allowed('create-polls', origin.jitsi_meet_context_features) then
+ origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
+ return true;
+ end
+
+ local answers = {}
+ local compact_answers = {}
+ for i, a in ipairs(data.answers) do
+ table.insert(answers, { name = a.name });
+ table.insert(compact_answers, { key = i, name = a.name});
+ end
+
+ local poll = {
+ pollId = data.pollId,
+ senderId = poll_creator.occupant_id,
+ senderName = poll_creator.occupant_name,
+ question = data.question,
+ answers = answers
+ };
+
+ room.polls.by_id[data.pollId] = poll
+ table.insert(room.polls.order, poll)
+ room.polls.count = room.polls.count + 1;
+
+ local pollData = {
+ event = event,
+ room = room,
+ poll = {
+ pollId = data.pollId,
+ senderId = poll_creator.occupant_id,
+ senderName = poll_creator.occupant_name,
+ question = data.question,
+ answers = compact_answers
+ }
+ }
+
+ module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
+
+ -- now send message to all participants
+ data.senderId = poll_creator.occupant_id;
+ data.type = 'polls';
+ local json_msg_str, error = json.encode(data);
+ if not json_msg_str then
+ module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
+ end
+ send_polls_message_to_all(room, json_msg_str);
+ elseif data.command == "answer-poll" then
+ if check_polls(room) then return end
+
+ local poll = room.polls.by_id[data.pollId];
+ if poll == nil then
+ module:log("warn", "answering inexistent poll");
+ return;
+ end
+
+ local voter = get_occupant_details(occupant)
+ if not voter then
+ module:log("error", "Cannot retrieve voter id and name for %s from %s", occupant.jid, room.jid)
+ return
+ end
+
+ local answers = {};
+ for vote_option_idx, vote_flag in ipairs(data.answers) do
+ local answer = poll.answers[vote_option_idx]
+
+ table.insert(answers, {
+ key = vote_option_idx,
+ value = vote_flag,
+ name = answer.name,
+ });
+
+ if vote_flag then
+ local voters = answer.voters;
+ if not voters then
+ answer.voters = {};
+ voters = answer.voters;
+ end
+
+ table.insert(voters, {
+ id = voter.occupant_id;
+ name = vote_flag and voter.occupant_name or nil;
+ });
+ end
+ end
+
+ local answerData = {
+ event = event,
+ room = room,
+ pollId = poll.pollId,
+ voterName = voter.occupant_name,
+ voterId = voter.occupant_id,
+ answers = answers
+ }
+ module:context(jid.host(room.jid)):fire_event("answer-poll", answerData);
+
+ data.senderId = voter.occupant_id;
+ data.type = 'polls';
+ local json_msg_str, error = json.encode(data);
+ if not json_msg_str then
+ module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
+ end
+ send_polls_message_to_all(room, json_msg_str);
+ end
+
+ return true;
+end);
+
+local setup_muc_component = function(host_module, host)
+ -- Sets up poll data in new rooms.
+ host_module:hook("muc-room-created", function(event)
+ local room = event.room;
+ if is_healthcheck_room(room.jid) then return end
+ room.polls = {
+ by_id = {};
+ order = {};
+ count = 0;
+ };
+ end);
+
+ -- Sends the current poll state to new occupants after joining a room.
+ host_module:hook("muc-occupant-joined", function(event)
+ local room = event.room;
+ if is_healthcheck_room(room.jid) then return end
+ if room.polls == nil or #room.polls.order == 0 then
+ return
+ end
+
+ local data = {
+ command = "old-polls",
+ polls = {},
+ type = 'polls'
+ };
+ for i, poll in ipairs(room.polls.order) do
+ data.polls[i] = {
+ pollId = poll.pollId,
+ senderId = poll.senderId,
+ senderName = poll.senderName,
+ question = poll.question,
+ answers = poll.answers
+ };
+ end
+
+ local json_msg_str, error = json.encode(data);
+ if not json_msg_str then
+ module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
+ end
+ send_polls_message(room, json_msg_str, event.occupant.jid);
+ end);
+end
+
+process_host_module(muc_domain_prefix..'.'..main_virtual_host, setup_muc_component);
+process_host_module('breakout.' .. main_virtual_host, setup_muc_component);
+
+process_host_module(main_virtual_host, function(host_module)
+ module:context(host_module.host):fire_event('jitsi-add-identity', {
+ name = 'polls'; host = module.host;
+ });
+end);
diff --git a/tests/pageobjects/ChatPanel.ts b/tests/pageobjects/ChatPanel.ts
index c4aff336d1..fa47748af0 100644
--- a/tests/pageobjects/ChatPanel.ts
+++ b/tests/pageobjects/ChatPanel.ts
@@ -19,4 +19,175 @@ export default class ChatPanel extends BasePageObject {
await this.participant.driver.$('body').click();
await this.participant.driver.keys([ 'c' ]);
}
+
+ /**
+ * Opens the polls tab in the chat panel.
+ */
+ async openPollsTab() {
+ await this.participant.driver.$('#polls-tab').click();
+ }
+
+ /**
+ * Checks whether the polls tab is visible.
+ */
+ async isPollsTabVisible() {
+ return this.participant.driver.$('#polls-tab-panel').isDisplayed();
+ }
+
+ async clickCreatePollButton() {
+ await this.participant.driver.$('aria/Create a poll').click();
+ }
+
+ /**
+ * Waits for the new poll input to be visible.
+ */
+ async waitForNewPollInput() {
+ await this.participant.driver.$(
+ '#polls-create-input')
+ .waitForExist({
+ timeout: 2000,
+ timeoutMsg: 'New poll not created'
+ });
+ }
+
+ /**
+ * Waits for the option input to be visible.
+ * @param index
+ */
+ async waitForOptionInput(index: number) {
+ await this.participant.driver.$(
+ `#polls-answer-input-${index}`)
+ .waitForExist({
+ timeout: 1000,
+ timeoutMsg: `Answer input ${index} not created`
+ });
+ }
+
+ /**
+ * Waits for the option input to be non-existing.
+ * @param index
+ */
+ async waitForOptionInputNonExisting(index: number) {
+ await this.participant.driver.$(
+ `#polls-answer-input-${index}`)
+ .waitForExist({
+ reverse: true,
+ timeout: 2000,
+ timeoutMsg: `Answer input ${index} still exists`
+ });
+ }
+
+ /**
+ * Clicks the "Add option" button.
+ */
+ async clickAddOptionButton() {
+ await this.participant.driver.$('aria/Add option').click();
+ }
+
+ /**
+ * Clicks the "Remove option" button.
+ * @param index
+ */
+ async clickRemoveOptionButton(index: number) {
+ await this.participant.driver.$(`[data-testid="remove-polls-answer-input-${index}"]`).click();
+ }
+
+ /**
+ * Fills in the poll question.
+ * @param question
+ */
+ async fillPollQuestion(question: string) {
+ const input = await this.participant.driver.$('#polls-create-input');
+
+ await input.click();
+ await this.participant.driver.keys(question);
+ }
+
+ /**
+ * Fills in the poll option.
+ * @param index
+ * @param option
+ */
+ async fillPollOption(index: number, option: string) {
+ const input = await this.participant.driver.$(`#polls-answer-input-${index}`);
+
+ await input.click();
+ await this.participant.driver.keys(option);
+ }
+
+ /**
+ * Gets the poll option.
+ * @param index
+ */
+ async getOption(index: number) {
+ return this.participant.driver.$(`#polls-answer-input-${index}`).getValue();
+ }
+
+ /**
+ * Clicks the "Save" button.
+ */
+ async clickSavePollButton() {
+ await this.participant.driver.$('aria/Save').click();
+ }
+
+ /**
+ * Clicks the "Edit" button.
+ */
+ async clickEditPollButton() {
+ await this.participant.driver.$('aria/Edit').click();
+ }
+
+ /**
+ * Clicks the "Skip" button.
+ */
+ async clickSkipPollButton() {
+ await this.participant.driver.$('aria/Skip').click();
+ }
+
+ /**
+ * Clicks the "Send" button.
+ */
+ async clickSendPollButton() {
+ await this.participant.driver.$('aria/Send poll').click();
+ }
+
+ /**
+ * Waits for the "Send" button to be visible.
+ */
+ async waitForSendButton() {
+ await this.participant.driver.$('aria/Send poll').waitForExist({
+ timeout: 1000,
+ timeoutMsg: 'Send button not visible'
+ });
+ }
+
+ /**
+ * Votes for the given option in the given poll.
+ * @param pollId
+ * @param index
+ */
+ async voteForOption(pollId: string, index: number) {
+ await this.participant.driver.execute(
+ (id, ix) => document.getElementById(`poll-answer-checkbox-${id}-${ix}`)?.click(),
+ pollId, index);
+
+ await this.participant.driver.$('aria/Submit').click();
+ }
+
+ /**
+ * Checks whether the given poll is visible.
+ * @param pollId
+ */
+ async isPollVisible(pollId: string) {
+ return this.participant.driver.$(`#poll-${pollId}`).isDisplayed();
+ }
+
+ /**
+ * Gets the result text for the given option in the given poll.
+ * @param pollId
+ * @param optionIndex
+ */
+ async getResult(pollId: string, optionIndex: number) {
+ return await this.participant.driver.$(`#poll-result-${pollId}-${optionIndex}`).getText();
+ }
}
diff --git a/tests/specs/2way/polls.spec.ts b/tests/specs/2way/polls.spec.ts
new file mode 100644
index 0000000000..efefca242b
--- /dev/null
+++ b/tests/specs/2way/polls.spec.ts
@@ -0,0 +1,123 @@
+import { ensureTwoParticipants } from '../../helpers/participants';
+
+describe('Polls', () => {
+ it('joining the meeting', async () => {
+ await ensureTwoParticipants();
+ });
+ it('create poll', async () => {
+ const { p1 } = ctx;
+
+ await p1.getToolbar().clickChatButton();
+ expect(await p1.getChatPanel().isOpen()).toBe(true);
+
+ expect(await p1.getChatPanel().isPollsTabVisible()).toBe(false);
+
+ await p1.getChatPanel().openPollsTab();
+ expect(await p1.getChatPanel().isPollsTabVisible()).toBe(true);
+
+ // create poll
+ await p1.getChatPanel().clickCreatePollButton();
+ await p1.getChatPanel().waitForNewPollInput();
+ });
+
+ it('fill in poll', async () => {
+ const { p1 } = ctx;
+
+ await p1.getChatPanel().fillPollQuestion('My Poll question?');
+
+ await p1.getChatPanel().waitForOptionInput(0);
+ await p1.getChatPanel().waitForOptionInput(1);
+ await p1.getChatPanel().fillPollOption(0, 'First option');
+ await p1.getChatPanel().fillPollOption(1, 'Second option');
+
+
+ await p1.getChatPanel().clickAddOptionButton();
+ await p1.getChatPanel().waitForOptionInput(2);
+ await p1.getChatPanel().fillPollOption(2, 'Third option');
+
+ await p1.getChatPanel().clickAddOptionButton();
+ await p1.getChatPanel().waitForOptionInput(3);
+ await p1.getChatPanel().fillPollOption(3, 'Fourth option');
+
+ await p1.getChatPanel().clickRemoveOptionButton(2);
+ // we remove the option and reindexing happens, so we check for index 3
+ await p1.getChatPanel().waitForOptionInputNonExisting(3);
+
+ expect(await p1.getChatPanel().getOption(2)).toBe('Fourth option');
+ });
+
+ it('save and edit poll', async () => {
+ const { p1 } = ctx;
+
+ await p1.getChatPanel().clickSavePollButton();
+
+ await p1.getChatPanel().waitForSendButton();
+
+ await p1.getChatPanel().clickEditPollButton();
+
+ await p1.getChatPanel().fillPollOption(0, ' edited!');
+
+ await p1.getChatPanel().clickSavePollButton();
+
+ await p1.getChatPanel().waitForSendButton();
+ });
+
+ it('send poll', async () => {
+ const { p1 } = ctx;
+
+ await p1.getChatPanel().clickSendPollButton();
+ });
+
+ it('vote on poll', async () => {
+ const { p1 } = ctx;
+
+ // await p1.getNotifications().closePollsNotification();
+
+ // we have only one poll, so we get its ID
+ const pollId: string = await p1.driver.waitUntil(() => p1.driver.execute(() => {
+ return Object.keys(APP.store.getState()['features/polls'].polls)[0];
+ }), { timeout: 2000 });
+
+ // we have just send the poll, so the UI should be in a state for voting
+ await p1.getChatPanel().voteForOption(pollId, 0);
+ });
+
+ it('check for vote', async () => {
+ const { p1, p2 } = ctx;
+ const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
+
+ // now let's check on p2 side
+ await p2.getToolbar().clickChatButton();
+ expect(await p2.getChatPanel().isOpen()).toBe(true);
+
+ expect(await p2.getChatPanel().isPollsTabVisible()).toBe(false);
+
+ await p2.getChatPanel().openPollsTab();
+ expect(await p2.getChatPanel().isPollsTabVisible()).toBe(true);
+
+ expect(await p2.getChatPanel().isPollVisible(pollId));
+
+ await p2.getChatPanel().clickSkipPollButton();
+
+ expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
+ });
+
+ it('leave and check for vote', async () => {
+ await ctx.p2.hangup();
+
+ await ensureTwoParticipants();
+
+ const { p1, p2 } = ctx;
+ const pollId: string = await p1.driver.execute('return Object.keys(APP.store.getState()["features/polls"].polls)[0];');
+
+
+ await p2.getToolbar().clickChatButton();
+ await p2.getChatPanel().openPollsTab();
+
+ expect(await p2.getChatPanel().isPollVisible(pollId));
+
+ await p2.getChatPanel().clickSkipPollButton();
+
+ expect(await p2.getChatPanel().getResult(pollId, 0)).toBe('1 (100%)');
+ });
+});