feat(polls): Move polls to using a component (#16406)

* squash: Renames module.

* squash: Loads polls component.

* squash: Attach needed logic when components/hosts load.

* squash: Moves to use component.

* squash: Uses json-message format with types.

* squash: Checks for polls support.

* squash: Fixes comments and moves validate polls to backend.

* squash: Fix debian build.

* fix(polls): Fixes polls in breakout rooms.

* squash: Further simplify types.

Separate type that needs to go into ljm and those used only for the UI part.
Simplify answer/voter type to be unified across operations which simplifies and its logic.

* squash: Change voters structure to be {id, name}.

* squash: Update react/features/conference/functions.any.ts

Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>

* squash: Drops roomJid from messages. Uses the connection information as breakout does.

---------

Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>
This commit is contained in:
Дамян Минков
2025-09-25 16:46:06 -05:00
committed by GitHub
parent 60679aa2d3
commit 469406d7cd
27 changed files with 870 additions and 506 deletions

View File

@@ -154,6 +154,16 @@ case "$1" in
PROSODY_CONFIG_PRESENT="false"
fi
# Start using the polls component
if ! grep -q "Component \"polls.$JVB_HOSTNAME\"" $PROSODY_HOST_CONFIG ;then
echo -e "\nComponent \"polls.$JVB_HOSTNAME\" \"polls_component\"" >> $PROSODY_HOST_CONFIG
PROSODY_CONFIG_PRESENT="false"
fi
if ! grep -q -- '--"polls";' $PROSODY_HOST_CONFIG ;then
sed -i "s/\"polls\";/--\"polls\";/g" $PROSODY_HOST_CONFIG
PROSODY_CONFIG_PRESENT="false"
fi
# Old versions of jitsi-meet-prosody come with the extra plugin path commented out (https://github.com/jitsi/jitsi-meet/commit/e11d4d3101e5228bf956a69a9e8da73d0aee7949)
# Make sure it is uncommented, as it contains required modules.
if grep -q -- '--plugin_paths = { "/usr/share/jitsi-meet/prosody-plugins/" }' $PROSODY_HOST_CONFIG ;then

View File

@@ -83,7 +83,6 @@ Component "conference.jitmeet.example.com" "muc"
"muc_hide_all";
"muc_meeting_id";
"muc_domain_mapper";
"polls";
--"token_verification";
"muc_rate_limit";
"muc_password_whitelist";
@@ -159,9 +158,10 @@ Component "lobby.jitmeet.example.com" "muc"
modules_enabled = {
"muc_hide_all";
"muc_rate_limit";
"polls";
}
Component "metadata.jitmeet.example.com" "room_metadata_component"
muc_component = "conference.jitmeet.example.com"
breakout_rooms_component = "breakout.jitmeet.example.com"
Component "polls.jitmeet.example.com" "polls_component"

View File

@@ -964,6 +964,9 @@
"by": "By {{ name }}",
"closeButton": "Close poll",
"create": {
"accessibilityLabel": {
"send": "Send poll"
},
"addOption": "Add option",
"answerPlaceholder": "Option {{index}}",
"cancel": "Cancel",
@@ -972,8 +975,7 @@
"pollQuestion": "Poll Question",
"questionPlaceholder": "Ask a question",
"removeOption": "Remove option",
"save": "Save",
"send": "Send"
"save": "Save"
},
"errors": {
"notUniqueOption": "Options must be unique"

View File

@@ -107,6 +107,7 @@ export interface IJitsiConference {
getParticipantById: Function;
getParticipantCount: Function;
getParticipants: Function;
getPolls: Function;
getRole: Function;
getShortTermCredentials: Function;
getSpeakerStats: () => ISpeakerStats;

View File

@@ -22,6 +22,11 @@ interface ICheckboxProps {
*/
disabled?: boolean;
/**
* The id of the input.
*/
id?: string;
/**
* The label of the input.
*/
@@ -147,6 +152,7 @@ const Checkbox = ({
checked,
className,
disabled,
id,
label,
name,
onChange
@@ -160,6 +166,7 @@ const Checkbox = ({
<input
checked = { checked }
disabled = { disabled }
id = { id }
name = { name }
onChange = { onChange }
type = 'checkbox' />

View File

@@ -30,5 +30,11 @@ export function shouldDisplayNotifications(stateful: IStateful) {
export function arePollsDisabled(stateful: IStateful) {
const state = toState(stateful);
const { conference } = state['features/base/conference'];
if (!conference?.getPolls()?.isSupported()) {
return true;
}
return state['features/base/config']?.disablePolls || iAmVisitor(state);
}

View File

@@ -21,7 +21,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
for (const key in pollsHistory) {
if (pollsHistory.hasOwnProperty(key) && pollsHistory[key].saved) {
dispatch(savePoll(key, pollsHistory[key]));
dispatch(savePoll(pollsHistory[key]));
}
}
break;

View File

@@ -1,6 +1,6 @@
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { IPoll } from '../polls/types';
import { IPollData } from '../polls/types';
import { REMOVE_POLL_FROM_HISTORY, SAVE_POLL_IN_HISTORY } from './actionTypes';
@@ -11,7 +11,7 @@ const INITIAL_STATE = {
export interface IPollsHistoryState {
polls: {
[meetingId: string]: {
[pollId: string]: IPoll;
[pollId: string]: IPollData;
};
};
}

View File

@@ -35,7 +35,6 @@ export const EDIT_POLL = 'EDIT_POLL';
* {
* type: RECEIVE_POLL,
* poll: Poll,
* pollId: string,
* notify: boolean
* }
*
@@ -47,8 +46,7 @@ export const RECEIVE_POLL = 'RECEIVE_POLL';
*
* {
* type: RECEIVE_ANSWER,
* answer: Answer,
* pollId: string,
* answer: IIncomingAnswerData
* }
*/
export const RECEIVE_ANSWER = 'RECEIVE_ANSWER';
@@ -89,9 +87,7 @@ export const RESET_NB_UNREAD_POLLS = 'RESET_NB_UNREAD_POLLS';
*
* {
* type: SAVE_POLL,
* poll: Poll,
* pollId: string,
* saved: boolean
* poll: IPollData
* }
*/
export const SAVE_POLL = 'SAVE_POLL';

View File

@@ -9,7 +9,7 @@ import {
RESET_NB_UNREAD_POLLS,
SAVE_POLL
} from './actionTypes';
import { IAnswer, IPoll } from './types';
import { IIncomingAnswerData, IPoll, IPollData } from './types';
/**
* Action to signal that existing polls needs to be cleared from state.
@@ -47,7 +47,6 @@ export const setVoteChanging = (pollId: string, value: boolean) => {
/**
* Action to signal that a new poll was received.
*
* @param {string} pollId - The id of the incoming poll.
* @param {IPoll} poll - The incoming Poll object.
* @param {boolean} notify - Whether to send or not a notification.
* @returns {{
@@ -57,10 +56,9 @@ export const setVoteChanging = (pollId: string, value: boolean) => {
* notify: boolean
* }}
*/
export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
export const receivePoll = (poll: IPoll, notify: boolean) => {
return {
type: RECEIVE_POLL,
pollId,
poll,
notify
};
@@ -69,18 +67,15 @@ export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
/**
* Action to signal that a new answer was received.
*
* @param {string} pollId - The id of the incoming poll.
* @param {IAnswer} answer - The incoming Answer object.
* @param {IIncomingAnswerData} answer - The incoming Answer object.
* @returns {{
* type: RECEIVE_ANSWER,
* pollId: string,
* answer: IAnswer
* answer: IIncomingAnswerData
* }}
*/
export const receiveAnswer = (pollId: string, answer: IAnswer) => {
export const receiveAnswer = (answer: IIncomingAnswerData) => {
return {
type: RECEIVE_ANSWER,
pollId,
answer
};
};
@@ -120,19 +115,15 @@ export function resetNbUnreadPollsMessages() {
/**
* Action to signal saving a poll.
*
* @param {string} pollId - The id of the poll that gets to be saved.
* @param {IPoll} poll - The Poll object that gets to be saved.
* @param {IPollData} poll - The Poll object that gets to be saved.
* @returns {{
* type: SAVE_POLL,
* meetingId: string,
* pollId: string,
* poll: IPoll
* poll: IPollData
* }}
*/
export function savePoll(pollId: string, poll: IPoll) {
export function savePoll(poll: IPollData) {
return {
type: SAVE_POLL,
pollId,
poll
};
}
@@ -159,18 +150,15 @@ export function editPoll(pollId: string, editing: boolean) {
/**
* Action to signal that existing polls needs to be removed.
*
* @param {string} pollId - The id of the poll that gets to be removed.
* @param {IPoll} poll - The incoming Poll object.
* @returns {{
* type: REMOVE_POLL,
* pollId: string,
* poll: IPoll
* }}
*/
export const removePoll = (pollId: string, poll: IPoll) => {
export const removePoll = (poll: IPoll) => {
return {
type: REMOVE_POLL,
pollId,
poll
};
};

View File

@@ -8,9 +8,8 @@ import { IReduxState } from '../../app/types';
import { getParticipantDisplayName } from '../../base/participants/functions';
import { useBoundSelector } from '../../base/util/hooks';
import { registerVote, removePoll, setVoteChanging } from '../actions';
import { COMMAND_ANSWER_POLL, COMMAND_NEW_POLL } from '../constants';
import { getPoll } from '../functions';
import { IPoll } from '../types';
import { IPollData } from '../types';
/**
* The type of the React {@code Component} props of inheriting component.
@@ -27,8 +26,7 @@ type InputProps = {
export type AbstractProps = {
checkBoxStates: boolean[];
creatorName: string;
poll: IPoll;
pollId: string;
poll: IPollData;
sendPoll: () => void;
setCheckbox: Function;
setCreateMode: (mode: boolean) => void;
@@ -51,7 +49,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const poll: IPoll = useSelector(getPoll(pollId));
const poll: IPollData = useSelector(getPoll(pollId));
const { answers, lastVote, question, senderId } = poll;
@@ -76,11 +74,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
const dispatch = useDispatch();
const submitAnswer = useCallback(() => {
conference?.sendMessage({
type: COMMAND_ANSWER_POLL,
pollId,
answers: checkBoxStates
});
conference?.getPolls().answerPoll(pollId, checkBoxStates);
sendAnalytics(createPollEvent('vote.sent'));
dispatch(registerVote(pollId, checkBoxStates));
@@ -89,14 +83,9 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
}, [ pollId, checkBoxStates, conference ]);
const sendPoll = useCallback(() => {
conference?.sendMessage({
type: COMMAND_NEW_POLL,
pollId,
question,
answers: answers.map(answer => answer.name)
});
conference?.getPolls().createPoll(pollId, question, answers);
dispatch(removePoll(pollId, poll));
dispatch(removePoll(poll));
}, [ conference, question, answers ]);
const skipAnswer = useCallback(() => {
@@ -114,7 +103,6 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
checkBoxStates = { checkBoxStates }
creatorName = { participantName }
poll = { poll }
pollId = { pollId }
sendPoll = { sendPoll }
setCheckbox = { setCheckbox }
setCreateMode = { setCreateMode }

View File

@@ -10,7 +10,7 @@ import { IReduxState } from '../../app/types';
import { getLocalParticipant } from '../../base/participants/functions';
import { savePoll } from '../actions';
import { hasIdenticalAnswers } from '../functions';
import { IAnswerData, IPoll } from '../types';
import { IAnswerData, IPollData } from '../types';
/**
* The type of the React {@code Component} props of inheriting component.
@@ -26,7 +26,7 @@ type InputProps = {
export type AbstractProps = InputProps & {
addAnswer: (index?: number) => void;
answers: Array<IAnswerData>;
editingPoll: IPoll | undefined;
editingPoll: IPollData | undefined;
editingPollId: string | undefined;
isSubmitDisabled: boolean;
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
@@ -52,7 +52,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
const pollState = useSelector((state: IReduxState) => state['features/polls'].polls);
const editingPoll: [ string, IPoll ] | null = useMemo(() => {
const editingPoll: [ string, IPollData ] | null = useMemo(() => {
if (!pollState) {
return null;
}
@@ -71,12 +71,10 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
? editingPoll[1].answers
: [
{
name: '',
voters: []
name: ''
},
{
name: '',
voters: []
name: ''
} ];
}, [ editingPoll ]);
@@ -104,8 +102,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
sendAnalytics(createPollEvent('option.added'));
newAnswers.splice(typeof i === 'number'
? i : answers.length, 0, {
name: '',
voters: []
name: ''
});
setAnswers(newAnswers);
}, [ answers ]);
@@ -140,7 +137,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
return;
}
const poll = {
dispatch(savePoll({
changingVote: false,
senderId: localParticipant?.id,
showResults: false,
@@ -148,14 +145,9 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
question,
answers: filteredAnswers,
saved: true,
editing: false
};
if (editingPoll) {
dispatch(savePoll(editingPoll[0], poll));
} else {
dispatch(savePoll(pollId, poll));
}
editing: false,
pollId: editingPoll ? editingPoll[0] : pollId
}));
sendAnalytics(createPollEvent('created'));

View File

@@ -10,7 +10,7 @@ import { getParticipantById, getParticipantDisplayName } from '../../base/partic
import { useBoundSelector } from '../../base/util/hooks';
import { setVoteChanging } from '../actions';
import { getPoll } from '../functions';
import { IPoll } from '../types';
import { IAnswerData, IPollData, IVoterData } from '../types';
/**
* The type of the React {@code Component} props of inheriting component.
@@ -23,11 +23,9 @@ type InputProps = {
pollId: string;
};
export type AnswerInfo = {
name: string;
export type AnswerInfo = IAnswerData & {
percentage: number;
voterCount: number;
voters?: Array<{ id: string; name: string; } | undefined>;
};
/**
@@ -38,6 +36,7 @@ export type AbstractProps = {
changeVote: (e?: React.MouseEvent<HTMLButtonElement> | GestureResponderEvent) => void;
creatorName: string;
haveVoted: boolean;
pollId: string;
question: string;
showDetails: boolean;
t: Function;
@@ -54,8 +53,8 @@ export type AbstractProps = {
const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
const { pollId } = props;
const poll: IPoll = useSelector(getPoll(pollId));
const participant = useBoundSelector(getParticipantById, poll.senderId);
const poll: IPollData = useSelector(getPoll(pollId));
const creatorName = useBoundSelector(getParticipantDisplayName, poll.senderId);
const reduxState = useSelector((state: IReduxState) => state);
const [ showDetails, setShowDetails ] = useState(false);
@@ -69,33 +68,27 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
// Getting every voters ID that participates to the poll
for (const answer of poll.answers) {
// checking if the voters is an array for supporting old structure model
const voters: string[] = answer.voters.length ? answer.voters : Object.keys(answer.voters);
voters.forEach((voter: string) => allVoters.add(voter));
answer.voters?.forEach(k => allVoters.add(k.id));
}
return poll.answers.map(answer => {
const nrOfVotersPerAnswer = answer.voters ? Object.keys(answer.voters).length : 0;
const nrOfVotersPerAnswer = answer.voters?.length || 0;
const percentage = allVoters.size > 0 ? Math.round(nrOfVotersPerAnswer / allVoters.size * 100) : 0;
let voters;
if (showDetails && answer.voters) {
const answerVoters = answer.voters?.length ? [ ...answer.voters ] : Object.keys({ ...answer.voters });
voters = answerVoters.map(id => {
return {
id,
name: getParticipantDisplayName(reduxState, id)
};
const voters = answer.voters?.reduce((acc, v) => {
acc.push({
id: v.id,
name: getParticipantById(reduxState, v.id)
? getParticipantDisplayName(reduxState, v.id) : v.name
});
}
return acc;
}, [] as Array<IVoterData>);
return {
name: answer.name,
percentage,
voters,
voters: voters,
voterCount: nrOfVotersPerAnswer
};
});
@@ -113,8 +106,9 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
<Component
answers = { answers }
changeVote = { changeVote }
creatorName = { participant ? participant.name : '' }
creatorName = { creatorName }
haveVoted = { poll.lastVote !== null }
pollId = { pollId }
question = { poll.question }
showDetails = { showDetails }
t = { t }

View File

@@ -20,7 +20,6 @@ const PollAnswer = (props: AbstractProps) => {
const {
checkBoxStates,
poll,
pollId,
sendPoll,
setCheckbox,
setCreateMode,
@@ -46,7 +45,7 @@ const PollAnswer = (props: AbstractProps) => {
</View>
{
pollSaved && <IconButton
onPress = { () => dispatch(removePoll(pollId, poll)) }
onPress = { () => dispatch(removePoll(poll)) }
src = { IconCloseLarge } />
}
</View>
@@ -79,7 +78,7 @@ const PollAnswer = (props: AbstractProps) => {
labelKey = 'polls.answer.edit'
onClick = { () => {
setCreateMode(true);
dispatch(editPoll(pollId, true));
dispatch(editPoll(poll.pollId, true));
} }
style = { pollsStyles.pollCreateButton }
type = { SECONDARY } />

View File

@@ -122,8 +122,7 @@ const PollCreate = (props: AbstractProps) => {
maxLength = { CHAR_LIMIT }
onChange = { name => setAnswer(index,
{
name,
voters: []
name
}) }
onKeyPress = { ev => onAnswerKeyDown(index, ev) }
placeholder = { t('polls.create.answerPlaceholder', { index: index + 1 }) }

View File

@@ -65,11 +65,11 @@ const PollResults = (props: AbstractProps) => {
{ voters && voterCount > 0
&& <View style = { resultsStyles.voters as ViewStyle }>
{/* @ts-ignore */}
{voters.map(({ id, name: voterName }) =>
{voters.map(voter =>
(<Text
key = { id }
key = { voter.id }
style = { resultsStyles.voter as TextStyle }>
{ voterName }
{ voter.name }
</Text>)
)}
</View>}

View File

@@ -62,7 +62,6 @@ const PollAnswer = ({
creatorName,
checkBoxStates,
poll,
pollId,
setCheckbox,
setCreateMode,
skipAnswer,
@@ -77,12 +76,14 @@ const PollAnswer = ({
const { classes } = useStyles();
return (
<div className = { classes.container }>
<div
className = { classes.container }
id = { `poll-${poll.pollId}` }>
{
pollSaved && <Icon
ariaLabel = { t('polls.closeButton') }
className = { classes.closeBtn }
onClick = { () => dispatch(removePoll(pollId, poll)) }
onClick = { () => dispatch(removePoll(poll)) }
role = 'button'
src = { IconCloseLarge }
tabIndex = { 0 } />
@@ -104,6 +105,7 @@ const PollAnswer = ({
<Checkbox
checked = { checkBoxStates[index] }
disabled = { poll.saved }
id = { `poll-answer-checkbox-${poll.pollId}-${index}` }
key = { index }
label = { answer.name }
onChange = { ev => setCheckbox(index, ev.target.checked) } />
@@ -120,11 +122,11 @@ const PollAnswer = ({
labelKey = { 'polls.answer.edit' }
onClick = { () => {
setCreateMode(true);
dispatch(editPoll(pollId, true));
dispatch(editPoll(poll.pollId, true));
} }
type = { BUTTON_TYPES.SECONDARY } />
<Button
accessibilityLabel = { t('polls.answer.send') }
accessibilityLabel = { t('polls.create.accessibilityLabel.send') }
labelKey = { 'polls.answer.send' }
onClick = { sendPoll } />
</> : <>

View File

@@ -223,8 +223,7 @@ const PollCreate = ({
label = { t('polls.create.pollOption', { index: i + 1 }) }
maxLength = { CHAR_LIMIT }
onChange = { name => setAnswer(i, {
name,
voters: []
name
}) }
onKeyPress = { ev => onAnswerKeyDown(i, ev) }
placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
@@ -235,6 +234,7 @@ const PollCreate = ({
{ answers.length > 2
&& <button
className = { classes.removeOption }
data-testid = { `remove-polls-answer-input-${i}` }
onClick = { () => removeAnswer(i) }
type = 'button'>
{ t('polls.create.removeOption') }

View File

@@ -113,6 +113,7 @@ const PollResults = ({
changeVote,
creatorName,
haveVoted,
pollId,
showDetails,
question,
t,
@@ -121,7 +122,9 @@ const PollResults = ({
const { classes } = useStyles();
return (
<div className = { classes.container }>
<div
className = { classes.container }
id = { `poll-${pollId}` }>
<div className = { classes.header }>
<div className = { classes.question }>
{question}
@@ -136,7 +139,9 @@ const PollResults = ({
<div className = { classes.answerName }>
{name}
</div>
<div className = { classes.answerResultContainer }>
<div
className = { classes.answerResultContainer }
id = { `poll-result-${pollId}-${index}` }>
<span className = { classes.barContainer }>
<div
className = { classes.bar }
@@ -148,8 +153,8 @@ const PollResults = ({
</div>
{showDetails && voters && voterCount > 0
&& <ul className = { classes.voters }>
{voters.map(voter =>
<li key = { voter?.id }>{voter?.name}</li>
{ voters.map(voter =>
<li key = { voter.id }>{ voter.name }</li>
)}
</ul>}
</li>)

View File

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

View File

@@ -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<IPollData>): 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));
}

View File

@@ -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<IPollsState>(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<IPollsState>(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<IPollsState>(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<IPollsState>(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<IPollsState>(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<IPollsState>(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,

View File

@@ -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<IAnswerData>;
/**
* 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<boolean> | 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<string>;
voters?: Array<IVoterData>;
}

View File

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

View File

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

View File

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

View File

@@ -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%)');
});
});