This commit is contained in:
97
react/features/polls/actionTypes.ts
Normal file
97
react/features/polls/actionTypes.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The type of the action which signals that a Poll will be changed
|
||||
*
|
||||
* {
|
||||
* type: CHANGE_VOTE,
|
||||
* }
|
||||
*
|
||||
*/
|
||||
export const CHANGE_VOTE = 'CHANGE_VOTE';
|
||||
|
||||
/**
|
||||
* The type of the action which signals that we need to clear all polls from the state.
|
||||
* For example, we are moving to another conference.
|
||||
*
|
||||
* {
|
||||
* type: CLEAR_POLLS
|
||||
* }
|
||||
*/
|
||||
export const CLEAR_POLLS = 'CLEAR_POLLS';
|
||||
|
||||
/**
|
||||
* The type of the action triggered when the poll is editing.
|
||||
*
|
||||
* {
|
||||
* type: EDIT_POLL,
|
||||
* pollId: string,
|
||||
* editing: boolean
|
||||
* }
|
||||
*/
|
||||
export const EDIT_POLL = 'EDIT_POLL';
|
||||
|
||||
/**
|
||||
* The type of the action which signals that a new Poll was received.
|
||||
*
|
||||
* {
|
||||
* type: RECEIVE_POLL,
|
||||
* poll: Poll,
|
||||
* pollId: string,
|
||||
* notify: boolean
|
||||
* }
|
||||
*
|
||||
*/
|
||||
export const RECEIVE_POLL = 'RECEIVE_POLL';
|
||||
|
||||
/**
|
||||
* The type of the action which signals that a new Answer was received.
|
||||
*
|
||||
* {
|
||||
* type: RECEIVE_ANSWER,
|
||||
* answer: Answer,
|
||||
* pollId: string,
|
||||
* }
|
||||
*/
|
||||
export const RECEIVE_ANSWER = 'RECEIVE_ANSWER';
|
||||
|
||||
/**
|
||||
* The type of the action which registers a vote.
|
||||
*
|
||||
* {
|
||||
* type: REGISTER_VOTE,
|
||||
* answers: Array<boolean> | null,
|
||||
* pollId: string
|
||||
* }
|
||||
*/
|
||||
export const REGISTER_VOTE = 'REGISTER_VOTE';
|
||||
|
||||
/**
|
||||
* The type of the action which signals that we need to remove poll.
|
||||
*
|
||||
* {
|
||||
* type: REMOVE_POLL,
|
||||
* pollId: string,
|
||||
* poll: IPoll
|
||||
* }
|
||||
*/
|
||||
export const REMOVE_POLL = 'REMOVE_POLL';
|
||||
|
||||
/**
|
||||
* The type of the action triggered when the poll tab in chat pane is closed
|
||||
*
|
||||
* {
|
||||
* type: RESET_NB_UNREAD_POLLS,
|
||||
* }
|
||||
*/
|
||||
export const RESET_NB_UNREAD_POLLS = 'RESET_NB_UNREAD_POLLS';
|
||||
|
||||
/**
|
||||
* The type of the action triggered when the poll is saved.
|
||||
*
|
||||
* {
|
||||
* type: SAVE_POLL,
|
||||
* poll: Poll,
|
||||
* pollId: string,
|
||||
* saved: boolean
|
||||
* }
|
||||
*/
|
||||
export const SAVE_POLL = 'SAVE_POLL';
|
||||
176
react/features/polls/actions.ts
Normal file
176
react/features/polls/actions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
CHANGE_VOTE,
|
||||
CLEAR_POLLS,
|
||||
EDIT_POLL,
|
||||
RECEIVE_ANSWER,
|
||||
RECEIVE_POLL,
|
||||
REGISTER_VOTE,
|
||||
REMOVE_POLL,
|
||||
RESET_NB_UNREAD_POLLS,
|
||||
SAVE_POLL
|
||||
} from './actionTypes';
|
||||
import { IAnswer, IPoll } from './types';
|
||||
|
||||
/**
|
||||
* Action to signal that existing polls needs to be cleared from state.
|
||||
*
|
||||
* @returns {{
|
||||
* type: CLEAR_POLLS
|
||||
* }}
|
||||
*/
|
||||
export const clearPolls = () => {
|
||||
return {
|
||||
type: CLEAR_POLLS
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to signal that a poll's vote will be changed.
|
||||
*
|
||||
* @param {string} pollId - The id of the incoming poll.
|
||||
* @param {boolean} value - The value of the 'changing' state.
|
||||
|
||||
* @returns {{
|
||||
* type: CHANGE_VOTE,
|
||||
* pollId: string,
|
||||
* value: boolean
|
||||
* }}
|
||||
*/
|
||||
export const setVoteChanging = (pollId: string, value: boolean) => {
|
||||
return {
|
||||
type: CHANGE_VOTE,
|
||||
pollId,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {{
|
||||
* type: RECEIVE_POLL,
|
||||
* pollId: string,
|
||||
* poll: IPoll,
|
||||
* notify: boolean
|
||||
* }}
|
||||
*/
|
||||
export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
|
||||
return {
|
||||
type: RECEIVE_POLL,
|
||||
pollId,
|
||||
poll,
|
||||
notify
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {{
|
||||
* type: RECEIVE_ANSWER,
|
||||
* pollId: string,
|
||||
* answer: IAnswer
|
||||
* }}
|
||||
*/
|
||||
export const receiveAnswer = (pollId: string, answer: IAnswer) => {
|
||||
return {
|
||||
type: RECEIVE_ANSWER,
|
||||
pollId,
|
||||
answer
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to register a vote on a poll.
|
||||
*
|
||||
* @param {string} pollId - The id of the poll.
|
||||
* @param {?Array<boolean>} answers - The new answers.
|
||||
* @returns {{
|
||||
* type: REGISTER_VOTE,
|
||||
* pollId: string,
|
||||
* answers: ?Array<boolean>
|
||||
* }}
|
||||
*/
|
||||
export const registerVote = (pollId: string, answers: Array<boolean> | null) => {
|
||||
return {
|
||||
type: REGISTER_VOTE,
|
||||
pollId,
|
||||
answers
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to signal the number reset of unread polls.
|
||||
*
|
||||
* @returns {{
|
||||
* type: RESET_NB_UNREAD_POLLS
|
||||
* }}
|
||||
*/
|
||||
export function resetNbUnreadPollsMessages() {
|
||||
return {
|
||||
type: RESET_NB_UNREAD_POLLS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {{
|
||||
* type: SAVE_POLL,
|
||||
* meetingId: string,
|
||||
* pollId: string,
|
||||
* poll: IPoll
|
||||
* }}
|
||||
*/
|
||||
export function savePoll(pollId: string, poll: IPoll) {
|
||||
return {
|
||||
type: SAVE_POLL,
|
||||
pollId,
|
||||
poll
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to signal editing a poll.
|
||||
*
|
||||
* @param {string} pollId - The id of the poll that gets to be edited.
|
||||
* @param {boolean} editing - Whether the poll is in edit mode or not.
|
||||
* @returns {{
|
||||
* type: EDIT_POLL,
|
||||
* pollId: string,
|
||||
* editing: boolean
|
||||
* }}
|
||||
*/
|
||||
export function editPoll(pollId: string, editing: boolean) {
|
||||
return {
|
||||
type: EDIT_POLL,
|
||||
pollId,
|
||||
editing
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
return {
|
||||
type: REMOVE_POLL,
|
||||
pollId,
|
||||
poll
|
||||
};
|
||||
};
|
||||
128
react/features/polls/components/AbstractPollAnswer.tsx
Normal file
128
react/features/polls/components/AbstractPollAnswer.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { ComponentType, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { createPollEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
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';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
*/
|
||||
type InputProps = {
|
||||
pollId: string;
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Props that will be passed by the AbstractPollAnswer to its
|
||||
* concrete implementations (web/native).
|
||||
**/
|
||||
export type AbstractProps = {
|
||||
checkBoxStates: boolean[];
|
||||
creatorName: string;
|
||||
poll: IPoll;
|
||||
pollId: string;
|
||||
sendPoll: () => void;
|
||||
setCheckbox: Function;
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
skipAnswer: () => void;
|
||||
skipChangeVote: () => void;
|
||||
submitAnswer: () => void;
|
||||
t: Function;
|
||||
};
|
||||
|
||||
/**
|
||||
* Higher Order Component taking in a concrete PollAnswer component and
|
||||
* augmenting it with state/behavior common to both web and native implementations.
|
||||
*
|
||||
* @param {React.AbstractComponent} Component - The concrete component.
|
||||
* @returns {React.AbstractComponent}
|
||||
*/
|
||||
const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
|
||||
|
||||
const { pollId, setCreateMode } = props;
|
||||
|
||||
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
|
||||
|
||||
const poll: IPoll = useSelector(getPoll(pollId));
|
||||
|
||||
const { answers, lastVote, question, senderId } = poll;
|
||||
|
||||
const [ checkBoxStates, setCheckBoxState ] = useState(() => {
|
||||
if (lastVote !== null) {
|
||||
return [ ...lastVote ];
|
||||
}
|
||||
|
||||
return new Array(answers.length).fill(false);
|
||||
});
|
||||
|
||||
const participantName = useBoundSelector(getParticipantDisplayName, senderId);
|
||||
|
||||
const setCheckbox = useCallback((index, state) => {
|
||||
const newCheckBoxStates = [ ...checkBoxStates ];
|
||||
|
||||
newCheckBoxStates[index] = state;
|
||||
setCheckBoxState(newCheckBoxStates);
|
||||
sendAnalytics(createPollEvent('vote.checked'));
|
||||
}, [ checkBoxStates ]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
conference?.sendMessage({
|
||||
type: COMMAND_ANSWER_POLL,
|
||||
pollId,
|
||||
answers: checkBoxStates
|
||||
});
|
||||
|
||||
sendAnalytics(createPollEvent('vote.sent'));
|
||||
dispatch(registerVote(pollId, checkBoxStates));
|
||||
|
||||
return false;
|
||||
}, [ pollId, checkBoxStates, conference ]);
|
||||
|
||||
const sendPoll = useCallback(() => {
|
||||
conference?.sendMessage({
|
||||
type: COMMAND_NEW_POLL,
|
||||
pollId,
|
||||
question,
|
||||
answers: answers.map(answer => answer.name)
|
||||
});
|
||||
|
||||
dispatch(removePoll(pollId, poll));
|
||||
}, [ conference, question, answers ]);
|
||||
|
||||
const skipAnswer = useCallback(() => {
|
||||
dispatch(registerVote(pollId, null));
|
||||
sendAnalytics(createPollEvent('vote.skipped'));
|
||||
}, [ pollId ]);
|
||||
|
||||
const skipChangeVote = useCallback(() => {
|
||||
dispatch(setVoteChanging(pollId, false));
|
||||
}, [ dispatch, pollId ]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (<Component
|
||||
checkBoxStates = { checkBoxStates }
|
||||
creatorName = { participantName }
|
||||
poll = { poll }
|
||||
pollId = { pollId }
|
||||
sendPoll = { sendPoll }
|
||||
setCheckbox = { setCheckbox }
|
||||
setCreateMode = { setCreateMode }
|
||||
skipAnswer = { skipAnswer }
|
||||
skipChangeVote = { skipChangeVote }
|
||||
submitAnswer = { submitAnswer }
|
||||
t = { t } />);
|
||||
|
||||
};
|
||||
|
||||
export default AbstractPollAnswer;
|
||||
190
react/features/polls/components/AbstractPollCreate.tsx
Normal file
190
react/features/polls/components/AbstractPollCreate.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/* eslint-disable arrow-body-style */
|
||||
|
||||
import React, { ComponentType, FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { createPollEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { getLocalParticipant } from '../../base/participants/functions';
|
||||
import { savePoll } from '../actions';
|
||||
import { hasIdenticalAnswers } from '../functions';
|
||||
import { IAnswerData, IPoll } from '../types';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
*/
|
||||
type InputProps = {
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Props that will be passed by the AbstractPollCreate to its
|
||||
* concrete implementations (web/native).
|
||||
**/
|
||||
export type AbstractProps = InputProps & {
|
||||
addAnswer: (index?: number) => void;
|
||||
answers: Array<IAnswerData>;
|
||||
editingPoll: IPoll | undefined;
|
||||
editingPollId: string | undefined;
|
||||
isSubmitDisabled: boolean;
|
||||
onSubmit: (event?: FormEvent<HTMLFormElement>) => void;
|
||||
question: string;
|
||||
removeAnswer: (index: number) => void;
|
||||
setAnswer: (index: number, value: IAnswerData) => void;
|
||||
setQuestion: (question: string) => void;
|
||||
t: Function;
|
||||
};
|
||||
|
||||
/**
|
||||
* Higher Order Component taking in a concrete PollCreate component and
|
||||
* augmenting it with state/behavior common to both web and native implementations.
|
||||
*
|
||||
* @param {React.AbstractComponent} Component - The concrete component.
|
||||
* @returns {React.AbstractComponent}
|
||||
*/
|
||||
|
||||
|
||||
const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
|
||||
|
||||
const { setCreateMode } = props;
|
||||
|
||||
const pollState = useSelector((state: IReduxState) => state['features/polls'].polls);
|
||||
|
||||
const editingPoll: [ string, IPoll ] | null = useMemo(() => {
|
||||
if (!pollState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key in pollState) {
|
||||
if (pollState.hasOwnProperty(key) && pollState[key].editing) {
|
||||
return [ key, pollState[key] ];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ pollState ]);
|
||||
|
||||
const answerResults = useMemo(() => {
|
||||
return editingPoll
|
||||
? editingPoll[1].answers
|
||||
: [
|
||||
{
|
||||
name: '',
|
||||
voters: []
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
voters: []
|
||||
} ];
|
||||
}, [ editingPoll ]);
|
||||
|
||||
const questionResult = useMemo(() => {
|
||||
return editingPoll ? editingPoll[1].question : '';
|
||||
}, [ editingPoll ]);
|
||||
|
||||
const [ question, setQuestion ] = useState(questionResult);
|
||||
|
||||
const [ answers, setAnswers ] = useState(answerResults);
|
||||
|
||||
const setAnswer = useCallback((i: number, answer: IAnswerData) => {
|
||||
setAnswers(currentAnswers => {
|
||||
const newAnswers = [ ...currentAnswers ];
|
||||
|
||||
newAnswers[i] = answer;
|
||||
|
||||
return newAnswers;
|
||||
});
|
||||
}, [ answers ]);
|
||||
|
||||
const addAnswer = useCallback((i?: number) => {
|
||||
const newAnswers: Array<IAnswerData> = [ ...answers ];
|
||||
|
||||
sendAnalytics(createPollEvent('option.added'));
|
||||
newAnswers.splice(typeof i === 'number'
|
||||
? i : answers.length, 0, {
|
||||
name: '',
|
||||
voters: []
|
||||
});
|
||||
setAnswers(newAnswers);
|
||||
}, [ answers ]);
|
||||
|
||||
const removeAnswer = useCallback(i => {
|
||||
if (answers.length <= 2) {
|
||||
return;
|
||||
}
|
||||
const newAnswers = [ ...answers ];
|
||||
|
||||
sendAnalytics(createPollEvent('option.removed'));
|
||||
newAnswers.splice(i, 1);
|
||||
setAnswers(newAnswers);
|
||||
}, [ answers ]);
|
||||
|
||||
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const pollId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
|
||||
|
||||
const localParticipant = useSelector(getLocalParticipant);
|
||||
|
||||
const onSubmit = useCallback(ev => {
|
||||
if (ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const filteredAnswers = answers.filter(answer => answer.name.trim().length > 0);
|
||||
|
||||
if (filteredAnswers.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = {
|
||||
changingVote: false,
|
||||
senderId: localParticipant?.id,
|
||||
showResults: false,
|
||||
lastVote: null,
|
||||
question,
|
||||
answers: filteredAnswers,
|
||||
saved: true,
|
||||
editing: false
|
||||
};
|
||||
|
||||
if (editingPoll) {
|
||||
dispatch(savePoll(editingPoll[0], poll));
|
||||
} else {
|
||||
dispatch(savePoll(pollId, poll));
|
||||
}
|
||||
|
||||
sendAnalytics(createPollEvent('created'));
|
||||
|
||||
setCreateMode(false);
|
||||
|
||||
}, [ conference, question, answers ]);
|
||||
|
||||
// Check if the poll create form can be submitted i.e. if the send button should be disabled.
|
||||
const isSubmitDisabled
|
||||
= question.trim().length <= 0 // If no question is provided
|
||||
|| answers.filter(answer => answer.name.trim().length > 0).length < 2 // If not enough options are provided
|
||||
|| hasIdenticalAnswers(answers); // If duplicate options are provided
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (<Component
|
||||
addAnswer = { addAnswer }
|
||||
answers = { answers }
|
||||
editingPoll = { editingPoll?.[1] }
|
||||
editingPollId = { editingPoll?.[0] }
|
||||
isSubmitDisabled = { isSubmitDisabled }
|
||||
onSubmit = { onSubmit }
|
||||
question = { question }
|
||||
removeAnswer = { removeAnswer }
|
||||
setAnswer = { setAnswer }
|
||||
setCreateMode = { setCreateMode }
|
||||
setQuestion = { setQuestion }
|
||||
t = { t } />);
|
||||
|
||||
};
|
||||
|
||||
export default AbstractPollCreate;
|
||||
125
react/features/polls/components/AbstractPollResults.tsx
Normal file
125
react/features/polls/components/AbstractPollResults.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { ComponentType, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GestureResponderEvent } from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { createPollEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { getParticipantById, getParticipantDisplayName } from '../../base/participants/functions';
|
||||
import { useBoundSelector } from '../../base/util/hooks';
|
||||
import { setVoteChanging } from '../actions';
|
||||
import { getPoll } from '../functions';
|
||||
import { IPoll } from '../types';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of inheriting component.
|
||||
*/
|
||||
type InputProps = {
|
||||
|
||||
/**
|
||||
* ID of the poll to display.
|
||||
*/
|
||||
pollId: string;
|
||||
};
|
||||
|
||||
export type AnswerInfo = {
|
||||
name: string;
|
||||
percentage: number;
|
||||
voterCount: number;
|
||||
voters?: Array<{ id: string; name: string; } | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link AbstractPollResults}.
|
||||
*/
|
||||
export type AbstractProps = {
|
||||
answers: Array<AnswerInfo>;
|
||||
changeVote: (e?: React.MouseEvent<HTMLButtonElement> | GestureResponderEvent) => void;
|
||||
creatorName: string;
|
||||
haveVoted: boolean;
|
||||
question: string;
|
||||
showDetails: boolean;
|
||||
t: Function;
|
||||
toggleIsDetailed: (e?: React.MouseEvent<HTMLButtonElement> | GestureResponderEvent) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Higher Order Component taking in a concrete PollResult component and
|
||||
* augmenting it with state/behavior common to both web and native implementations.
|
||||
*
|
||||
* @param {React.AbstractComponent} Component - The concrete component.
|
||||
* @returns {React.AbstractComponent}
|
||||
*/
|
||||
const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
|
||||
const { pollId } = props;
|
||||
|
||||
const poll: IPoll = useSelector(getPoll(pollId));
|
||||
const participant = useBoundSelector(getParticipantById, poll.senderId);
|
||||
const reduxState = useSelector((state: IReduxState) => state);
|
||||
|
||||
const [ showDetails, setShowDetails ] = useState(false);
|
||||
const toggleIsDetailed = useCallback(() => {
|
||||
sendAnalytics(createPollEvent('vote.detailsViewed'));
|
||||
setShowDetails(details => !details);
|
||||
}, []);
|
||||
|
||||
const answers: Array<AnswerInfo> = useMemo(() => {
|
||||
const allVoters = new Set();
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
return poll.answers.map(answer => {
|
||||
const nrOfVotersPerAnswer = answer.voters ? Object.keys(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)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name: answer.name,
|
||||
percentage,
|
||||
voters,
|
||||
voterCount: nrOfVotersPerAnswer
|
||||
};
|
||||
});
|
||||
}, [ poll.answers, showDetails ]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const changeVote = useCallback(() => {
|
||||
dispatch(setVoteChanging(pollId, true));
|
||||
sendAnalytics(createPollEvent('vote.changed'));
|
||||
}, [ dispatch, pollId ]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Component
|
||||
answers = { answers }
|
||||
changeVote = { changeVote }
|
||||
creatorName = { participant ? participant.name : '' }
|
||||
haveVoted = { poll.lastVote !== null }
|
||||
question = { poll.question }
|
||||
showDetails = { showDetails }
|
||||
t = { t }
|
||||
toggleIsDetailed = { toggleIsDetailed } />
|
||||
);
|
||||
};
|
||||
|
||||
export default AbstractPollResults;
|
||||
47
react/features/polls/components/AbstractPollsPane.tsx
Normal file
47
react/features/polls/components/AbstractPollsPane.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { ComponentType, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { isCreatePollDisabled } from '../functions';
|
||||
|
||||
/*
|
||||
* Props that will be passed by the AbstractPollsPane to its
|
||||
* concrete implementations (web/native).
|
||||
**/
|
||||
export type AbstractProps = {
|
||||
createMode: boolean;
|
||||
isCreatePollsDisabled: boolean;
|
||||
onCreate: () => void;
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
t: Function;
|
||||
};
|
||||
|
||||
/**
|
||||
* Higher Order Component taking in a concrete PollsPane component and
|
||||
* augmenting it with state/behavior common to both web and native implementations.
|
||||
*
|
||||
* @param {React.AbstractComponent} Component - The concrete component.
|
||||
* @returns {React.AbstractComponent}
|
||||
*/
|
||||
const AbstractPollsPane = (Component: ComponentType<AbstractProps>) => () => {
|
||||
|
||||
const isCreatePollsDisabled = useSelector(isCreatePollDisabled);
|
||||
const [ createMode, setCreateMode ] = useState(false);
|
||||
|
||||
const onCreate = () => {
|
||||
setCreateMode(true);
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (<Component
|
||||
createMode = { createMode }
|
||||
isCreatePollsDisabled = { isCreatePollsDisabled }
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
onCreate = { onCreate }
|
||||
setCreateMode = { setCreateMode }
|
||||
t = { t } />);
|
||||
|
||||
};
|
||||
|
||||
export default AbstractPollsPane;
|
||||
121
react/features/polls/components/native/PollAnswer.tsx
Normal file
121
react/features/polls/components/native/PollAnswer.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
|
||||
import React from 'react';
|
||||
import { Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import { getLocalParticipant } from '../../../base/participants/functions';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import IconButton from '../../../base/ui/components/native/IconButton';
|
||||
import Switch from '../../../base/ui/components/native/Switch';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { editPoll, removePoll } from '../../actions';
|
||||
import { isSubmitAnswerDisabled } from '../../functions';
|
||||
import AbstractPollAnswer, { AbstractProps } from '../AbstractPollAnswer';
|
||||
|
||||
import { dialogStyles, pollsStyles } from './styles';
|
||||
|
||||
const PollAnswer = (props: AbstractProps) => {
|
||||
const {
|
||||
checkBoxStates,
|
||||
poll,
|
||||
pollId,
|
||||
sendPoll,
|
||||
setCheckbox,
|
||||
setCreateMode,
|
||||
skipAnswer,
|
||||
skipChangeVote,
|
||||
submitAnswer,
|
||||
t
|
||||
} = props;
|
||||
const { changingVote, saved: pollSaved } = poll;
|
||||
const dispatch = useDispatch();
|
||||
const localParticipant = useSelector(getLocalParticipant);
|
||||
const { PRIMARY, SECONDARY } = BUTTON_TYPES;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style = { dialogStyles.headerContainer as ViewStyle }>
|
||||
<View>
|
||||
<Text style = { dialogStyles.questionText as TextStyle } >{ poll.question }</Text>
|
||||
<Text style = { dialogStyles.questionOwnerText as TextStyle } >{
|
||||
t('polls.by', { name: localParticipant?.name })
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
{
|
||||
pollSaved && <IconButton
|
||||
onPress = { () => dispatch(removePoll(pollId, poll)) }
|
||||
src = { IconCloseLarge } />
|
||||
}
|
||||
</View>
|
||||
<View
|
||||
id = 'answer-content'
|
||||
style = { pollsStyles.answerContent as ViewStyle }>
|
||||
{
|
||||
poll.answers.map((answer, index: number) => (
|
||||
<View
|
||||
key = { index }
|
||||
style = { pollsStyles.switchRow as ViewStyle } >
|
||||
<Switch
|
||||
checked = { checkBoxStates[index] }
|
||||
disabled = { poll.saved }
|
||||
id = 'answer-switch'
|
||||
onChange = { state => setCheckbox(index, state) } />
|
||||
<Text style = { pollsStyles.switchLabel as TextStyle }>
|
||||
{ answer.name }
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
}
|
||||
</View>
|
||||
{
|
||||
pollSaved
|
||||
? <View style = { pollsStyles.buttonRow as ViewStyle }>
|
||||
<Button
|
||||
accessibilityLabel = 'polls.answer.edit'
|
||||
id = { t('polls.answer.edit') }
|
||||
labelKey = 'polls.answer.edit'
|
||||
onClick = { () => {
|
||||
setCreateMode(true);
|
||||
dispatch(editPoll(pollId, true));
|
||||
} }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = 'polls.answer.send'
|
||||
id = { t('polls.answer.send') }
|
||||
labelKey = 'polls.answer.send'
|
||||
onClick = { sendPoll }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { PRIMARY } />
|
||||
</View>
|
||||
: <View style = { pollsStyles.buttonRow as ViewStyle }>
|
||||
<Button
|
||||
accessibilityLabel = 'polls.answer.skip'
|
||||
id = { t('polls.answer.skip') }
|
||||
labelKey = 'polls.answer.skip'
|
||||
onClick = { changingVote ? skipChangeVote : skipAnswer }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = 'polls.answer.submit'
|
||||
disabled = { isSubmitAnswerDisabled(checkBoxStates) }
|
||||
id = { t('polls.answer.submit') }
|
||||
labelKey = 'polls.answer.submit'
|
||||
onClick = { submitAnswer }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { PRIMARY } />
|
||||
</View>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollAnswer to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollAnswer(PollAnswer);
|
||||
220
react/features/polls/components/native/PollCreate.tsx
Normal file
220
react/features/polls/components/native/PollCreate.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FlatList, Platform, TextInput, View, ViewStyle } from 'react-native';
|
||||
import { Divider } from 'react-native-paper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import Input from '../../../base/ui/components/native/Input';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { editPoll } from '../../actions';
|
||||
import { ANSWERS_LIMIT, CHAR_LIMIT } from '../../constants';
|
||||
import AbstractPollCreate, { AbstractProps } from '../AbstractPollCreate';
|
||||
|
||||
import { dialogStyles, pollsStyles } from './styles';
|
||||
|
||||
const PollCreate = (props: AbstractProps) => {
|
||||
const {
|
||||
addAnswer,
|
||||
answers,
|
||||
editingPoll,
|
||||
editingPollId,
|
||||
isSubmitDisabled,
|
||||
onSubmit,
|
||||
question,
|
||||
removeAnswer,
|
||||
setAnswer,
|
||||
setCreateMode,
|
||||
setQuestion,
|
||||
t
|
||||
} = props;
|
||||
|
||||
const answerListRef = useRef<FlatList>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/*
|
||||
* This ref stores the Array of answer input fields, allowing us to focus on them.
|
||||
* This array is maintained by registerFieldRef and the useEffect below.
|
||||
*/
|
||||
const answerInputs = useRef<TextInput[]>([]);
|
||||
const registerFieldRef = useCallback((i, input) => {
|
||||
if (input === null) {
|
||||
return;
|
||||
}
|
||||
answerInputs.current[i] = input;
|
||||
}, [ answerInputs ]);
|
||||
|
||||
useEffect(() => {
|
||||
answerInputs.current = answerInputs.current.slice(0, answers.length);
|
||||
setTimeout(() => {
|
||||
answerListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 1000);
|
||||
}, [ answers ]);
|
||||
|
||||
/*
|
||||
* This state allows us to requestFocus asynchronously, without having to worry
|
||||
* about whether a newly created input field has been rendered yet or not.
|
||||
*/
|
||||
const [ lastFocus, requestFocus ] = useState<number | null>(null);
|
||||
const { PRIMARY, SECONDARY, TERTIARY } = BUTTON_TYPES;
|
||||
|
||||
useEffect(() => {
|
||||
if (lastFocus === null) {
|
||||
return;
|
||||
}
|
||||
const input = answerInputs.current[lastFocus];
|
||||
|
||||
if (input === undefined) {
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
|
||||
}, [ answerInputs, lastFocus ]);
|
||||
|
||||
|
||||
const onQuestionKeyDown = useCallback(() => {
|
||||
answerInputs.current[0].focus();
|
||||
}, []);
|
||||
|
||||
// Called on keypress in answer fields
|
||||
const onAnswerKeyDown = useCallback((index: number, ev) => {
|
||||
const { key } = ev.nativeEvent;
|
||||
const currentText = answers[index].name;
|
||||
|
||||
if (key === 'Backspace' && currentText === '' && answers.length > 1) {
|
||||
removeAnswer(index);
|
||||
requestFocus(index > 0 ? index - 1 : 0);
|
||||
}
|
||||
}, [ answers, addAnswer, removeAnswer, requestFocus ]);
|
||||
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
const createRemoveOptionButton = (onPress: () => void) => (
|
||||
<Button
|
||||
id = { t('polls.create.removeOption') }
|
||||
labelKey = 'polls.create.removeOption'
|
||||
labelStyle = { dialogStyles.optionRemoveButtonText }
|
||||
onClick = { onPress }
|
||||
style = { dialogStyles.optionRemoveButton }
|
||||
type = { TERTIARY } />
|
||||
);
|
||||
|
||||
const pollCreateButtonsContainerStyles = Platform.OS === 'android'
|
||||
? pollsStyles.pollCreateButtonsContainerAndroid : pollsStyles.pollCreateButtonsContainerIos;
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
const renderListItem = ({ index }: { index: number; }) => {
|
||||
|
||||
const isIdenticalAnswer
|
||||
= answers.slice(0, index).length === 0 ? false : answers.slice(0, index).some(prevAnswer =>
|
||||
prevAnswer.name === answers[index].name
|
||||
&& prevAnswer.name !== '' && answers[index].name !== '');
|
||||
|
||||
return (
|
||||
<View
|
||||
id = 'option-container'
|
||||
style = { dialogStyles.optionContainer as ViewStyle }>
|
||||
<Input
|
||||
blurOnSubmit = { false }
|
||||
bottomLabel = { (
|
||||
isIdenticalAnswer ? t('polls.errors.notUniqueOption', { index: index + 1 }) : '') }
|
||||
error = { isIdenticalAnswer }
|
||||
id = { `polls-answer-input-${index}` }
|
||||
label = { t('polls.create.pollOption', { index: index + 1 }) }
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { name => setAnswer(index,
|
||||
{
|
||||
name,
|
||||
voters: []
|
||||
}) }
|
||||
onKeyPress = { ev => onAnswerKeyDown(index, ev) }
|
||||
placeholder = { t('polls.create.answerPlaceholder', { index: index + 1 }) }
|
||||
|
||||
// This is set to help the touch event not be propagated to any subviews.
|
||||
pointerEvents = { 'auto' }
|
||||
ref = { input => registerFieldRef(index, input) }
|
||||
value = { answers[index].name } />
|
||||
{
|
||||
answers.length > 2
|
||||
&& createRemoveOptionButton(() => removeAnswer(index))
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListHeaderComponent = useMemo(() => (
|
||||
<>
|
||||
<Input
|
||||
autoFocus = { true }
|
||||
blurOnSubmit = { false }
|
||||
customStyles = {{ container: dialogStyles.customContainer }}
|
||||
id = { t('polls.create.pollQuestion') }
|
||||
label = { t('polls.create.pollQuestion') }
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { setQuestion }
|
||||
onSubmitEditing = { onQuestionKeyDown }
|
||||
placeholder = { t('polls.create.questionPlaceholder') }
|
||||
|
||||
// This is set to help the touch event not be propagated to any subviews.
|
||||
pointerEvents = { 'auto' }
|
||||
value = { question } />
|
||||
<Divider style = { pollsStyles.fieldSeparator as ViewStyle } />
|
||||
</>
|
||||
), [ question ]);
|
||||
|
||||
return (
|
||||
<View style = { pollsStyles.pollCreateContainer as ViewStyle }>
|
||||
<View style = { pollsStyles.pollCreateSubContainer as ViewStyle }>
|
||||
<FlatList
|
||||
ListHeaderComponent = { renderListHeaderComponent }
|
||||
data = { answers }
|
||||
extraData = { answers }
|
||||
keyExtractor = { (item, index) => index.toString() }
|
||||
ref = { answerListRef }
|
||||
renderItem = { renderListItem } />
|
||||
<View style = { pollCreateButtonsContainerStyles as ViewStyle }>
|
||||
<Button
|
||||
accessibilityLabel = 'polls.create.addOption'
|
||||
disabled = { answers.length >= ANSWERS_LIMIT }
|
||||
id = { t('polls.create.addOption') }
|
||||
labelKey = 'polls.create.addOption'
|
||||
onClick = { () => {
|
||||
// adding and answer
|
||||
addAnswer();
|
||||
requestFocus(answers.length);
|
||||
} }
|
||||
style = { pollsStyles.pollCreateAddButton }
|
||||
type = { SECONDARY } />
|
||||
<View
|
||||
style = { pollsStyles.buttonRow as ViewStyle }>
|
||||
<Button
|
||||
accessibilityLabel = 'polls.create.cancel'
|
||||
id = { t('polls.create.cancel') }
|
||||
labelKey = 'polls.create.cancel'
|
||||
onClick = { () => {
|
||||
setCreateMode(false);
|
||||
editingPollId
|
||||
&& editingPoll?.editing
|
||||
&& dispatch(editPoll(editingPollId, false));
|
||||
} }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = 'polls.create.save'
|
||||
disabled = { isSubmitDisabled }
|
||||
id = { t('polls.create.save') }
|
||||
labelKey = 'polls.create.save'
|
||||
onClick = { onSubmit }
|
||||
style = { pollsStyles.pollCreateButton }
|
||||
type = { PRIMARY } />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollCreate to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollCreate(PollCreate);
|
||||
45
react/features/polls/components/native/PollItem.tsx
Normal file
45
react/features/polls/components/native/PollItem.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { shouldShowResults } from '../../functions';
|
||||
|
||||
import PollAnswer from './PollAnswer';
|
||||
import PollResults from './PollResults';
|
||||
import { pollsStyles } from './styles';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Id of the poll.
|
||||
*/
|
||||
pollId: string;
|
||||
|
||||
/**
|
||||
* Create mode control.
|
||||
*/
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
const PollItem = ({ pollId, setCreateMode }: IProps) => {
|
||||
const showResults = useSelector(shouldShowResults(pollId));
|
||||
|
||||
return (
|
||||
<View
|
||||
id = 'poll-item-container'
|
||||
style = { pollsStyles.pollItemContainer as ViewStyle }>
|
||||
{ showResults
|
||||
? <PollResults
|
||||
key = { pollId }
|
||||
pollId = { pollId } />
|
||||
: <PollAnswer
|
||||
pollId = { pollId }
|
||||
setCreateMode = { setCreateMode } />
|
||||
}
|
||||
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollItem;
|
||||
149
react/features/polls/components/native/PollResults.tsx
Normal file
149
react/features/polls/components/native/PollResults.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { FlatList, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import {
|
||||
default as AbstractPollResults,
|
||||
type AbstractProps,
|
||||
type AnswerInfo
|
||||
} from '../AbstractPollResults';
|
||||
|
||||
import { dialogStyles, pollsStyles, resultsStyles } from './styles';
|
||||
|
||||
/**
|
||||
* Component that renders the poll results.
|
||||
*
|
||||
* @param {Props} props - The passed props.
|
||||
* @returns {React.Node}
|
||||
*/
|
||||
const PollResults = (props: AbstractProps) => {
|
||||
const {
|
||||
answers,
|
||||
changeVote,
|
||||
creatorName,
|
||||
haveVoted,
|
||||
question,
|
||||
showDetails,
|
||||
t,
|
||||
toggleIsDetailed
|
||||
} = props;
|
||||
|
||||
/**
|
||||
* Render a header summing up answer information.
|
||||
*
|
||||
* @param {string} answer - The name of the answer.
|
||||
* @param {number} percentage - The percentage of voters.
|
||||
* @param {number} nbVotes - The number of collected votes.
|
||||
* @returns {React.Node}
|
||||
*/
|
||||
const renderHeader = (answer: string, percentage: number, nbVotes: number) => (
|
||||
<View style = { resultsStyles.answerHeader as ViewStyle }>
|
||||
<Text style = { resultsStyles.answer as TextStyle }>{ answer }</Text>
|
||||
<View>
|
||||
<Text style = { resultsStyles.answer as TextStyle }>({nbVotes}) {percentage}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render voters of and answer.
|
||||
*
|
||||
* @param {AnswerInfo} answer - The answer info.
|
||||
* @returns {React.Node}
|
||||
*/
|
||||
const renderRow = useCallback((answer: AnswerInfo) => {
|
||||
const { name, percentage, voters, voterCount } = answer;
|
||||
|
||||
if (showDetails) {
|
||||
return (
|
||||
<View style = { resultsStyles.answerContainer as ViewStyle }>
|
||||
{ renderHeader(name, percentage, voterCount) }
|
||||
<View style = { resultsStyles.barContainer as ViewStyle }>
|
||||
<View style = { [ resultsStyles.bar, { width: `${percentage}%` } ] as ViewStyle[] } />
|
||||
</View>
|
||||
{ voters && voterCount > 0
|
||||
&& <View style = { resultsStyles.voters as ViewStyle }>
|
||||
{/* @ts-ignore */}
|
||||
{voters.map(({ id, name: voterName }) =>
|
||||
(<Text
|
||||
key = { id }
|
||||
style = { resultsStyles.voter as TextStyle }>
|
||||
{ voterName }
|
||||
</Text>)
|
||||
)}
|
||||
</View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// else, we display a simple list
|
||||
// We add a progress bar by creating an empty view of width equal to percentage.
|
||||
return (
|
||||
<View style = { resultsStyles.answerContainer as ViewStyle }>
|
||||
{ renderHeader(answer.name, percentage, voterCount) }
|
||||
<View style = { resultsStyles.barContainer as ViewStyle }>
|
||||
<View style = { [ resultsStyles.bar, { width: `${percentage}%` } ] as ViewStyle[] } />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
}, [ showDetails ]);
|
||||
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
return (
|
||||
<View>
|
||||
<Text
|
||||
id = 'question-text'
|
||||
style = { dialogStyles.questionText as TextStyle } >{ question }</Text>
|
||||
<Text
|
||||
id = 'poll-owner-text'
|
||||
style = { dialogStyles.questionOwnerText as TextStyle } >
|
||||
{ t('polls.by', { name: creatorName }) }
|
||||
</Text>
|
||||
<FlatList
|
||||
data = { answers }
|
||||
keyExtractor = { (item, index) => index.toString() }
|
||||
renderItem = { answer => renderRow(answer.item) } />
|
||||
<View style = { pollsStyles.bottomLinks as ViewStyle }>
|
||||
<Button
|
||||
id = {
|
||||
showDetails
|
||||
? t('polls.results.hideDetailedResults')
|
||||
: t('polls.results.showDetailedResults')
|
||||
}
|
||||
labelKey = {
|
||||
showDetails
|
||||
? 'polls.results.hideDetailedResults'
|
||||
: 'polls.results.showDetailedResults'
|
||||
}
|
||||
labelStyle = { pollsStyles.toggleText }
|
||||
onClick = { toggleIsDetailed }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
<Button
|
||||
id = {
|
||||
haveVoted
|
||||
? t('polls.results.changeVote')
|
||||
: t('polls.results.vote')
|
||||
}
|
||||
labelKey = {
|
||||
haveVoted
|
||||
? 'polls.results.changeVote'
|
||||
: 'polls.results.vote'
|
||||
}
|
||||
labelStyle = { pollsStyles.toggleText }
|
||||
onClick = { changeVote }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollResults to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollResults(PollResults);
|
||||
70
react/features/polls/components/native/PollsList.tsx
Normal file
70
react/features/polls/components/native/PollsList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconMessage } from '../../../base/icons/svg';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
import PollItem from './PollItem';
|
||||
import { pollsStyles } from './styles';
|
||||
|
||||
interface IPollListProps {
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
}
|
||||
|
||||
const PollsList = ({ setCreateMode }: IPollListProps) => {
|
||||
const polls = useSelector((state: IReduxState) => state['features/polls'].polls);
|
||||
const { t } = useTranslation();
|
||||
const listPolls = Object.keys(polls);
|
||||
|
||||
const renderItem = useCallback(({ item }) => (
|
||||
<PollItem
|
||||
key = { item }
|
||||
pollId = { item }
|
||||
setCreateMode = { setCreateMode } />)
|
||||
, []);
|
||||
|
||||
const flatlistRef = useRef<FlatList>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
flatlistRef.current?.scrollToEnd({ animated: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [ polls ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
listPolls.length === 0
|
||||
&& <View style = { pollsStyles.noPollContent as ViewStyle }>
|
||||
<Icon
|
||||
color = { BaseTheme.palette.icon03 }
|
||||
size = { 160 }
|
||||
src = { IconMessage } />
|
||||
<Text
|
||||
id = 'no-polls-text'
|
||||
style = { pollsStyles.noPollText as TextStyle } >
|
||||
{
|
||||
t('polls.results.empty')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
<FlatList
|
||||
data = { listPolls }
|
||||
extraData = { listPolls }
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
keyExtractor = { (item, index) => index.toString() }
|
||||
ref = { flatlistRef }
|
||||
renderItem = { renderItem } />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollsList;
|
||||
77
react/features/polls/components/native/PollsPane.tsx
Normal file
77
react/features/polls/components/native/PollsPane.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { ChatTabs } from '../../../chat/constants';
|
||||
import { TabBarLabelCounter }
|
||||
from '../../../mobile/navigation/components/TabBarLabelCounter';
|
||||
import {
|
||||
default as AbstractPollsPane,
|
||||
type AbstractProps
|
||||
} from '../AbstractPollsPane';
|
||||
|
||||
import PollCreate from './PollCreate';
|
||||
import PollsList from './PollsList';
|
||||
import { pollsStyles } from './styles';
|
||||
|
||||
const PollsPane = (props: AbstractProps) => {
|
||||
const { createMode, isCreatePollsDisabled, onCreate, setCreateMode, t } = props;
|
||||
const navigation = useNavigation();
|
||||
const isPollsTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.POLLS);
|
||||
const { nbUnreadPolls } = useSelector((state: IReduxState) => state['features/polls']);
|
||||
|
||||
useEffect(() => {
|
||||
const activeUnreadPollsNr = !isPollsTabFocused && nbUnreadPolls > 0;
|
||||
|
||||
navigation.setOptions({
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
tabBarLabel: () => (
|
||||
<TabBarLabelCounter
|
||||
activeUnreadNr = { activeUnreadPollsNr }
|
||||
isFocused = { isPollsTabFocused }
|
||||
label = { t('chat.tabs.polls') }
|
||||
nbUnread = { nbUnreadPolls } />
|
||||
)
|
||||
});
|
||||
|
||||
}, [ isPollsTabFocused, nbUnreadPolls ]);
|
||||
|
||||
const createPollButtonStyles = Platform.OS === 'android'
|
||||
? pollsStyles.createPollButtonAndroid : pollsStyles.createPollButtonIos;
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
contentContainerStyle = { pollsStyles.pollPane as StyleType }
|
||||
disableForcedKeyboardDismiss = { true }
|
||||
hasExtraHeaderHeight = { true }
|
||||
style = { pollsStyles.pollPaneContainer as StyleType }>
|
||||
{
|
||||
createMode
|
||||
? <PollCreate setCreateMode = { setCreateMode } />
|
||||
: <>
|
||||
<PollsList setCreateMode = { setCreateMode } />
|
||||
{!isCreatePollsDisabled && <Button
|
||||
accessibilityLabel = 'polls.create.create'
|
||||
id = { t('polls.create.create') }
|
||||
labelKey = 'polls.create.create'
|
||||
onClick = { onCreate }
|
||||
style = { createPollButtonStyles }
|
||||
type = { BUTTON_TYPES.PRIMARY } />}
|
||||
</>
|
||||
}
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollsPane to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollsPane(PollsPane);
|
||||
239
react/features/polls/components/native/styles.ts
Normal file
239
react/features/polls/components/native/styles.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { createStyleSheet } from '../../../base/styles/functions.native';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
|
||||
export const dialogStyles = createStyleSheet({
|
||||
|
||||
headerContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
customContainer: {
|
||||
marginBottom: BaseTheme.spacing[3],
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
marginTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
questionText: {
|
||||
...BaseTheme.typography.bodyShortBold,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[1]
|
||||
},
|
||||
|
||||
questionOwnerText: {
|
||||
...BaseTheme.typography.bodyShortBold,
|
||||
color: BaseTheme.palette.text03,
|
||||
marginBottom: BaseTheme.spacing[2],
|
||||
marginLeft: BaseTheme.spacing[1]
|
||||
},
|
||||
|
||||
optionContainer: {
|
||||
flexDirection: 'column',
|
||||
marginTop: BaseTheme.spacing[3],
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
optionRemoveButton: {
|
||||
width: 128
|
||||
},
|
||||
|
||||
optionRemoveButtonText: {
|
||||
color: BaseTheme.palette.link01
|
||||
},
|
||||
|
||||
field: {
|
||||
borderWidth: 1,
|
||||
borderColor: BaseTheme.palette.ui06,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 14,
|
||||
paddingBottom: BaseTheme.spacing[2],
|
||||
paddingLeft: BaseTheme.spacing[3],
|
||||
paddingRight: BaseTheme.spacing[3],
|
||||
paddingTop: BaseTheme.spacing[2]
|
||||
}
|
||||
});
|
||||
|
||||
export const resultsStyles = createStyleSheet({
|
||||
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
|
||||
barContainer: {
|
||||
backgroundColor: '#ccc',
|
||||
borderRadius: 3,
|
||||
width: '100%',
|
||||
height: 6,
|
||||
marginTop: 2
|
||||
},
|
||||
|
||||
bar: {
|
||||
backgroundColor: BaseTheme.palette.action01,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
height: 6
|
||||
},
|
||||
|
||||
voters: {
|
||||
backgroundColor: BaseTheme.palette.ui04,
|
||||
borderColor: BaseTheme.palette.ui03,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
borderWidth: 1,
|
||||
padding: BaseTheme.spacing[2],
|
||||
marginTop: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
voter: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
answerContainer: {
|
||||
marginHorizontal: BaseTheme.spacing[1],
|
||||
marginVertical: BaseTheme.spacing[3],
|
||||
maxWidth: '100%'
|
||||
},
|
||||
|
||||
answerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
answer: {
|
||||
color: BaseTheme.palette.text01,
|
||||
flexShrink: 1
|
||||
},
|
||||
|
||||
answerVoteCount: {
|
||||
paddingLeft: 10
|
||||
},
|
||||
|
||||
chatQuestion: {
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
});
|
||||
|
||||
export const pollsStyles = createStyleSheet({
|
||||
|
||||
noPollContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '25%'
|
||||
},
|
||||
|
||||
noPollText: {
|
||||
flex: 1,
|
||||
color: BaseTheme.palette.text03,
|
||||
textAlign: 'center',
|
||||
maxWidth: '70%'
|
||||
},
|
||||
|
||||
pollItemContainer: {
|
||||
backgroundColor: BaseTheme.palette.uiBackground,
|
||||
borderColor: BaseTheme.palette.ui06,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
boxShadow: BaseTheme.shape.boxShadow,
|
||||
borderWidth: 1,
|
||||
padding: BaseTheme.spacing[2],
|
||||
margin: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
pollCreateContainer: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
pollCreateSubContainer: {
|
||||
flex: 1,
|
||||
marginTop: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
pollCreateButtonsContainerAndroid: {
|
||||
marginBottom: BaseTheme.spacing[8],
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
pollCreateButtonsContainerIos: {
|
||||
marginBottom: BaseTheme.spacing[5],
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
pollSendLabel: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
pollSendDisabledLabel: {
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
|
||||
answerContent: {
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
switchRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
padding: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
switchLabel: {
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
pollCreateAddButton: {
|
||||
marginHorizontal: BaseTheme.spacing[1],
|
||||
marginVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
pollCreateButton: {
|
||||
marginHorizontal: BaseTheme.spacing[1],
|
||||
flex: 1
|
||||
},
|
||||
|
||||
toggleText: {
|
||||
color: BaseTheme.palette.action01
|
||||
},
|
||||
|
||||
createPollButtonIos: {
|
||||
marginHorizontal: 20,
|
||||
marginVertical: BaseTheme.spacing[5]
|
||||
},
|
||||
|
||||
createPollButtonAndroid: {
|
||||
marginHorizontal: 20,
|
||||
marginVertical: BaseTheme.spacing[5]
|
||||
},
|
||||
|
||||
pollPane: {
|
||||
flex: 1,
|
||||
padding: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
pollPaneContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
bottomLinks: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginHorizontal: BaseTheme.spacing[1]
|
||||
},
|
||||
|
||||
fieldSeparator: {
|
||||
borderBottomWidth: 1,
|
||||
borderColor: BaseTheme.palette.ui05,
|
||||
marginTop: BaseTheme.spacing[3]
|
||||
}
|
||||
});
|
||||
154
react/features/polls/components/web/PollAnswer.tsx
Normal file
154
react/features/polls/components/web/PollAnswer.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconCloseLarge } from '../../../base/icons/svg';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Checkbox from '../../../base/ui/components/web/Checkbox';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { editPoll, removePoll } from '../../actions';
|
||||
import { isSubmitAnswerDisabled } from '../../functions';
|
||||
import AbstractPollAnswer, { AbstractProps } from '../AbstractPollAnswer';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
margin: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: theme.palette.ui02,
|
||||
borderRadius: '8px',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
closeBtn: {
|
||||
cursor: 'pointer',
|
||||
float: 'right'
|
||||
},
|
||||
header: {
|
||||
marginBottom: '24px'
|
||||
},
|
||||
question: {
|
||||
...theme.typography.heading6,
|
||||
color: theme.palette.text01,
|
||||
marginBottom: '8px'
|
||||
},
|
||||
creator: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text02
|
||||
},
|
||||
answerList: {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
marginBottom: '24px'
|
||||
},
|
||||
answer: {
|
||||
display: 'flex',
|
||||
marginBottom: '16px'
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
buttonMargin: {
|
||||
marginRight: theme.spacing(3)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PollAnswer = ({
|
||||
creatorName,
|
||||
checkBoxStates,
|
||||
poll,
|
||||
pollId,
|
||||
setCheckbox,
|
||||
setCreateMode,
|
||||
skipAnswer,
|
||||
skipChangeVote,
|
||||
sendPoll,
|
||||
submitAnswer,
|
||||
t
|
||||
}: AbstractProps) => {
|
||||
const { changingVote, saved: pollSaved } = poll;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
{
|
||||
pollSaved && <Icon
|
||||
ariaLabel = { t('polls.closeButton') }
|
||||
className = { classes.closeBtn }
|
||||
onClick = { () => dispatch(removePoll(pollId, poll)) }
|
||||
role = 'button'
|
||||
src = { IconCloseLarge }
|
||||
tabIndex = { 0 } />
|
||||
}
|
||||
<div className = { classes.header }>
|
||||
<div className = { classes.question }>
|
||||
{ poll.question }
|
||||
</div>
|
||||
<div className = { classes.creator }>
|
||||
{ t('polls.by', { name: creatorName }) }
|
||||
</div>
|
||||
</div>
|
||||
<ul className = { classes.answerList }>
|
||||
{
|
||||
poll.answers.map((answer, index: number) => (
|
||||
<li
|
||||
className = { classes.answer }
|
||||
key = { index }>
|
||||
<Checkbox
|
||||
checked = { checkBoxStates[index] }
|
||||
disabled = { poll.saved }
|
||||
key = { index }
|
||||
label = { answer.name }
|
||||
onChange = { ev => setCheckbox(index, ev.target.checked) } />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div className = { classes.footer } >
|
||||
{
|
||||
pollSaved ? <>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.answer.edit') }
|
||||
className = { classes.buttonMargin }
|
||||
labelKey = { 'polls.answer.edit' }
|
||||
onClick = { () => {
|
||||
setCreateMode(true);
|
||||
dispatch(editPoll(pollId, true));
|
||||
} }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.answer.send') }
|
||||
labelKey = { 'polls.answer.send' }
|
||||
onClick = { sendPoll } />
|
||||
</> : <>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.answer.skip') }
|
||||
className = { classes.buttonMargin }
|
||||
labelKey = { 'polls.answer.skip' }
|
||||
onClick = { changingVote ? skipChangeVote : skipAnswer }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.answer.submit') }
|
||||
disabled = { isSubmitAnswerDisabled(checkBoxStates) }
|
||||
labelKey = { 'polls.answer.submit' }
|
||||
onClick = { submitAnswer } />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollAnswer to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollAnswer(PollAnswer);
|
||||
284
react/features/polls/components/web/PollCreate.tsx
Normal file
284
react/features/polls/components/web/PollCreate.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { editPoll } from '../../actions';
|
||||
import { ANSWERS_LIMIT, CHAR_LIMIT } from '../../constants';
|
||||
import AbstractPollCreate, { AbstractProps } from '../AbstractPollCreate';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
},
|
||||
createContainer: {
|
||||
padding: '0 24px',
|
||||
height: 'calc(100% - 88px)',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
header: {
|
||||
...theme.typography.heading6,
|
||||
color: theme.palette.text01,
|
||||
margin: '24px 0 16px'
|
||||
},
|
||||
questionContainer: {
|
||||
paddingBottom: '24px',
|
||||
borderBottom: `1px solid ${theme.palette.ui03}`
|
||||
},
|
||||
answerList: {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
answer: {
|
||||
marginBottom: '24px'
|
||||
},
|
||||
removeOption: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.link01,
|
||||
marginTop: '8px',
|
||||
border: 0,
|
||||
background: 'transparent'
|
||||
},
|
||||
addButtonContainer: {
|
||||
display: 'flex'
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box'
|
||||
},
|
||||
buttonMargin: {
|
||||
marginRight: theme.spacing(3)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PollCreate = ({
|
||||
addAnswer,
|
||||
answers,
|
||||
editingPoll,
|
||||
editingPollId,
|
||||
isSubmitDisabled,
|
||||
onSubmit,
|
||||
question,
|
||||
removeAnswer,
|
||||
setAnswer,
|
||||
setCreateMode,
|
||||
setQuestion,
|
||||
t
|
||||
}: AbstractProps) => {
|
||||
const { classes } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
/*
|
||||
* This ref stores the Array of answer input fields, allowing us to focus on them.
|
||||
* This array is maintained by registerfieldRef and the useEffect below.
|
||||
*/
|
||||
const answerInputs = useRef<Array<HTMLInputElement>>([]);
|
||||
const registerFieldRef = useCallback((i, r) => {
|
||||
if (r === null) {
|
||||
return;
|
||||
}
|
||||
answerInputs.current[i] = r;
|
||||
}, [ answerInputs ]);
|
||||
|
||||
useEffect(() => {
|
||||
answerInputs.current = answerInputs.current.slice(0, answers.length);
|
||||
}, [ answers ]);
|
||||
|
||||
/*
|
||||
* This state allows us to requestFocus asynchronously, without having to worry
|
||||
* about whether a newly created input field has been rendered yet or not.
|
||||
*/
|
||||
const [ lastFocus, requestFocus ] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastFocus === null) {
|
||||
return;
|
||||
}
|
||||
const input = answerInputs.current[lastFocus];
|
||||
|
||||
if (input === undefined) {
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
}, [ lastFocus ]);
|
||||
|
||||
const checkModifiers = useCallback(ev => {
|
||||
// Composition events used to add accents to characters
|
||||
// despite their absence from standard US keyboards,
|
||||
// to build up logograms of many Asian languages
|
||||
// from their base components or categories and so on.
|
||||
if (ev.isComposing || ev.keyCode === 229) {
|
||||
// keyCode 229 means that user pressed some button,
|
||||
// but input method is still processing that.
|
||||
// This is a standard behavior for some input methods
|
||||
// like entering japanese or сhinese hieroglyphs.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Because this isn't done automatically on MacOS
|
||||
if (ev.key === 'Enter' && ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
onSubmit();
|
||||
|
||||
return;
|
||||
}
|
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onQuestionKeyDown = useCallback(ev => {
|
||||
if (checkModifiers(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter') {
|
||||
requestFocus(0);
|
||||
ev.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Called on keypress in answer fields
|
||||
const onAnswerKeyDown = useCallback((i, ev) => {
|
||||
if (checkModifiers(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Enter') {
|
||||
// We add a new option input
|
||||
// only if we are on the last option input
|
||||
if (i === answers.length - 1) {
|
||||
addAnswer(i + 1);
|
||||
}
|
||||
requestFocus(i + 1);
|
||||
ev.preventDefault();
|
||||
} else if (ev.key === 'Backspace' && ev.target.value === '' && answers.length > 1) {
|
||||
removeAnswer(i);
|
||||
requestFocus(i > 0 ? i - 1 : 0);
|
||||
ev.preventDefault();
|
||||
} else if (ev.key === 'ArrowDown') {
|
||||
if (i === answers.length - 1) {
|
||||
addAnswer();
|
||||
}
|
||||
requestFocus(i + 1);
|
||||
ev.preventDefault();
|
||||
} else if (ev.key === 'ArrowUp') {
|
||||
if (i === 0) {
|
||||
addAnswer(0);
|
||||
requestFocus(0);
|
||||
} else {
|
||||
requestFocus(i - 1);
|
||||
}
|
||||
ev.preventDefault();
|
||||
}
|
||||
}, [ answers, addAnswer, removeAnswer, requestFocus ]);
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
return (<form
|
||||
className = { classes.container }
|
||||
onSubmit = { onSubmit }>
|
||||
<div className = { classes.createContainer }>
|
||||
<div className = { classes.header }>
|
||||
{ t('polls.create.create') }
|
||||
</div>
|
||||
<div className = { classes.questionContainer }>
|
||||
<Input
|
||||
autoFocus = { true }
|
||||
id = 'polls-create-input'
|
||||
label = { t('polls.create.pollQuestion') }
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { setQuestion }
|
||||
onKeyPress = { onQuestionKeyDown }
|
||||
placeholder = { t('polls.create.questionPlaceholder') }
|
||||
textarea = { true }
|
||||
value = { question } />
|
||||
</div>
|
||||
<ol className = { classes.answerList }>
|
||||
{answers.map((answer, i: number) => {
|
||||
|
||||
const isIdenticalAnswer = answers.slice(0, i).length === 0 ? false
|
||||
: answers.slice(0, i).some(prevAnswer =>
|
||||
prevAnswer.name === answer.name
|
||||
&& prevAnswer.name !== '' && answer.name !== '');
|
||||
|
||||
return (<li
|
||||
className = { classes.answer }
|
||||
key = { i }>
|
||||
<Input
|
||||
bottomLabel = { (isIdenticalAnswer ? t('polls.errors.notUniqueOption',
|
||||
{ index: i + 1 }) : '') }
|
||||
error = { isIdenticalAnswer }
|
||||
id = { `polls-answer-input-${i}` }
|
||||
label = { t('polls.create.pollOption', { index: i + 1 }) }
|
||||
maxLength = { CHAR_LIMIT }
|
||||
onChange = { name => setAnswer(i, {
|
||||
name,
|
||||
voters: []
|
||||
}) }
|
||||
onKeyPress = { ev => onAnswerKeyDown(i, ev) }
|
||||
placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
|
||||
ref = { r => registerFieldRef(i, r) }
|
||||
textarea = { true }
|
||||
value = { answer.name } />
|
||||
|
||||
{ answers.length > 2
|
||||
&& <button
|
||||
className = { classes.removeOption }
|
||||
onClick = { () => removeAnswer(i) }
|
||||
type = 'button'>
|
||||
{ t('polls.create.removeOption') }
|
||||
</button>}
|
||||
</li>);
|
||||
}
|
||||
)}
|
||||
</ol>
|
||||
<div className = { classes.addButtonContainer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.create.addOption') }
|
||||
disabled = { answers.length >= ANSWERS_LIMIT }
|
||||
labelKey = { 'polls.create.addOption' }
|
||||
onClick = { () => {
|
||||
addAnswer();
|
||||
requestFocus(answers.length);
|
||||
} }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</div>
|
||||
</div>
|
||||
<div className = { classes.footer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.create.cancel') }
|
||||
className = { classes.buttonMargin }
|
||||
labelKey = { 'polls.create.cancel' }
|
||||
onClick = { () => {
|
||||
setCreateMode(false);
|
||||
editingPollId
|
||||
&& editingPoll?.editing
|
||||
&& dispatch(editPoll(editingPollId, false));
|
||||
} }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.create.save') }
|
||||
disabled = { isSubmitDisabled }
|
||||
isSubmit = { true }
|
||||
labelKey = { 'polls.create.save' } />
|
||||
</div>
|
||||
</form>);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollCreate to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollCreate(PollCreate);
|
||||
42
react/features/polls/components/web/PollItem.tsx
Normal file
42
react/features/polls/components/web/PollItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { shouldShowResults } from '../../functions';
|
||||
|
||||
import PollAnswer from './PollAnswer';
|
||||
import PollResults from './PollResults';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Id of the poll.
|
||||
*/
|
||||
pollId: string;
|
||||
|
||||
/**
|
||||
* Create mode control.
|
||||
*/
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
const PollItem = React.forwardRef<HTMLDivElement, IProps>(({ pollId, setCreateMode }: IProps, ref) => {
|
||||
const showResults = useSelector(shouldShowResults(pollId));
|
||||
|
||||
return (
|
||||
<div ref = { ref }>
|
||||
{ showResults
|
||||
? <PollResults
|
||||
key = { pollId }
|
||||
pollId = { pollId } />
|
||||
: <PollAnswer
|
||||
pollId = { pollId }
|
||||
setCreateMode = { setCreateMode } />
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PollItem;
|
||||
177
react/features/polls/components/web/PollResults.tsx
Normal file
177
react/features/polls/components/web/PollResults.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractPollResults, { AbstractProps } from '../AbstractPollResults';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
margin: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: theme.palette.ui02,
|
||||
borderRadius: '8px',
|
||||
wordBreak: 'break-word'
|
||||
},
|
||||
header: {
|
||||
marginBottom: '16px'
|
||||
},
|
||||
question: {
|
||||
...theme.typography.heading6,
|
||||
color: theme.palette.text01,
|
||||
marginBottom: '8px'
|
||||
},
|
||||
creator: {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text02
|
||||
},
|
||||
resultList: {
|
||||
listStyleType: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
|
||||
'& li': {
|
||||
marginBottom: '16px'
|
||||
}
|
||||
},
|
||||
answerName: {
|
||||
display: 'flex',
|
||||
flexShrink: 1,
|
||||
overflowWrap: 'anywhere',
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
marginBottom: '4px'
|
||||
},
|
||||
answerResultContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
minWidth: '10em'
|
||||
},
|
||||
barContainer: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: '4px',
|
||||
height: '6px',
|
||||
maxWidth: '160px',
|
||||
width: '158px',
|
||||
flexGrow: 1,
|
||||
marginTop: '2px'
|
||||
},
|
||||
bar: {
|
||||
height: '6px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: theme.palette.action01
|
||||
},
|
||||
voteCount: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
...theme.typography.bodyShortBold,
|
||||
color: theme.palette.text01
|
||||
},
|
||||
voters: {
|
||||
margin: 0,
|
||||
marginTop: '4px',
|
||||
listStyleType: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: '8px 16px',
|
||||
|
||||
'& li': {
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01,
|
||||
margin: 0,
|
||||
marginBottom: '2px',
|
||||
|
||||
'&:last-of-type': {
|
||||
marginBottom: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
buttonsContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
'& button': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.link01
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component that renders the poll results.
|
||||
*
|
||||
* @param {Props} props - The passed props.
|
||||
* @returns {React.Node}
|
||||
*/
|
||||
const PollResults = ({
|
||||
answers,
|
||||
changeVote,
|
||||
creatorName,
|
||||
haveVoted,
|
||||
showDetails,
|
||||
question,
|
||||
t,
|
||||
toggleIsDetailed
|
||||
}: AbstractProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<div className = { classes.header }>
|
||||
<div className = { classes.question }>
|
||||
{question}
|
||||
</div>
|
||||
<div className = { classes.creator }>
|
||||
{t('polls.by', { name: creatorName })}
|
||||
</div>
|
||||
</div>
|
||||
<ul className = { classes.resultList }>
|
||||
{answers.map(({ name, percentage, voters, voterCount }, index) =>
|
||||
(<li key = { index }>
|
||||
<div className = { classes.answerName }>
|
||||
{name}
|
||||
</div>
|
||||
<div className = { classes.answerResultContainer }>
|
||||
<span className = { classes.barContainer }>
|
||||
<div
|
||||
className = { classes.bar }
|
||||
style = {{ width: `${percentage}%` }} />
|
||||
</span>
|
||||
<div className = { classes.voteCount }>
|
||||
{voterCount} ({percentage}%)
|
||||
</div>
|
||||
</div>
|
||||
{showDetails && voters && voterCount > 0
|
||||
&& <ul className = { classes.voters }>
|
||||
{voters.map(voter =>
|
||||
<li key = { voter?.id }>{voter?.name}</li>
|
||||
)}
|
||||
</ul>}
|
||||
</li>)
|
||||
)}
|
||||
</ul>
|
||||
<div className = { classes.buttonsContainer }>
|
||||
<button
|
||||
onClick = { toggleIsDetailed }>
|
||||
{showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')}
|
||||
</button>
|
||||
<button
|
||||
onClick = { changeVote }>
|
||||
{haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollResults to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollResults(PollResults);
|
||||
93
react/features/polls/components/web/PollsList.tsx
Normal file
93
react/features/polls/components/web/PollsList.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconMessage } from '../../../base/icons/svg';
|
||||
import { browser } from '../../../base/lib-jitsi-meet';
|
||||
|
||||
import PollItem from './PollItem';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
emptyIcon: {
|
||||
width: '100px',
|
||||
padding: '16px',
|
||||
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}
|
||||
},
|
||||
emptyMessage: {
|
||||
...theme.typography.bodyLongBold,
|
||||
color: theme.palette.text02,
|
||||
padding: '0 24px',
|
||||
textAlign: 'center'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
interface IPollListProps {
|
||||
setCreateMode: (mode: boolean) => void;
|
||||
}
|
||||
|
||||
const PollsList = ({ setCreateMode }: IPollListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes, theme } = useStyles();
|
||||
const { polls } = useSelector((state: IReduxState) => state['features/polls']);
|
||||
|
||||
const pollListEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (pollListEndRef.current) {
|
||||
// Safari does not support options
|
||||
const param = browser.isSafari()
|
||||
? false : {
|
||||
behavior: 'smooth' as const,
|
||||
block: 'end' as const,
|
||||
inline: 'nearest' as const
|
||||
};
|
||||
|
||||
pollListEndRef.current.scrollIntoView(param);
|
||||
}
|
||||
}, [ pollListEndRef.current ]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [ polls ]);
|
||||
|
||||
const listPolls = Object.keys(polls);
|
||||
|
||||
return (
|
||||
<>
|
||||
{listPolls.length === 0
|
||||
? <div className = { classes.container }>
|
||||
<Icon
|
||||
className = { classes.emptyIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconMessage } />
|
||||
<span className = { classes.emptyMessage }>{t('polls.results.empty')}</span>
|
||||
</div>
|
||||
: listPolls.map((id, index) => (
|
||||
<PollItem
|
||||
key = { id }
|
||||
pollId = { id }
|
||||
ref = { listPolls.length - 1 === index ? pollListEndRef : null }
|
||||
setCreateMode = { setCreateMode } />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollsList;
|
||||
55
react/features/polls/components/web/PollsPane.tsx
Normal file
55
react/features/polls/components/web/PollsPane.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import AbstractPollsPane, { AbstractProps } from '../AbstractPollsPane';
|
||||
|
||||
import PollCreate from './PollCreate';
|
||||
import PollsList from './PollsList';
|
||||
/* eslint-enable lines-around-comment */
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
},
|
||||
listContainer: {
|
||||
height: 'calc(100% - 88px)',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PollsPane = ({ createMode, isCreatePollsDisabled, onCreate, setCreateMode, t }: AbstractProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return createMode
|
||||
? <PollCreate setCreateMode = { setCreateMode } />
|
||||
: <div className = { classes.container }>
|
||||
<div className = { classes.listContainer } >
|
||||
<PollsList setCreateMode = { setCreateMode } />
|
||||
</div>
|
||||
{ !isCreatePollsDisabled && <div className = { classes.footer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.create.create') }
|
||||
fullWidth = { true }
|
||||
labelKey = { 'polls.create.create' }
|
||||
onClick = { onCreate } />
|
||||
</div>}
|
||||
</div>;
|
||||
};
|
||||
|
||||
/*
|
||||
* We apply AbstractPollsPane to fill in the AbstractProps common
|
||||
* to both the web and native implementations.
|
||||
*/
|
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollsPane(PollsPane);
|
||||
6
react/features/polls/constants.ts
Normal file
6
react/features/polls/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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;
|
||||
82
react/features/polls/functions.ts
Normal file
82
react/features/polls/functions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { MEET_FEATURES } from '../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../base/jwt/functions';
|
||||
|
||||
import { IAnswerData } from './types';
|
||||
|
||||
/**
|
||||
* Selector creator for determining if poll results should be displayed or not.
|
||||
*
|
||||
* @param {string} id - Id of the poll.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function shouldShowResults(id: string) {
|
||||
return function(state: IReduxState) {
|
||||
return Boolean(state['features/polls']?.polls[id].showResults);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector creator for polls.
|
||||
*
|
||||
* @param {string} pollId - Id of the poll to get.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function getPoll(pollId: string) {
|
||||
return function(state: IReduxState) {
|
||||
return state['features/polls'].polls[pollId];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector for calculating the number of unread poll messages.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {number} The number of unread messages.
|
||||
*/
|
||||
export function getUnreadPollCount(state: IReduxState) {
|
||||
const { nbUnreadPolls } = state['features/polls'];
|
||||
|
||||
return nbUnreadPolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the submit poll answer button should be disabled.
|
||||
*
|
||||
* @param {Array<boolean>} checkBoxStates - The states of the checkboxes.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSubmitAnswerDisabled(checkBoxStates: Array<boolean>) {
|
||||
return !checkBoxStates.find(checked => checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input array has identical answers.
|
||||
*
|
||||
* @param {Array<IAnswerData>} currentAnswers - The array of current answers to compare.
|
||||
* @returns {boolean} - Returns true if the answers are identical.
|
||||
*/
|
||||
export function hasIdenticalAnswers(currentAnswers: Array<IAnswerData>): boolean {
|
||||
|
||||
const nonEmptyCurrentAnswers = currentAnswers.filter((answer): boolean => answer.name !== '');
|
||||
|
||||
const currentAnswersSet = new Set(nonEmptyCurrentAnswers.map(answer => answer.name));
|
||||
|
||||
return currentAnswersSet.size !== nonEmptyCurrentAnswers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if participant is not allowed to create polls.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - Returns true if the participant is not allowed to create polls.
|
||||
*/
|
||||
export function isCreatePollDisabled(state: IReduxState) {
|
||||
const { pollCreationRequiresPermission } = state['features/dynamic-branding'];
|
||||
|
||||
if (!pollCreationRequiresPermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isJwtFeatureEnabled(state, MEET_FEATURES.CREATE_POLLS, false);
|
||||
}
|
||||
3
react/features/polls/logger.ts
Normal file
3
react/features/polls/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('features/polls');
|
||||
209
react/features/polls/middleware.ts
Normal file
209
react/features/polls/middleware.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { ENDPOINT_MESSAGE_RECEIVED, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { playSound } from '../base/sounds/actions';
|
||||
import { ChatTabs, INCOMING_MSG_SOUND_ID } from '../chat/constants';
|
||||
import { arePollsDisabled } from '../conference/functions.any';
|
||||
import { showNotification } from '../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* The maximum number of answers a poll can have.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => getCurrentConference(state),
|
||||
(conference, { dispatch }, previousConference): void => {
|
||||
if (conference !== previousConference) {
|
||||
dispatch(clearPolls());
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (arePollsDisabled(state)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const isChatOpen: boolean = state['features/chat'].isOpen;
|
||||
const isPollsTabFocused: boolean = state['features/chat'].focusedTab === ChatTabs.POLLS;
|
||||
|
||||
// Finally, we notify user they received a new poll if their pane is not opened
|
||||
if (action.notify && (!isChatOpen || !isPollsTabFocused)) {
|
||||
dispatch(playSound(INCOMING_MSG_SOUND_ID));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles receiving of polls message command.
|
||||
*
|
||||
* @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 _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
|
||||
if (arePollsDisabled(getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
|
||||
case COMMAND_NEW_POLL: {
|
||||
const { pollId, answers, senderId, question } = data;
|
||||
const tmp = {
|
||||
id: pollId,
|
||||
answers,
|
||||
question,
|
||||
senderId
|
||||
};
|
||||
|
||||
// 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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
react/features/polls/reducer.ts
Normal file
195
react/features/polls/reducer.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
CHANGE_VOTE,
|
||||
CLEAR_POLLS,
|
||||
EDIT_POLL,
|
||||
RECEIVE_ANSWER,
|
||||
RECEIVE_POLL,
|
||||
REGISTER_VOTE,
|
||||
REMOVE_POLL,
|
||||
RESET_NB_UNREAD_POLLS,
|
||||
SAVE_POLL
|
||||
} from './actionTypes';
|
||||
import { IAnswer, IPoll } from './types';
|
||||
|
||||
const INITIAL_STATE = {
|
||||
polls: {},
|
||||
|
||||
// Number of not read message
|
||||
nbUnreadPolls: 0
|
||||
};
|
||||
|
||||
export interface IPollsState {
|
||||
nbUnreadPolls: number;
|
||||
polls: {
|
||||
[pollId: string]: IPoll;
|
||||
};
|
||||
}
|
||||
|
||||
const STORE_NAME = 'features/polls';
|
||||
|
||||
ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action): IPollsState => {
|
||||
switch (action.type) {
|
||||
|
||||
case CHANGE_VOTE: {
|
||||
const { pollId, value } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[pollId]: {
|
||||
...state.polls[pollId],
|
||||
changingVote: value,
|
||||
showResults: !value
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case CLEAR_POLLS: {
|
||||
return {
|
||||
...state,
|
||||
...INITIAL_STATE
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer triggered when a poll is received or saved.
|
||||
case RECEIVE_POLL: {
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[action.pollId]: action.poll
|
||||
},
|
||||
nbUnreadPolls: state.nbUnreadPolls + 1
|
||||
};
|
||||
}
|
||||
|
||||
case SAVE_POLL: {
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[action.pollId]: action.poll
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer triggered when an answer is received
|
||||
// The answer is added to an existing poll
|
||||
case RECEIVE_ANSWER: {
|
||||
|
||||
const { pollId, answer }: { answer: IAnswer; pollId: string; } = action;
|
||||
|
||||
// if the poll doesn't exist
|
||||
if (!(pollId in state.polls)) {
|
||||
console.warn('requested poll does not exist: pollId ', pollId);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// 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++) {
|
||||
// if the answer was chosen, we add the senderId to the array of voters of this answer
|
||||
const voters = newAnswers[i].voters as any;
|
||||
|
||||
const index = voters.indexOf(answer.voterId);
|
||||
|
||||
if (answer.answers[i]) {
|
||||
if (index === -1) {
|
||||
voters.push(answer.voterId);
|
||||
}
|
||||
} else if (index > -1) {
|
||||
voters.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// finally we update the state by returning the updated poll
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[pollId]: {
|
||||
...state.polls[pollId],
|
||||
answers: newAnswers
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case REGISTER_VOTE: {
|
||||
const { answers, pollId }: { answers: Array<boolean> | null; pollId: string; } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[pollId]: {
|
||||
...state.polls[pollId],
|
||||
changingVote: false,
|
||||
lastVote: answers,
|
||||
showResults: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case RESET_NB_UNREAD_POLLS: {
|
||||
return {
|
||||
...state,
|
||||
nbUnreadPolls: 0
|
||||
};
|
||||
}
|
||||
|
||||
case EDIT_POLL: {
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...state.polls,
|
||||
[action.pollId]: {
|
||||
...state.polls[action.pollId],
|
||||
editing: action.editing
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case REMOVE_POLL: {
|
||||
if (Object.keys(state.polls ?? {})?.length === 1) {
|
||||
return {
|
||||
...state,
|
||||
...INITIAL_STATE
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [action.pollId]: _removedPoll, ...newState } = state.polls;
|
||||
|
||||
return {
|
||||
...state,
|
||||
polls: {
|
||||
...newState
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
84
react/features/polls/types.ts
Normal file
84
react/features/polls/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export interface IAnswer {
|
||||
|
||||
/**
|
||||
* An array of boolean: true if the answer was chosen by the responder, else false.
|
||||
*/
|
||||
answers: Array<boolean>;
|
||||
|
||||
/**
|
||||
* ID of the parent Poll of this answer.
|
||||
*/
|
||||
pollId: string;
|
||||
|
||||
/**
|
||||
* ID of the voter for this answer.
|
||||
*/
|
||||
voterId: string;
|
||||
|
||||
/**
|
||||
* Name of the voter for this answer.
|
||||
*/
|
||||
voterName?: string;
|
||||
}
|
||||
|
||||
export interface IPoll {
|
||||
|
||||
/**
|
||||
* An array of answers:
|
||||
* the name of the answer name and a map of ids and names of voters voting for this option.
|
||||
*/
|
||||
answers: Array<IAnswerData>;
|
||||
|
||||
/**
|
||||
* Whether the poll vote is being edited/changed.
|
||||
*/
|
||||
changingVote: boolean;
|
||||
|
||||
/**
|
||||
* Whether poll is in edit mode or not?.
|
||||
*/
|
||||
editing: boolean;
|
||||
|
||||
/**
|
||||
* The last sent votes for this poll, or null if voting was skipped
|
||||
* Note: This is reset when voting/skipping, not when clicking "Change vote".
|
||||
*/
|
||||
lastVote: Array<boolean> | null;
|
||||
|
||||
/**
|
||||
* The question asked by this poll.
|
||||
*/
|
||||
question: string;
|
||||
|
||||
/**
|
||||
* Whether poll is saved or not?.
|
||||
*/
|
||||
saved: boolean;
|
||||
|
||||
/**
|
||||
* ID of the sender of this poll.
|
||||
*/
|
||||
senderId: string | undefined;
|
||||
|
||||
/**
|
||||
* Whether the results should be shown instead of the answer form.
|
||||
*/
|
||||
showResults: boolean;
|
||||
}
|
||||
|
||||
export interface IPollData extends IPoll {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IAnswerData {
|
||||
|
||||
/**
|
||||
* The answer name chosen for the poll.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An array of voters.
|
||||
*/
|
||||
voters: Array<string>;
|
||||
}
|
||||
Reference in New Issue
Block a user