feat(polls-history): control polls through local storage

pull/14947/head jitsi-meet_9639
Calin-Teodor 10 months ago committed by Calinteodor
parent 2514617417
commit 60b4581cb5
  1. 1
      lang/main.json
  2. 1
      react/features/app/middlewares.any.ts
  3. 1
      react/features/app/reducers.any.ts
  4. 2
      react/features/app/types.ts
  5. 8
      react/features/base/conference/functions.ts
  6. 23
      react/features/polls-history/actionTypes.ts
  7. 47
      react/features/polls-history/actions.ts
  8. 46
      react/features/polls-history/middleware.ts
  9. 52
      react/features/polls-history/reducer.ts
  10. 9
      react/features/polls/actionTypes.ts
  11. 81
      react/features/polls/actions.ts
  12. 12
      react/features/polls/components/AbstractPollAnswer.tsx
  13. 8
      react/features/polls/components/AbstractPollCreate.tsx
  14. 15
      react/features/polls/components/AbstractPollResults.tsx
  15. 13
      react/features/polls/components/native/PollAnswer.tsx
  16. 6
      react/features/polls/components/native/styles.ts
  17. 17
      react/features/polls/components/web/PollAnswer.tsx
  18. 2
      react/features/polls/components/web/PollsList.tsx
  19. 6
      react/features/polls/middleware.ts
  20. 49
      react/features/polls/reducer.ts

@ -877,6 +877,7 @@
"submit": "Submit"
},
"by": "By {{ name }}",
"closeButton": "Close poll",
"create": {
"addOption": "Add option",
"answerPlaceholder": "Option {{index}}",

@ -39,6 +39,7 @@ import '../notifications/middleware';
import '../overlay/middleware';
import '../participants-pane/middleware';
import '../polls/middleware';
import '../polls-history/middleware';
import '../reactions/middleware';
import '../recent-list/middleware';
import '../recording/middleware';

@ -41,6 +41,7 @@ import '../notifications/reducer';
import '../overlay/reducer';
import '../participants-pane/reducer';
import '../polls/reducer';
import '../polls-history/reducer';
import '../reactions/reducer';
import '../recent-list/reducer';
import '../recording/reducer';

@ -60,6 +60,7 @@ import { INotificationsState } from '../notifications/reducer';
import { IOverlayState } from '../overlay/reducer';
import { IParticipantsPaneState } from '../participants-pane/reducer';
import { IPollsState } from '../polls/reducer';
import { IPollsHistoryState } from '../polls-history/reducer';
import { IPowerMonitorState } from '../power-monitor/reducer';
import { IPrejoinState } from '../prejoin/reducer';
import { IReactionsState } from '../reactions/reducer';
@ -149,6 +150,7 @@ export interface IReduxState {
'features/overlay': IOverlayState;
'features/participants-pane': IParticipantsPaneState;
'features/polls': IPollsState;
'features/polls-history': IPollsHistoryState;
'features/power-monitor': IPowerMonitorState;
'features/prejoin': IPrejoinState;
'features/reactions': IReactionsState;

@ -37,14 +37,6 @@ import { IJitsiConference } from './reducer';
*/
export const getConferenceState = (state: IReduxState) => state['features/base/conference'];
/**
* Is the conference joined or not.
*
* @param {IReduxState} state - Global state.
* @returns {boolean}
*/
export const getIsConferenceJoined = (state: IReduxState) => Boolean(getConferenceState(state).conference);
/**
* Attach a set of local tracks to a conference.
*

@ -0,0 +1,23 @@
/**
* The type of the action which signals that we need to remove poll from the history(local storage).
*
* {
* type: REMOVE_POLL_FROM_HISTORY,
* meetingId: string,
* pollId: string,
* poll: IPoll
* }
*/
export const REMOVE_POLL_FROM_HISTORY = 'REMOVE_POLL_FROM_HISTORY';
/**
* The type of the action triggered when the poll is saved in history(local storage).
*
* {
* type: SAVE_POLL_IN_HISTORY,
* poll: Poll,
* pollId: string,
* saved: boolean
* }
*/
export const SAVE_POLL_IN_HISTORY = 'SAVE_POLL_IN_HISTORY';

@ -0,0 +1,47 @@
import { IPoll } from '../polls/types';
import { REMOVE_POLL_FROM_HISTORY, SAVE_POLL_IN_HISTORY } from './actionTypes';
/**
* Action to signal saving a poll in history(local storage).
*
* @param {string} meetingId - The id of the meeting in which polls get to be saved.
* @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_IN_HISTORY,
* meetingId: string,
* pollId: string,
* poll: IPoll
* }}
*/
export function savePollInHistory(meetingId: string | undefined, pollId: string, poll: IPoll) {
return {
type: SAVE_POLL_IN_HISTORY,
meetingId,
pollId,
poll
};
}
/**
* Action to signal that existing poll needs to be deleted from history(local storage).
*
* @param {string} meetingId - The id of the meeting in which poll gets to be removed.
* @param {string} pollId - The id of the poll that gets to be removed.
* @param {IPoll} poll - The incoming IPoll object.
* @returns {{
* type: REMOVE_POLL_FROM_HISTORY,
* meetingId: string,
* pollId: string,
* poll: IPoll
* }}
*/
export const removePollFromHistory = (meetingId: string | undefined, pollId: string, poll: IPoll) => {
return {
type: REMOVE_POLL_FROM_HISTORY,
meetingId,
pollId,
poll
};
};

@ -0,0 +1,46 @@
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { REMOVE_POLL, SAVE_POLL } from '../polls/actionTypes';
import { savePoll } from '../polls/actions';
import { removePollFromHistory, savePollInHistory } from './actions';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const result = next(action);
const { room: meetingId } = getState()['features/base/conference'];
switch (action.type) {
case CONFERENCE_JOINED: {
const state = getState();
const pollsHistory = meetingId && state['features/polls-history'].polls?.[meetingId];
if (!pollsHistory) {
return null;
}
for (const key in pollsHistory) {
if (pollsHistory.hasOwnProperty(key) && pollsHistory[key].saved) {
dispatch(savePoll(key, pollsHistory[key]));
}
}
break;
}
case REMOVE_POLL: {
const { poll, pollId } = action;
dispatch(removePollFromHistory(meetingId, pollId, poll));
break;
}
case SAVE_POLL: {
const { poll, pollId } = action;
dispatch(savePollInHistory(meetingId, pollId, poll));
break;
}
}
return result;
});

@ -0,0 +1,52 @@
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { IPoll } from '../polls/types';
import { REMOVE_POLL_FROM_HISTORY, SAVE_POLL_IN_HISTORY } from './actionTypes';
const INITIAL_STATE = {
polls: {}
};
export interface IPollsHistoryState {
polls: {
[meetingId: string]: {
[pollId: string]: IPoll;
};
};
}
const STORE_NAME = 'features/polls-history';
PersistenceRegistry.register(STORE_NAME, INITIAL_STATE);
ReducerRegistry.register<IPollsHistoryState>(STORE_NAME, (state = INITIAL_STATE, action): IPollsHistoryState => {
switch (action.type) {
case REMOVE_POLL_FROM_HISTORY: {
if (Object.keys(state.polls[action.meetingId] ?? {})?.length === 1) {
delete state.polls[action.meetingId];
} else {
delete state.polls[action.meetingId]?.[action.pollId];
}
return state;
}
case SAVE_POLL_IN_HISTORY: {
return {
...state,
polls: {
...state.polls,
[action.meetingId]: {
...state.polls[action.meetingId],
[action.pollId]: action.poll
}
}
};
}
default:
return state;
}
});

@ -10,7 +10,7 @@ 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.
* For example, we are moving to another conference.
*
* {
* type: CLEAR_POLLS
@ -65,14 +65,15 @@ export const RECEIVE_ANSWER = 'RECEIVE_ANSWER';
export const REGISTER_VOTE = 'REGISTER_VOTE';
/**
* The type of the action which retracts a vote.
* The type of the action which signals that we need to remove poll.
*
* {
* type: RETRACT_VOTE,
* type: REMOVE_POLL,
* pollId: string,
* poll: IPoll
* }
*/
export const RETRACT_VOTE = 'RETRACT_VOTE';
export const REMOVE_POLL = 'REMOVE_POLL';
/**
* The type of the action triggered when the poll tab in chat pane is closed

@ -5,8 +5,8 @@ import {
RECEIVE_ANSWER,
RECEIVE_POLL,
REGISTER_VOTE,
REMOVE_POLL,
RESET_NB_UNREAD_POLLS,
RETRACT_VOTE,
SAVE_POLL
} from './actionTypes';
import { IAnswer, IPoll } from './types';
@ -19,7 +19,9 @@ import { IAnswer, IPoll } from './types';
* }}
*/
export const clearPolls = () => {
return { type: CLEAR_POLLS };
return {
type: CLEAR_POLLS
};
};
/**
@ -50,16 +52,16 @@ export const setVoteChanging = (pollId: string, value: boolean) => {
* @param {boolean} notify - Whether to send or not a notification.
* @returns {{
* type: RECEIVE_POLL,
* poll: IPoll,
* pollId: string,
* poll: IPoll,
* notify: boolean
* }}
*/
export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
return {
type: RECEIVE_POLL,
poll,
pollId,
poll,
notify
};
};
@ -71,15 +73,15 @@ export const receivePoll = (pollId: string, poll: IPoll, notify: boolean) => {
* @param {IAnswer} answer - The incoming Answer object.
* @returns {{
* type: RECEIVE_ANSWER,
* answer: IAnswer,
* pollId: string
* pollId: string,
* answer: IAnswer
* }}
*/
export const receiveAnswer = (pollId: string, answer: IAnswer) => {
return {
type: RECEIVE_ANSWER,
answer,
pollId
pollId,
answer
};
};
@ -90,39 +92,23 @@ export const receiveAnswer = (pollId: string, answer: IAnswer) => {
* @param {?Array<boolean>} answers - The new answers.
* @returns {{
* type: REGISTER_VOTE,
* answers: ?Array<boolean>,
* pollId: string
* pollId: string,
* answers: ?Array<boolean>
* }}
*/
export const registerVote = (pollId: string, answers: Array<boolean> | null) => {
return {
type: REGISTER_VOTE,
answers,
pollId
};
};
/**
* Action to retract a vote on a poll.
*
* @param {string} pollId - The id of the poll.
* @returns {{
* type: RETRACT_VOTE,
* pollId: string
* }}
*/
export const retractVote = (pollId: string) => {
return {
type: RETRACT_VOTE,
pollId
pollId,
answers
};
};
/**
* Action to signal the closing of the polls tab.
* Action to signal the number reset of unread polls.
*
* @returns {{
* type: POLL_TAB_CLOSED
* type: RESET_NB_UNREAD_POLLS
* }}
*/
export function resetNbUnreadPollsMessages() {
@ -136,22 +122,18 @@ export function resetNbUnreadPollsMessages() {
*
* @param {string} pollId - The id of the poll that gets to be saved.
* @param {IPoll} poll - The Poll object that gets to be saved.
* @param {boolean} saved - Whether the poll is saved or not.
* @returns {{
* type: RECEIVE_POLL,
* poll: IPoll,
* type: SAVE_POLL,
* meetingId: string,
* pollId: string,
* saved: boolean
* poll: IPoll
* }}
*/
export function savePoll(pollId: string, poll: IPoll, saved: boolean) {
export function savePoll(pollId: string, poll: IPoll) {
return {
type: SAVE_POLL,
pollId,
poll: {
...poll,
saved
}
poll
};
}
@ -161,7 +143,7 @@ export function savePoll(pollId: string, poll: IPoll, saved: boolean) {
* @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: RECEIVE_POLL,
* type: EDIT_POLL,
* pollId: string,
* editing: boolean
* }}
@ -173,3 +155,22 @@ export function editPoll(pollId: string, editing: boolean) {
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
};
};

@ -7,8 +7,9 @@ import { sendAnalytics } from '../../analytics/functions';
import { IReduxState } from '../../app/types';
import { getParticipantDisplayName } from '../../base/participants/functions';
import { useBoundSelector } from '../../base/util/hooks';
import { editPoll, registerVote, setVoteChanging } from '../actions';
import { registerVote, removePoll, setVoteChanging } from '../actions';
import { COMMAND_ANSWER_POLL, COMMAND_NEW_POLL } from '../constants';
import { getPoll } from '../functions';
import { IPoll } from '../types';
/**
@ -48,9 +49,9 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
const { pollId, setCreateMode } = props;
const conference: any = useSelector((state: IReduxState) => state['features/base/conference'].conference);
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const poll: IPoll = useSelector((state: IReduxState) => state['features/polls'].polls[pollId]);
const poll: IPoll = useSelector(getPoll(pollId));
const { answers, lastVote, question, senderId } = poll;
@ -75,7 +76,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
const dispatch = useDispatch();
const submitAnswer = useCallback(() => {
conference.sendMessage({
conference?.sendMessage({
type: COMMAND_ANSWER_POLL,
pollId,
answers: checkBoxStates
@ -95,8 +96,7 @@ const AbstractPollAnswer = (Component: ComponentType<AbstractProps>) => (props:
answers: answers.map(answer => answer.name)
});
dispatch(editPoll(pollId, false));
dispatch(removePoll(pollId, poll));
}, [ conference, question, answers ]);
const skipAnswer = useCallback(() => {

@ -121,7 +121,7 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
setAnswers(newAnswers);
}, [ answers ]);
const conference = useSelector((state: IReduxState) => state['features/base/conference'].conference);
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const dispatch = useDispatch();
@ -147,14 +147,14 @@ const AbstractPollCreate = (Component: ComponentType<AbstractProps>) => (props:
lastVote: null,
question,
answers: filteredAnswers,
saved: false,
saved: true,
editing: false
};
if (editingPoll) {
dispatch(savePoll(editingPoll[0], poll, true));
dispatch(savePoll(editingPoll[0], poll));
} else {
dispatch(savePoll(pollId, poll, true));
dispatch(savePoll(pollId, poll));
}
sendAnalytics(createPollEvent('created'));

@ -10,6 +10,7 @@ import { getParticipantById, getParticipantDisplayName } from '../../base/partic
import { useBoundSelector } from '../../base/util/hooks';
import { setVoteChanging } from '../actions';
import { getPoll } from '../functions';
import { IPoll } from '../types';
/**
* The type of the React {@code Component} props of inheriting component.
@ -53,8 +54,8 @@ export type AbstractProps = {
const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props: InputProps) => {
const { pollId } = props;
const pollDetails = useSelector(getPoll(pollId));
const participant = useBoundSelector(getParticipantById, pollDetails.senderId);
const poll: IPoll = useSelector(getPoll(pollId));
const participant = useBoundSelector(getParticipantById, poll.senderId);
const reduxState = useSelector((state: IReduxState) => state);
const [ showDetails, setShowDetails ] = useState(false);
@ -67,14 +68,14 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
const allVoters = new Set();
// Getting every voters ID that participates to the poll
for (const answer of pollDetails.answers) {
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 pollDetails.answers.map(answer => {
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;
@ -98,7 +99,7 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
voterCount: nrOfVotersPerAnswer
};
});
}, [ pollDetails.answers, showDetails ]);
}, [ poll.answers, showDetails ]);
const dispatch = useDispatch();
const changeVote = useCallback(() => {
@ -113,8 +114,8 @@ const AbstractPollResults = (Component: ComponentType<AbstractProps>) => (props:
answers = { answers }
changeVote = { changeVote }
creatorName = { participant ? participant.name : '' }
haveVoted = { pollDetails.lastVote !== null }
question = { pollDetails.question }
haveVoted = { poll.lastVote !== null }
question = { poll.question }
showDetails = { showDetails }
t = { t }
toggleIsDetailed = { toggleIsDetailed } />

@ -4,11 +4,13 @@ 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 } from '../../actions';
import { editPoll, removePoll } from '../../actions';
import { isSubmitAnswerDisabled } from '../../functions';
import AbstractPollAnswer, { AbstractProps } from '../AbstractPollAnswer';
@ -34,11 +36,20 @@ const PollAnswer = (props: AbstractProps) => {
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 style = { pollsStyles.answerContent as ViewStyle }>
{
poll.answers.map((answer, index: number) => (

@ -4,6 +4,12 @@ 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],

@ -4,11 +4,13 @@ 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 { withPixelLineHeight } from '../../../base/styles/functions.web';
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 } from '../../actions';
import { editPoll, removePoll } from '../../actions';
import { isSubmitAnswerDisabled } from '../../functions';
import AbstractPollAnswer, { AbstractProps } from '../AbstractPollAnswer';
@ -21,6 +23,10 @@ const useStyles = makeStyles()(theme => {
borderRadius: '8px',
wordBreak: 'break-word'
},
closeBtn: {
cursor: 'pointer',
float: 'right'
},
header: {
marginBottom: '24px'
},
@ -73,6 +79,15 @@ const PollAnswer = ({
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 }

@ -46,8 +46,8 @@ interface IPollListProps {
const PollsList = ({ setCreateMode }: IPollListProps) => {
const { t } = useTranslation();
const { classes, theme } = useStyles();
const { polls } = useSelector((state: IReduxState) => state['features/polls']);
const polls = useSelector((state: IReduxState) => state['features/polls'].polls);
const pollListEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {

@ -25,10 +25,8 @@ import { IAnswer, IPoll, IPollData } from './types';
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference) => {
(conference, { dispatch }, previousConference): void => {
if (conference !== previousConference) {
// conference changed, left or failed...
// clean old polls
dispatch(clearPolls());
}
});
@ -101,7 +99,6 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
break;
}
}
return result;
@ -122,6 +119,7 @@ function _handleReceivePollsMessage(data: any, dispatch: IStore['dispatch'], get
}
switch (data.type) {
case COMMAND_NEW_POLL: {
const { pollId, answers, senderId, question } = data;

@ -7,8 +7,8 @@ import {
RECEIVE_ANSWER,
RECEIVE_POLL,
REGISTER_VOTE,
REMOVE_POLL,
RESET_NB_UNREAD_POLLS,
RETRACT_VOTE,
SAVE_POLL
} from './actionTypes';
import { IAnswer, IPoll } from './types';
@ -27,7 +27,9 @@ export interface IPollsState {
};
}
ReducerRegistry.register<IPollsState>('features/polls', (state = INITIAL_STATE, action): IPollsState => {
const STORE_NAME = 'features/polls';
ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action): IPollsState => {
switch (action.type) {
case CHANGE_VOTE: {
@ -54,8 +56,7 @@ ReducerRegistry.register<IPollsState>('features/polls', (state = INITIAL_STATE,
}
// Reducer triggered when a poll is received or saved.
case RECEIVE_POLL:
case SAVE_POLL: {
case RECEIVE_POLL: {
return {
...state,
polls: {
@ -66,6 +67,16 @@ ReducerRegistry.register<IPollsState>('features/polls', (state = INITIAL_STATE,
};
}
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: {
@ -139,37 +150,41 @@ ReducerRegistry.register<IPollsState>('features/polls', (state = INITIAL_STATE,
};
}
case RETRACT_VOTE: {
const { pollId }: { pollId: string; } = action;
case RESET_NB_UNREAD_POLLS: {
return {
...state,
nbUnreadPolls: 0
};
}
case EDIT_POLL: {
return {
...state,
polls: {
...state.polls,
[pollId]: {
...state.polls[pollId],
showResults: false
[action.pollId]: {
...state.polls[action.pollId],
editing: action.editing
}
}
};
}
case RESET_NB_UNREAD_POLLS: {
case REMOVE_POLL: {
if (Object.keys(state.polls ?? {})?.length === 1) {
return {
...state,
nbUnreadPolls: 0
...INITIAL_STATE
};
}
case EDIT_POLL: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [action.pollId]: _removedPoll, ...newState } = state.polls;
return {
...state,
polls: {
...state.polls,
[action.pollId]: {
...state.polls[action.pollId],
editing: action.editing
}
...newState
}
};
}

Loading…
Cancel
Save