snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
/**
* 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,
* 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: IIncomingAnswerData
* }
*/
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_UNREAD_POLLS_COUNT,
* }
*/
export const RESET_UNREAD_POLLS_COUNT = 'RESET_UNREAD_POLLS_COUNT';
/**
* The type of the action triggered when the poll is saved.
*
* {
* type: SAVE_POLL,
* poll: IPollData
* }
*/
export const SAVE_POLL = 'SAVE_POLL';

View File

@@ -0,0 +1,164 @@
import {
CHANGE_VOTE,
CLEAR_POLLS,
EDIT_POLL,
RECEIVE_ANSWER,
RECEIVE_POLL,
REGISTER_VOTE,
REMOVE_POLL,
RESET_UNREAD_POLLS_COUNT,
SAVE_POLL
} from './actionTypes';
import { IIncomingAnswerData, IPoll, IPollData } 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 {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 = (poll: IPoll, notify: boolean) => {
return {
type: RECEIVE_POLL,
poll,
notify
};
};
/**
* Action to signal that a new answer was received.
*
* @param {IIncomingAnswerData} answer - The incoming Answer object.
* @returns {{
* type: RECEIVE_ANSWER,
* answer: IIncomingAnswerData
* }}
*/
export const receiveAnswer = (answer: IIncomingAnswerData) => {
return {
type: RECEIVE_ANSWER,
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_UNREAD_POLLS_COUNT
* }}
*/
export function resetUnreadPollsCount() {
return {
type: RESET_UNREAD_POLLS_COUNT
};
}
/**
* Action to signal saving a poll.
*
* @param {IPollData} poll - The Poll object that gets to be saved.
* @returns {{
* type: SAVE_POLL,
* poll: IPollData
* }}
*/
export function savePoll(poll: IPollData) {
return {
type: SAVE_POLL,
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 {IPoll} poll - The incoming Poll object.
* @returns {{
* type: REMOVE_POLL,
* poll: IPoll
* }}
*/
export const removePoll = (poll: IPoll) => {
return {
type: REMOVE_POLL,
poll
};
};

View File

@@ -0,0 +1,116 @@
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 { getPoll } from '../functions';
import { IPollData } 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: IPollData;
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: IPollData = 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?.getPolls().answerPoll(pollId, checkBoxStates);
sendAnalytics(createPollEvent('vote.sent'));
dispatch(registerVote(pollId, checkBoxStates));
return false;
}, [ pollId, checkBoxStates, conference ]);
const sendPoll = useCallback(() => {
conference?.getPolls().createPoll(pollId, question, answers);
dispatch(removePoll(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 }
sendPoll = { sendPoll }
setCheckbox = { setCheckbox }
setCreateMode = { setCreateMode }
skipAnswer = { skipAnswer }
skipChangeVote = { skipChangeVote }
submitAnswer = { submitAnswer }
t = { t } />);
};
export default AbstractPollAnswer;

View File

@@ -0,0 +1,182 @@
/* 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, IPollData } 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: IPollData | 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, IPollData ] | 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: ''
},
{
name: ''
} ];
}, [ 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: ''
});
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;
}
dispatch(savePoll({
changingVote: false,
senderId: localParticipant?.id,
showResults: false,
lastVote: null,
question,
answers: filteredAnswers,
saved: true,
editing: false,
pollId: editingPoll ? editingPoll[0] : pollId
}));
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;

View File

@@ -0,0 +1,122 @@
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 { IAnswerData, IPollData, IVoterData } 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 = IAnswerData & {
percentage: number;
voterCount: number;
};
/**
* 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;
pollId: string;
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: IPollData = useSelector(getPoll(pollId));
const creatorName = useBoundSelector(getParticipantDisplayName, 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) {
const answerVoters = Array.isArray(answer.voters) ? answer.voters : undefined;
answerVoters?.forEach(k => allVoters.add(k.id));
}
return poll.answers.map(answer => {
const answerVoters = Array.isArray(answer.voters) ? answer.voters : undefined;
const nrOfVotersPerAnswer = answerVoters?.length || 0;
const percentage = allVoters.size > 0 ? Math.round(nrOfVotersPerAnswer / allVoters.size * 100) : 0;
const voters = answerVoters?.reduce((acc, v) => {
acc.push({
id: v.id,
name: getParticipantById(reduxState, v.id)
? getParticipantDisplayName(reduxState, v.id) : v.name
});
return acc;
}, [] as Array<IVoterData>);
return {
name: answer.name,
percentage,
voters: voters,
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 = { creatorName }
haveVoted = { poll.lastVote !== null }
pollId = { pollId }
question = { poll.question }
showDetails = { showDetails }
t = { t }
toggleIsDetailed = { toggleIsDetailed } />
);
};
export default AbstractPollResults;

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

View File

@@ -0,0 +1,119 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { useDispatch } from 'react-redux';
import { IconCloseLarge } from '../../../base/icons/svg';
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,
creatorName,
poll,
sendPoll,
setCheckbox,
setCreateMode,
skipAnswer,
skipChangeVote,
submitAnswer,
t
} = props;
const { changingVote, saved: pollSaved } = poll;
const dispatch = useDispatch();
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: creatorName })
}
</Text>
</View>
{
pollSaved && <IconButton
onPress = { () => dispatch(removePoll(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(poll.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);

View File

@@ -0,0 +1,224 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlatList, Platform, SafeAreaView, TextInput, View, ViewStyle } from 'react-native';
import { Divider } from 'react-native-paper';
import { useDispatch } from 'react-redux';
import { useKeyboardVisible } from '../../../base/modal/hooks.native';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
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();
const isKeyboardVisible = useKeyboardVisible();
const keyboardVisible = Platform.OS === 'android' && isKeyboardVisible;
/*
* 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 } />
);
/* 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
}) }
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 (
<SafeAreaView 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 = { [
pollsStyles.pollCreateButtonsContainer,
keyboardVisible && { marginBottom: BaseTheme.spacing[8] }
] 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>
</SafeAreaView>
);
};
/*
* 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);

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

View 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(voter =>
(<Text
key = { voter.id }
style = { resultsStyles.voter as TextStyle }>
{ voter.name }
</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);

View File

@@ -0,0 +1,74 @@
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 ]);
const renderEmptyComponent = useCallback(() => (
<View style = { pollsStyles.noPollContent as ViewStyle }>
<Icon
color = { BaseTheme.palette.icon03 }
size = { 100 }
src = { IconMessage } />
<Text
id = 'no-polls-text'
style = { pollsStyles.noPollText as TextStyle } >
{
t('polls.results.empty')
}
</Text>
</View>
), [ t ]);
const noPolls = listPolls.length === 0;
return (
<FlatList
ListEmptyComponent = { renderEmptyComponent }
// @ts-ignore
contentContainerStyle = { noPolls && pollsStyles.emptyListContentContainer as ViewStyle }
data = { listPolls }
extraData = { listPolls }
// eslint-disable-next-line react/jsx-no-bind
keyExtractor = { (item, index) => index.toString() }
ref = { flatlistRef }
renderItem = { renderItem }
style = { noPolls && pollsStyles.emptyListStyle as ViewStyle } />
);
};
export default PollsList;

View File

@@ -0,0 +1,75 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect } from 'react';
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 { unreadPollsCount } = useSelector((state: IReduxState) => state['features/polls']);
useEffect(() => {
const activeUnreadPollsNr = !isPollsTabFocused && unreadPollsCount > 0;
navigation.setOptions({
// eslint-disable-next-line react/no-multi-comp
tabBarLabel: () => (
<TabBarLabelCounter
activeUnreadNr = { activeUnreadPollsNr }
isFocused = { isPollsTabFocused }
label = { t('chat.tabs.polls') }
unreadCount = { unreadPollsCount } />
)
});
}, [ isPollsTabFocused, unreadPollsCount ]);
const renderCreateAPollBtn = useCallback(() => (
!createMode && !isCreatePollsDisabled && <Button
accessibilityLabel = 'polls.create.create'
id = { t('polls.create.create') }
labelKey = 'polls.create.create'
onClick = { onCreate }
style = { pollsStyles.createPollButton }
type = { BUTTON_TYPES.PRIMARY } />
), [ createMode, isCreatePollsDisabled ]);
return (
<JitsiScreen
contentContainerStyle = { pollsStyles.pollPane as StyleType }
disableForcedKeyboardDismiss = { true }
footerComponent = { renderCreateAPollBtn }
hasExtraHeaderHeight = { true }
style = { pollsStyles.pollPaneContainer as StyleType }>
{
createMode
? <PollCreate setCreateMode = { setCreateMode } />
: <PollsList setCreateMode = { setCreateMode } />
}
</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);

View File

@@ -0,0 +1,231 @@
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[4],
marginHorizontal: BaseTheme.spacing[4],
marginTop: BaseTheme.spacing[2]
},
questionText: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text01,
marginLeft: BaseTheme.spacing[2]
},
questionOwnerText: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text03,
marginBottom: BaseTheme.spacing[4],
marginLeft: BaseTheme.spacing[2]
},
optionContainer: {
flexDirection: 'column',
marginTop: BaseTheme.spacing[4],
marginHorizontal: BaseTheme.spacing[4]
},
optionRemoveButton: {
width: 136
},
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[4],
paddingRight: BaseTheme.spacing[4],
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[2],
marginVertical: BaseTheme.spacing[4],
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({
emptyListStyle: {
flex: 1
},
emptyListContentContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
noPollContent: {
alignItems: 'center',
justifyContent: 'center'
},
noPollText: {
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center',
maxWidth: '80%'
},
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[2]
},
pollCreateContainer: {
flex: 1
},
pollCreateSubContainer: {
flex: 1
},
pollCreateButtonsContainer: {
marginHorizontal: BaseTheme.spacing[2]
},
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[2],
marginVertical: BaseTheme.spacing[2]
},
pollCreateButton: {
marginHorizontal: BaseTheme.spacing[2],
flex: 1
},
toggleText: {
color: BaseTheme.palette.action01
},
createPollButton: {
marginHorizontal: BaseTheme.spacing[4],
},
pollPane: {
flex: 1,
padding: BaseTheme.spacing[2]
},
pollPaneContainer: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
bottomLinks: {
flexDirection: 'row',
justifyContent: 'space-between',
marginHorizontal: BaseTheme.spacing[2]
},
fieldSeparator: {
borderBottomWidth: 1,
borderColor: BaseTheme.palette.ui05,
marginTop: BaseTheme.spacing[2]
}
});

View File

@@ -0,0 +1,156 @@
/* 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.pollsBackground,
borderRadius: '8px',
wordBreak: 'break-word'
},
closeBtn: {
cursor: 'pointer',
float: 'right'
},
header: {
marginBottom: '24px'
},
question: {
...theme.typography.heading6,
color: theme.palette.pollsQuestion,
marginBottom: '8px'
},
creator: {
...theme.typography.bodyShortRegular,
color: theme.palette.pollsSubtitle
},
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,
setCheckbox,
setCreateMode,
skipAnswer,
skipChangeVote,
sendPoll,
submitAnswer,
t
}: AbstractProps) => {
const { changingVote, saved: pollSaved } = poll;
const dispatch = useDispatch();
const { classes } = useStyles();
return (
<div
className = { classes.container }
id = { `poll-${poll.pollId}` }>
{
pollSaved && <Icon
ariaLabel = { t('polls.closeButton') }
className = { classes.closeBtn }
onClick = { () => dispatch(removePoll(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 }
id = { `poll-answer-checkbox-${poll.pollId}-${index}` }
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(poll.pollId, true));
} }
type = { BUTTON_TYPES.SECONDARY } />
<Button
accessibilityLabel = { t('polls.create.accessibilityLabel.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);

View 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.pollsTitle,
margin: '24px 0 16px'
},
questionContainer: {
paddingBottom: '24px',
borderBottom: `1px solid ${theme.palette.pollsSeparator}`
},
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
}) }
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 }
data-testid = { `remove-polls-answer-input-${i}` }
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);

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

View File

@@ -0,0 +1,182 @@
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.pollsBackground,
borderRadius: '8px',
wordBreak: 'break-word'
},
header: {
marginBottom: '16px'
},
question: {
...theme.typography.heading6,
color: theme.palette.pollsQuestion,
marginBottom: '8px'
},
creator: {
...theme.typography.bodyShortRegular,
color: theme.palette.pollsSubtitle
},
resultList: {
listStyleType: 'none',
margin: 0,
padding: 0,
'& li': {
marginBottom: '16px'
}
},
answerName: {
display: 'flex',
flexShrink: 1,
overflowWrap: 'anywhere',
...theme.typography.bodyShortRegular,
color: theme.palette.pollsAnswer,
marginBottom: '4px'
},
answerResultContainer: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
minWidth: '10em'
},
barContainer: {
backgroundColor: theme.palette.pollsBarBackground,
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.pollsBarPercentage
},
voters: {
margin: 0,
marginTop: '4px',
listStyleType: 'none',
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.pollsVotersBackground,
borderRadius: theme.shape.borderRadius,
padding: '8px 16px',
'& li': {
...theme.typography.bodyShortRegular,
color: theme.palette.pollsVotersText,
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,
pollId,
showDetails,
question,
t,
toggleIsDetailed
}: AbstractProps) => {
const { classes } = useStyles();
return (
<div
className = { classes.container }
id = { `poll-${pollId}` }>
<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 }
id = { `poll-result-${pollId}-${index}` }>
<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);

View File

@@ -0,0 +1,45 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconInfo } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { openPollsPanel } from '../../../chat/actions.any';
import { arePollsDisabled } from '../../../conference/functions.any';
/**
* Component that renders a button to open the polls panel.
*
* @augments AbstractButton
*/
class PollsButton extends AbstractButton<AbstractButtonProps> {
override icon = IconInfo;
override label = 'toolbar.polls';
override tooltip = 'toolbar.polls';
/**
* Handles clicking the button to open the polls panel.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
dispatch(openPollsPanel());
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {IReduxState} state - The Redux state.
* @returns {Object} - Mapped props.
*/
function mapStateToProps(state: IReduxState) {
return {
visible: !arePollsDisabled(state)
};
}
export default translate(connect(mapStateToProps)(PollsButton));

View 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.pollsSubtitle,
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;

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

View File

@@ -0,0 +1,2 @@
export const CHAR_LIMIT = 500;
export const ANSWERS_LIMIT = 255;

View File

@@ -0,0 +1,87 @@
import { IReduxState } from '../app/types';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { iAmVisitor } from '../visitors/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 { unreadPollsCount } = state['features/polls'];
return unreadPollsCount;
}
/**
* 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) {
if (iAmVisitor(state)) {
return true;
}
const { pollCreationRequiresPermission } = state['features/dynamic-branding'];
if (!pollCreationRequiresPermission) {
return false;
}
return !isJwtFeatureEnabled(state, MEET_FEATURES.CREATE_POLLS, false);
}

View File

@@ -0,0 +1,24 @@
import { useSelector } from 'react-redux';
import { arePollsDisabled } from '../conference/functions.any';
import PollsButton from './components/web/PollsButton';
const polls = {
key: 'polls',
Content: PollsButton,
group: 2
};
/**
* A hook that returns the polls button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined} - The polls button object or undefined.
*/
export function usePollsButton() {
const isPollsDisabled = useSelector(arePollsDisabled);
if (!isPollsDisabled) {
return polls;
}
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:polls');

View File

@@ -0,0 +1,154 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { playSound } from '../base/sounds/actions';
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 { IAnswerData, IIncomingAnswerData } from './types';
/**
* The maximum number of answers a poll can have.
*/
const MAX_ANSWERS = 32;
/**
* Normalizes the answers received for a poll, keeping only the expected fields
* and shapes. Answers arrive from remote participants and cannot be trusted to
* match the expected structure, so we coerce the name to a string and only keep
* voters when it is an array.
*
* @param {any} answers - The raw answers array carried by the polls message.
* @returns {Array<IAnswerData>}
*/
function _sanitizeAnswers(answers: any): Array<IAnswerData> {
if (!Array.isArray(answers)) {
return [];
}
return answers.slice(0, MAX_ANSWERS).map((answer: any) => ({
name: String(answer?.name ?? ''),
voters: Array.isArray(answer?.voters) ? answer.voters : undefined
}));
}
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Clear messages or close the chat modal if it's left
* open.
* When joining new conference set up the listeners for polls.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch, getState }, previousConference): void => {
if (conference !== previousConference) {
dispatch(clearPolls());
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.POLL_RECEIVED, (data: any) => {
_handleReceivedPollsData(data, dispatch, getState);
});
conference.on(JitsiConferenceEvents.POLL_ANSWER_RECEIVED, (data: any) => {
_handleReceivedPollsAnswer(data, dispatch, getState);
});
}
}
});
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const result = next(action);
switch (action.type) {
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 new or history polls to load.
*
* @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 _handleReceivedPollsData(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
if (arePollsDisabled(getState())) {
return;
}
const { pollId, answers, senderId, question, history } = data;
const poll = {
changingVote: false,
senderId,
showResults: false,
lastVote: null,
question,
answers: _sanitizeAnswers(answers),
saved: false,
editing: false,
pollId
};
dispatch(receivePoll(poll, !history));
if (!history) {
dispatch(showNotification({
appearance: NOTIFICATION_TYPE.NORMAL,
titleKey: 'polls.notification.title',
descriptionKey: 'polls.notification.description'
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
}
}
/**
* Handles receiving of pools answers.
*
* @param {Object} data - The json data carried by the polls message.
* @param {Function} dispatch - The dispatch function.
* @param {Function} getState - The getState function.
*
* @returns {void}
*/
function _handleReceivedPollsAnswer(data: any, dispatch: IStore['dispatch'], getState: IStore['getState']) {
if (arePollsDisabled(getState())) {
return;
}
const { pollId, answers, senderId, senderName } = data;
const receivedAnswer: IIncomingAnswerData = {
answers: Array.isArray(answers) ? answers.slice(0, MAX_ANSWERS).map(Boolean) : [],
pollId,
senderId,
voterName: getParticipantById(getState(), senderId)
? getParticipantDisplayName(getState(), senderId) : senderName
};
dispatch(receiveAnswer(receivedAnswer));
}

View File

@@ -0,0 +1,187 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CHANGE_VOTE,
CLEAR_POLLS,
EDIT_POLL,
RECEIVE_ANSWER,
RECEIVE_POLL,
REGISTER_VOTE,
REMOVE_POLL,
RESET_UNREAD_POLLS_COUNT,
SAVE_POLL
} from './actionTypes';
import logger from './logger';
import { IIncomingAnswerData, IPollData, IVoterData } from './types';
const INITIAL_STATE = {
polls: {},
// Number of not read message
unreadPollsCount: 0
};
export interface IPollsState {
polls: {
[pollId: string]: IPollData;
};
unreadPollsCount: number;
}
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.poll.pollId]: action.poll
},
unreadPollsCount: state.unreadPollsCount + 1
};
}
case SAVE_POLL: {
return {
...state,
polls: {
...state.polls,
[action.poll.pollId]: action.poll
}
};
}
// Reducer triggered when an answer is received
// The answer is added to an existing poll
case RECEIVE_ANSWER: {
const { answer }: { answer: IIncomingAnswerData; } = action;
const pollId = answer.pollId;
const poll = state.polls[pollId];
// if the poll doesn't exist
if (!(pollId in state.polls)) {
logger.warn('Requested poll does not exist', { pollId });
return state;
}
// if the poll exists, we update it with the incoming answer
for (let i = 0; i < poll.answers.length; i++) {
// if the answer was chosen, we add the senderId to the array of voters of this answer
let voters = Array.isArray(poll.answers[i].voters) ? poll.answers[i].voters as IVoterData[] : [];
if (voters.find(user => user.id === answer.senderId)) {
if (!answer.answers[i]) {
voters = voters.filter(user => user.id !== answer.senderId);
}
} else if (answer.answers[i]) {
voters.push({
id: answer.senderId,
name: answer.voterName
});
}
poll.answers[i].voters = voters?.length ? voters : undefined;
}
// finally we update the state by returning the updated poll
return {
...state,
polls: {
...state.polls,
[pollId]: {
...poll,
answers: [ ...poll.answers ]
}
}
};
}
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_UNREAD_POLLS_COUNT: {
return {
...state,
unreadPollsCount: 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.poll.pollId]: _removedPoll, ...newState } = state.polls;
return {
...state,
polls: {
...newState
}
};
}
default:
return state;
}
});

View File

@@ -0,0 +1,119 @@
/**
* TODO: move to ljm.
*/
export interface IIncomingAnswer {
/**
* 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 sender of this answer.
*/
senderId: string;
}
/**
* Extension of IIncomingAnswer with UI only fields.
*/
export interface IIncomingAnswerData extends IIncomingAnswer {
/**
* Name of the voter for this answer.
*/
voterName: string;
}
/**
* TODO: move to ljm and use it from there.
*/
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>;
/**
* The unique ID of this poll.
*/
pollId: string;
/**
* The question asked by this poll.
*/
question: string;
/**
* ID of the sender of this poll.
*/
senderId: string | undefined;
}
/**
* Extension of IPoll with UI only fields.
*/
export interface IPollData extends IPoll {
/**
* Whether the poll vote is being edited/changed. UI only, not stored on the backend.
*/
changingVote: boolean;
/**
* 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;
/**
* Whether poll is saved or not?. UI only, not stored on the backend.
*/
saved: boolean;
/**
* Whether the results should be shown instead of the answer form.
* UI only, not stored on the backend.
*/
showResults: boolean;
}
/**
* TODO: move to ljm and use it from there.
*/
export interface IVoterData {
/**
* The id of the voter.
*/
id: string;
/**
* Voter name if voter is not in the meeting.
*/
name: string;
}
/**
* TODO: move to ljm and use it from there.
*/
export interface IAnswerData {
/**
* The answer name chosen for the poll.
*/
name: string;
/**
* An array of voters.
*/
voters?: Array<IVoterData>;
}