diff --git a/bottombar.tsx b/bottombar.tsx index 323559a..423d7be 100644 --- a/bottombar.tsx +++ b/bottombar.tsx @@ -1,7 +1,13 @@ -import { ToolbarPreset, ToolbarComponents } from "customization-api"; +import { + ToolbarPreset, + ToolbarComponents, + useSidePanel, +} from "customization-api"; import React from "react"; +import { PollButtonWithSidePanel, POLL_SIDEBAR_NAME } from "./polling-ui"; const Bottombar = () => { + const { setSidePanel } = useSidePanel(); const { MeetingTitleToolbarItem, ParticipantCountToolbarItem, @@ -62,6 +68,15 @@ const Bottombar = () => { return w > 767 ? true : false; }, }, + poll: { + hide: (w) => { + return w > 767 ? true : false; + }, + component: PollButtonWithSidePanel, + onPress: () => { + setSidePanel(POLL_SIDEBAR_NAME); + }, + }, }, }, "meeting-title": { diff --git a/index.tsx b/index.tsx index 708c881..d247102 100644 --- a/index.tsx +++ b/index.tsx @@ -1,9 +1,23 @@ import { customize, isMobileUA } from "customization-api"; import Topbar from "./topbar"; import Bottombar from "./bottombar"; +import PollSidebar from "./polling/components/PollSidebar"; +import Poll from "./polling/components/Poll"; +import { POLL_SIDEBAR_NAME } from "./polling-ui"; const config = customize({ components: { + wrapper: Poll, + customSidePanel: () => { + return [ + { + name: POLL_SIDEBAR_NAME, + component: PollSidebar, + title: "Polls", + onClose: () => {}, + }, + ]; + }, videoCall: { topToolBar: Topbar, bottomToolBar: Bottombar, diff --git a/polling-ui.tsx b/polling-ui.tsx new file mode 100644 index 0000000..50ddd2f --- /dev/null +++ b/polling-ui.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { + ToolbarPreset, + useSidePanel, + ToolbarItem, + ImageIcon, + ThemeConfig, + $config, + useActionSheet, + IconButton, + IconButtonProps, +} from "customization-api"; +import { View, Text, StyleSheet } from "react-native"; +import pollIcons from "./polling/poll-icons"; + +const POLL_SIDEBAR_NAME = "side-panel-poll"; + +const PollButtonWithSidePanel = () => { + const { isOnActionSheet } = useActionSheet(); + const { setSidePanel } = useSidePanel(); + + // On smaller screens + if (isOnActionSheet) { + const iconButtonProps: IconButtonProps = { + onPress: () => { + setSidePanel(POLL_SIDEBAR_NAME); + }, + iconProps: { + icon: pollIcons["bar-chart"], + tintColor: $config.SECONDARY_ACTION_COLOR, + }, + btnTextProps: { + text: "Polls", + textColor: $config.FONT_COLOR, + numberOfLines: 1, + textStyle: { + marginTop: 8, + }, + }, + isOnActionSheet: isOnActionSheet, + }; + + return ( + + + + ); + } + // On bigger screens + return ( + + + + + Polls + + ); +}; + +export { PollButtonWithSidePanel, POLL_SIDEBAR_NAME }; + +const style = StyleSheet.create({ + toolbarItem: { + display: "flex", + flexDirection: "row", + }, + toolbarImg: { + display: "flex", + justifyContent: "center", + alignItems: "center", + marginRight: 8, + }, + toolbarText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontWeight: "400", + fontFamily: ThemeConfig.FontFamily.sansPro, + }, +}); diff --git a/polling/components/Poll.tsx b/polling/components/Poll.tsx new file mode 100644 index 0000000..b5133ee --- /dev/null +++ b/polling/components/Poll.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import {PollModalType, PollProvider, usePoll} from '../context/poll-context'; +import PollFormWizardModal from './modals/PollFormWizardModal'; +import {PollEventsProvider, PollEventsSubscriber} from '../context/poll-events'; +import PollResponseFormModal from './modals/PollResponseFormModal'; +import PollResultModal from './modals/PollResultModal'; +import PollConfirmModal from './modals/PollEndConfirmModal'; +import PollItemNotFound from './modals/PollItemNotFound'; +import {log} from '../helpers'; + +function Poll({children}: {children?: React.ReactNode}) { + return ( + + + + {children} + + + + + ); +} + +function PollModals() { + const {modalState, polls} = usePoll(); + // Log only in development mode to prevent performance hits + if (process.env.NODE_ENV === 'development') { + log('polls data changed: ', polls); + } + + const renderModal = () => { + switch (modalState.modalType) { + case PollModalType.DRAFT_POLL: + if (modalState.id && polls[modalState.id]) { + const editFormObject = {...polls[modalState.id]}; + return ; + } + return ; + case PollModalType.PREVIEW_POLL: + if (modalState.id && polls[modalState.id]) { + const previewFormObject = {...polls[modalState.id]}; + return ( + + ); + } + break; + case PollModalType.RESPOND_TO_POLL: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.VIEW_POLL_RESULTS: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.END_POLL_CONFIRMATION: + if (modalState.id && polls[modalState.id]) { + return ; + } + return ; + case PollModalType.DELETE_POLL_CONFIRMATION: + if (modalState.id && polls[modalState.id]) { + return ( + + ); + } + return ; + case PollModalType.NONE: + break; + default: + log('Unknown modal type: ', modalState); + return <>; + } + }; + + return <>{renderModal()}; +} + +export default Poll; diff --git a/polling/components/PollAvatarHeader.tsx b/polling/components/PollAvatarHeader.tsx new file mode 100644 index 0000000..d584f05 --- /dev/null +++ b/polling/components/PollAvatarHeader.tsx @@ -0,0 +1,86 @@ +import {Text, View, StyleSheet} from 'react-native'; +import React from 'react'; +import {PollItem} from '../context/poll-context'; +import { + useContent, + UserAvatar, + ThemeConfig, + useString, + videoRoomUserFallbackText, + UidType, + $config, +} from 'customization-api'; + +interface Props { + pollItem: PollItem; +} + +function PollAvatarHeader({pollItem}: Props) { + const remoteUserDefaultLabel = useString(videoRoomUserFallbackText)(); + const {defaultContent} = useContent(); + + const getPollCreaterName = ({uid, name}: {uid: UidType; name: string}) => { + return defaultContent[uid]?.name || name || remoteUserDefaultLabel; + }; + + return ( + + + + + + + {getPollCreaterName(pollItem.createdBy)} + + {pollItem.type} + + + ); +} +export const style = StyleSheet.create({ + titleCard: { + display: 'flex', + flexDirection: 'row', + gap: 12, + }, + title: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + titleAvatar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + titleAvatarContainer: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + titleAvatarContainerText: { + fontSize: ThemeConfig.FontSize.small, + lineHeight: 16, + fontWeight: '600', + color: $config.VIDEO_AUDIO_TILE_COLOR, + }, + titleText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontWeight: '700', + lineHeight: 20, + }, + titleSubtext: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + lineHeight: 16, + }, +}); + +export default PollAvatarHeader; diff --git a/polling/components/PollCard.tsx b/polling/components/PollCard.tsx new file mode 100644 index 0000000..51fb2f2 --- /dev/null +++ b/polling/components/PollCard.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import {Text, View, StyleSheet, TouchableOpacity} from 'react-native'; +import { + PollItem, + PollStatus, + PollTaskRequestTypes, + usePoll, +} from '../context/poll-context'; +import { + ThemeConfig, + TertiaryButton, + useLocalUid, + $config, + LinkButton, + ImageIcon, +} from 'customization-api'; +import {BaseMoreButton} from '../ui/BaseMoreButton'; +import {PollCardMoreActions} from './PollCardMoreActions'; +import {capitalizeFirstLetter, getPollTypeDesc, hasUserVoted} from '../helpers'; +import { + PollFormSubmitButton, + PollRenderResponseFormBody, +} from './form/poll-response-forms'; +import {usePollPermissions} from '../hook/usePollPermissions'; +import {usePollForm} from '../hook/usePollForm'; + +const PollCardHeader = ({pollItem}: {pollItem: PollItem}) => { + const moreBtnRef = React.useRef(null); + const [actionMenuVisible, setActionMenuVisible] = + React.useState(false); + const {editPollForm, handlePollTaskRequest} = usePoll(); + const {canEdit} = usePollPermissions({pollItem}); + + return ( + + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + {pollItem.status === PollStatus.LATER && ( + <> + + Draft + + )} + + {canEdit && ( + + {pollItem.status === PollStatus.LATER && ( + { + editPollForm(pollItem.id); + }}> + + + Edit + + + )} + + { + handlePollTaskRequest(action, pollItem.id); + setActionMenuVisible(false); + }} + /> + + )} + + ); +}; + +const PollCardContent = ({pollItem}: {pollItem: PollItem}) => { + const {sendResponseToPoll} = usePoll(); + const {canViewPollDetails} = usePollPermissions({pollItem}); + const localUid = useLocalUid(); + const hasSubmittedResponse = hasUserVoted(pollItem.options, localUid); + + const onFormSubmit = (responses: string | string[]) => { + sendResponseToPoll(pollItem, responses); + }; + + const onFormSubmitComplete = () => { + // console.log('supriya'); + // Declaring this method just to have buttonVisible working + }; + + const { + onSubmit, + selectedOption, + handleRadioSelect, + selectedOptions, + handleCheckboxToggle, + answer, + setAnswer, + buttonText, + submitDisabled, + buttonStatus, + buttonVisible, + } = usePollForm({ + pollItem, + initialSubmitted: hasSubmittedResponse, + onFormSubmit, + onFormSubmitComplete, + }); + + return ( + + + {capitalizeFirstLetter(pollItem.question)} + + {pollItem.status === PollStatus.LATER ? ( + <> + ) : ( + <> + + {(hasSubmittedResponse && !buttonVisible) || + pollItem.status === PollStatus.FINISHED ? ( + <> + ) : ( + + + + )} + + )} + + ); +}; + +const PollCardFooter = ({pollItem}: {pollItem: PollItem}) => { + const {handlePollTaskRequest} = usePoll(); + const {canEnd, canViewPollDetails} = usePollPermissions({pollItem}); + + return ( + + {canEnd && pollItem.status === PollStatus.ACTIVE && ( + + { + handlePollTaskRequest( + PollTaskRequestTypes.FINISH_CONFIRMATION, + pollItem.id, + ); + }} + /> + + )} + {canViewPollDetails && ( + + + + handlePollTaskRequest( + PollTaskRequestTypes.VIEW_DETAILS, + pollItem.id, + ) + } + /> + + + )} + + ); +}; + +function PollCard({pollItem}: {pollItem: PollItem}) { + return ( + + + + + {pollItem.status !== PollStatus.LATER && ( + <> + + + )} + + + ); +} +export {PollCard}; + +const style = StyleSheet.create({ + fullWidth: { + alignSelf: 'stretch', + }, + pollItem: { + marginVertical: 12, + }, + pollCard: { + display: 'flex', + flexDirection: 'column', + gap: 8, + padding: 12, + borderRadius: 12, + borderWidth: 2, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_1_COLOR, + }, + pollCardHeader: { + height: 24, + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + pollCardHeaderText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 12, + }, + pollCardContent: { + display: 'flex', + flexDirection: 'column', + gap: 12, + alignSelf: 'stretch', + alignItems: 'flex-start', + }, + pollCardContentQuestionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 19, + }, + pollCardFooter: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + linkBtnContainer: { + height: 36, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + linkText: { + textAlign: 'center', + color: $config.PRIMARY_ACTION_BRAND_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + lineHeight: 16, + }, + space: { + marginHorizontal: 8, + }, + row: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + gap8: { + gap: 8, + }, + gap5: { + gap: 5, + }, + mr8: { + marginRight: 8, + }, + dot: { + width: 5, + height: 5, + borderRadius: 3, + backgroundColor: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + alignRight: { + alignSelf: 'flex-end', + }, +}); diff --git a/polling/components/PollCardMoreActions.tsx b/polling/components/PollCardMoreActions.tsx new file mode 100644 index 0000000..ec244eb --- /dev/null +++ b/polling/components/PollCardMoreActions.tsx @@ -0,0 +1,147 @@ +import React, {Dispatch, SetStateAction} from 'react'; +import {View, useWindowDimensions} from 'react-native'; +import { + ActionMenu, + ActionMenuItem, + calculatePosition, + ThemeConfig, + $config, +} from 'customization-api'; +import {PollStatus, PollTaskRequestTypes} from '../context/poll-context'; + +interface PollCardMoreActionsMenuProps { + status: PollStatus; + moreBtnRef: React.RefObject; + actionMenuVisible: boolean; + setActionMenuVisible: Dispatch>; + onCardActionSelect: (action: PollTaskRequestTypes) => void; +} +const PollCardMoreActions = (props: PollCardMoreActionsMenuProps) => { + const { + actionMenuVisible, + setActionMenuVisible, + moreBtnRef, + onCardActionSelect, + status, + } = props; + const actionMenuItems: ActionMenuItem[] = []; + const [modalPosition, setModalPosition] = React.useState({}); + const [isPosCalculated, setIsPosCalculated] = React.useState(false); + const {width: globalWidth, height: globalHeight} = useWindowDimensions(); + + status !== PollStatus.FINISHED && + actionMenuItems.push({ + icon: 'send', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Launch Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status !== PollStatus.LATER, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.SEND); + setActionMenuVisible(false); + }, + }); + + // status === PollStatus.ACTIVE && + // actionMenuItems.push({ + // icon: 'share', + // iconColor: $config.SECONDARY_ACTION_COLOR, + // textColor: $config.FONT_COLOR, + // title: 'Publish Result', + // titleStyle: { + // fontSize: ThemeConfig.FontSize.small, + // }, + // onPress: () => { + // onCardActionSelect(PollTaskRequestTypes.PUBLISH); + // setActionMenuVisible(false); + // }, + // }); + + status !== PollStatus.LATER && + actionMenuItems.push({ + icon: 'download', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'Export Resuts', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.EXPORT); + setActionMenuVisible(false); + }, + }); + + actionMenuItems.push({ + icon: 'close', + iconColor: $config.SECONDARY_ACTION_COLOR, + textColor: $config.FONT_COLOR, + title: 'End Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + disabled: status !== PollStatus.ACTIVE, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.FINISH_CONFIRMATION); + setActionMenuVisible(false); + }, + }); + + actionMenuItems.push({ + icon: 'delete', + iconColor: $config.SEMANTIC_ERROR, + textColor: $config.SEMANTIC_ERROR, + title: 'Delete Poll', + titleStyle: { + fontSize: ThemeConfig.FontSize.small, + }, + onPress: () => { + onCardActionSelect(PollTaskRequestTypes.DELETE_CONFIRMATION); + setActionMenuVisible(false); + }, + }); + + React.useEffect(() => { + if (actionMenuVisible && moreBtnRef.current) { + //getting btnRef x,y + moreBtnRef?.current?.measure( + ( + _fx: number, + _fy: number, + localWidth: number, + localHeight: number, + px: number, + py: number, + ) => { + const data = calculatePosition({ + px, + py, + localWidth, + localHeight, + globalHeight, + globalWidth, + }); + setModalPosition(data); + setIsPosCalculated(true); + }, + ); + } + }, [actionMenuVisible, globalWidth, globalHeight, moreBtnRef]); + + return ( + <> + + + ); +}; + +export {PollCardMoreActions}; diff --git a/polling/components/PollList.tsx b/polling/components/PollList.tsx new file mode 100644 index 0000000..5016f96 --- /dev/null +++ b/polling/components/PollList.tsx @@ -0,0 +1,94 @@ +import React, {useState, useEffect} from 'react'; +import {View} from 'react-native'; +import {PollCard} from './PollCard'; +import {PollItem, PollStatus, usePoll} from '../context/poll-context'; +import { + BaseAccordion, + BaseAccordionItem, + BaseAccordionHeader, + BaseAccordionContent, +} from '../ui/BaseAccordian'; +import {useLocalUid} from 'customization-api'; + +type PollsGrouped = Array<{key: string; poll: PollItem}>; + +export default function PollList() { + const {polls} = usePoll(); + const localUid = useLocalUid(); + + // State to keep track of the currently open accordion + const [openAccordion, setOpenAccordion] = useState(null); + + // Group polls by their status + const groupedPolls = Object.entries(polls).reduce( + (acc, [key, poll]) => { + // Check if the poll should be included in the LATER group based on creator + if (poll.status === PollStatus.LATER && poll.createdBy.uid !== localUid) { + return acc; // Skip this poll if it doesn't match the localUid + } + // Otherwise, add the poll to the corresponding group + acc[poll.status].push({key, poll}); + return acc; + }, + { + [PollStatus.LATER]: [] as PollsGrouped, + [PollStatus.ACTIVE]: [] as PollsGrouped, + [PollStatus.FINISHED]: [] as PollsGrouped, + }, + ); + + // Destructure grouped polls for easy access + const { + LATER: draftPolls, + ACTIVE: activePolls, + FINISHED: finishedPolls, + } = groupedPolls; + + // Set default open accordion based on priority: Active > Draft > Completed + useEffect(() => { + if (activePolls.length > 0) { + setOpenAccordion('active-accordion'); + } else if (draftPolls.length > 0) { + setOpenAccordion('draft-accordion'); + } else if (finishedPolls.length > 0) { + setOpenAccordion('finished-accordion'); + } + }, [activePolls, draftPolls, finishedPolls]); + + // Function to handle accordion toggling + const handleAccordionToggle = (id: string) => { + setOpenAccordion(prev => (prev === id ? null : id)); + }; + + // Render a section with its corresponding Accordion + const renderPollList = ( + pollsGrouped: PollsGrouped, + title: string, + id: string, + ) => { + return pollsGrouped.length ? ( + + + handleAccordionToggle(id)} + /> + + {pollsGrouped.map(({key, poll}) => ( + + ))} + + + + ) : null; + }; + + return ( + + {renderPollList(activePolls, 'Active', 'active-accordion')} + {renderPollList(draftPolls, 'Saved as Draft', 'draft-accordion')} + {renderPollList(finishedPolls, 'Completed', 'finished-accordion')} + + ); +} diff --git a/polling/components/PollSidebar.tsx b/polling/components/PollSidebar.tsx new file mode 100644 index 0000000..a55beed --- /dev/null +++ b/polling/components/PollSidebar.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import {View, StyleSheet, ScrollView, Text} from 'react-native'; +import { + PrimaryButton, + ThemeConfig, + $config, + ImageIcon, +} from 'customization-api'; +import {usePoll} from '../context/poll-context'; +import PollList from './PollList'; +import pollIcons from '../poll-icons'; +import {isWebOnly} from '../helpers'; +import {usePollPermissions} from '../hook/usePollPermissions'; + +const PollSidebar = () => { + const {startPollForm, isHost, polls} = usePoll(); + const {canCreate} = usePollPermissions({}); + + return ( + + {Object.keys(polls).length === 0 ? ( + + + {isHost && ( + + + + )} + + {isHost + ? isWebOnly + ? 'Visit our web platform to create and manage polls.' + : 'Create a new poll and boost interaction with your audience.' + : 'No polls here yet...'} + + + + ) : ( + + + + )} + {canCreate ? ( + + startPollForm()} + text="+ Create Poll" + /> + + ) : ( + <> + )} + + ); +}; + +const style = StyleSheet.create({ + pollSidebar: { + display: 'flex', + flex: 1, + }, + pollFooter: { + padding: 12, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + emptyCard: { + maxWidth: 220, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + emptyCardIcon: { + width: 72, + height: 72, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + emptyView: { + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 8, + }, + emptyText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 20, + textAlign: 'center', + }, + scrollViewContent: {}, + bodyXSmallText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.medium, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 16, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + paddingVertical: 10, + paddingHorizontal: 8, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, +}); + +export default PollSidebar; diff --git a/polling/components/PollTimer.tsx b/polling/components/PollTimer.tsx new file mode 100644 index 0000000..1475954 --- /dev/null +++ b/polling/components/PollTimer.tsx @@ -0,0 +1,54 @@ +import React, {useEffect, useState} from 'react'; +import {Text, View, StyleSheet} from 'react-native'; +import {useCountdown} from '../hook/useCountdownTimer'; +import {ThemeConfig, $config} from 'customization-api'; + +interface Props { + expiresAt: number; + setFreezeForm?: React.Dispatch>; +} + +const padZero = (value: number) => { + return value.toString().padStart(2, '0'); +}; + +export default function PollTimer({expiresAt}: Props) { + const [days, hours, minutes, seconds] = useCountdown(expiresAt); + const [freeze, setFreeze] = useState(false); + + const getTime = () => { + if (days) { + return `${padZero(days)} : ${padZero(hours)} : ${padZero( + minutes, + )} : ${padZero(seconds)}`; + } + if (hours) { + return `${padZero(hours)} : ${padZero(minutes)} : ${padZero(seconds)}`; + } + if (minutes || seconds) { + return `${padZero(minutes)} : ${padZero(seconds)}`; + } + return '00 : 00'; + }; + + useEffect(() => { + if (days + hours + minutes + seconds === 0) { + setFreeze(true); + } + }, [days, hours, minutes, seconds, freeze]); + + return ( + + {getTime()} + + ); +} + +export const style = StyleSheet.create({ + timer: { + color: $config.SEMANTIC_WARNING, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontSize: 16, + lineHeight: 20, + }, +}); diff --git a/polling/components/form/DraftPollFormView.tsx b/polling/components/form/DraftPollFormView.tsx new file mode 100644 index 0000000..a388d8b --- /dev/null +++ b/polling/components/form/DraftPollFormView.tsx @@ -0,0 +1,520 @@ +import { + Text, + View, + StyleSheet, + TextInput, + TouchableOpacity, +} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import { + IconButton, + PrimaryButton, + ThemeConfig, + $config, + TertiaryButton, + ImageIcon, + PlatformWrapper, +} from 'customization-api'; +import {PollFormErrors, PollItem, PollKind} from '../../context/poll-context'; +import {nanoid} from 'nanoid'; +import BaseButtonWithToggle from '../../ui/BaseButtonWithToggle'; + +function FormTitle({title}: {title: string}) { + return ( + + {title} + + ); +} +interface Props { + form: PollItem; + setForm: React.Dispatch>; + onPreview: () => void; + onSave: (launch?: boolean) => void; + errors: Partial; + onClose: () => void; +} + +// Define the form action types and reducer for state management +const formReducer = ( + state: PollItem, + action: {type: string; payload?: any}, +) => { + switch (action.type) { + case 'UPDATE_FIELD': + return {...state, [action.payload.field]: action.payload.value}; + case 'UPDATE_OPTION': + return { + ...state, + options: state.options?.map((option, index) => + index === action.payload.index + ? {...option, ...action.payload.option} + : option, + ), + }; + case 'ADD_OPTION': + return { + ...state, + options: [ + ...(state.options || []), + {text: '', value: '', votes: [], percent: '0'}, + ], + }; + case 'DELETE_OPTION': + return { + ...state, + options: + state.options?.filter((_, index) => index !== action.payload.index) || + [], + }; + default: + return state; + } +}; + +export default function DraftPollFormView({ + form, + setForm, + onPreview, + errors, + onClose, + onSave, +}: Props) { + const handleInputChange = (field: string, value: string | boolean) => { + setForm({ + ...form, + [field]: value, + }); + }; + + const handleCheckboxChange = (field: keyof PollItem, value: boolean) => { + if (field === 'anonymous' && value) { + setForm({ + ...form, + [field]: value, + share_attendee: false, + share_host: false, + }); + return; + } else if (field === 'share_attendee' || field === 'share_host') { + if (value) { + setForm({ + ...form, + [field]: value, + anonymous: false, + }); + return; + } + } + setForm({ + ...form, + [field]: value, + }); + }; + + const updateFormOption = ( + action: 'update' | 'delete' | 'add', + value: string, + index: number, + ) => { + if (action === 'add') { + setForm({ + ...form, + options: [ + ...(form.options || []), + { + text: '', + value: '', + votes: [], + percent: '0', + }, + ], + }); + } + if (action === 'update') { + setForm(prevForm => ({ + ...prevForm, + options: prevForm.options?.map((option, i) => { + if (i === index) { + const text = value; + const lowerText = text + .replace(/\s+/g, '-') + .toLowerCase() + .concat('-') + .concat(nanoid(2)); + return { + ...option, + text: text, + value: lowerText, + }; + } + return option; + }), + })); + } + if (action === 'delete') { + setForm({ + ...form, + options: form.options?.filter((option, i) => i !== index) || [], + }); + } + }; + + return ( + <> + + + + + + {/* Question section */} + + + + { + handleInputChange('question', text); + }} + placeholder="Enter your question here..." + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {errors?.question && ( + {errors.question.message} + )} + + + {/* MCQ section */} + {form.type === PollKind.MCQ ? ( + + + + + + { + handleCheckboxChange('multiple_response', value); + }} + /> + + + + + {form.options?.map((option, index) => ( + + + + + { + updateFormOption('update', text, index); + }} + placeholder={`Option ${index + 1}`} + placeholderTextColor={ + $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low + } + /> + {index > 1 ? ( + + { + updateFormOption('delete', '', index); + }} + /> + + ) : ( + <> + )} + + ))} + {form.options?.length < 5 ? ( + + {(isHovered: boolean) => ( + { + updateFormOption('add', '', -1); + }}> + + + Add option + + + )} + + ) : ( + <> + )} + {errors?.options && ( + {errors.options.message} + )} + + + ) : ( + <> + )} + {/* Yes / No section */} + {form.type === PollKind.YES_NO ? ( + + + + + + + + Yes + + + + + + No + + + + ) : ( + <> + )} + + + + + + { + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } + }} + /> + + + { + try { + onPreview(); + } catch (error) { + console.error('Error previewing form:', error); + } + }} + /> + + + + + ); +} + +export const style = StyleSheet.create({ + pForm: { + display: 'flex', + flexDirection: 'column', + gap: 24, + }, + pFormSection: { + gap: 8, + }, + pFormSettings: { + display: 'flex', + flexDirection: 'column', + gap: 4, + padding: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: $config.CARD_LAYER_2_COLOR, + }, + pFormTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '600', + }, + pFormTitleRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + pFormTextarea: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + height: 60, + outlineStyle: 'none', + padding: 20, + }, + pFormOptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '400', + }, + pFormOptionPrefix: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + paddingRight: 4, + }, + pFormOptionLink: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + height: 48, + paddingVertical: 12, + }, + pFormOptions: { + gap: 8, + }, + pFormInput: { + flex: 1, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '400', + outlineStyle: 'none', + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 8, + paddingVertical: 12, + height: 48, + }, + pFormSettingsText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + }, + pFormOptionCard: { + display: 'flex', + paddingHorizontal: 12, + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + gap: 4, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + borderRadius: 8, + borderWidth: 1, + }, + noBorder: { + borderColor: 'transparent', + }, + pFormToggle: { + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + gap: 12, + justifyContent: 'space-between', + position: 'relative', + }, + verticalPadding: { + paddingVertical: 12, + }, + pFormCheckboxContainer: {}, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 16, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + errorBorder: { + borderColor: $config.SEMANTIC_ERROR, + }, + hoverBorder: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + errorText: { + color: $config.SEMANTIC_ERROR, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + paddingTop: 8, + paddingLeft: 8, + }, + pushRight: { + marginLeft: 'auto', + }, +}); diff --git a/polling/components/form/PreviewPollFormView.tsx b/polling/components/form/PreviewPollFormView.tsx new file mode 100644 index 0000000..0aa7b30 --- /dev/null +++ b/polling/components/form/PreviewPollFormView.tsx @@ -0,0 +1,212 @@ +import {Text, StyleSheet, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {PollItem, PollKind} from '../../context/poll-context'; +import { + PrimaryButton, + TertiaryButton, + ThemeConfig, + $config, + ImageIcon, +} from 'customization-api'; + +interface Props { + form: PollItem; + onEdit: () => void; + onSave: (launch: boolean) => void; + onClose: () => void; +} + +export default function PreviewPollFormView({ + form, + onEdit, + onSave, + onClose, +}: Props) { + return ( + <> + + + + + + + + + Here is a preview of the poll you will be sending + + + + { + try { + onEdit(); + } catch (error) { + console.error('Error editing form:', error); + } + }}> + + + Edit + + + + + + + + {form.question} + + {form.type === PollKind.MCQ || form.type === PollKind.YES_NO ? ( + + {form.options?.map((option, index) => ( + + {option.text} + + ))} + + ) : ( + <> + )} + + + + + + + + { + try { + onSave(false); + } catch (error) { + console.error('Error saving form:', error); + } + }} + /> + + + { + try { + onSave(true); + } catch (error) { + console.error('Error launching form:', error); + } + }} + /> + + + + + ); +} + +export const style = StyleSheet.create({ + previewContainer: { + // width: 550, + }, + previewInfoContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: 20, + }, + previewInfoText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + editSection: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + editText: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '600', + }, + previewFormContainer: { + backgroundColor: $config.BACKGROUND_COLOR, + paddingVertical: 40, + paddingHorizontal: 60, + }, + previewFormCard: { + display: 'flex', + flexDirection: 'column', + padding: 20, + gap: 12, + borderRadius: 20, + borderWidth: 1, + borderColor: $config.CARD_LAYER_4_COLOR, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + previewQuestion: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + fontStyle: 'italic', + }, + previewOptionSection: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + previewOptionCard: { + display: 'flex', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 6, + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, + previewOptionText: { + color: $config.SECONDARY_ACTION_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 20, + fontStyle: 'italic', + }, + previewActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 16, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, +}); diff --git a/polling/components/form/SelectNewPollTypeFormView.tsx b/polling/components/form/SelectNewPollTypeFormView.tsx new file mode 100644 index 0000000..b00714d --- /dev/null +++ b/polling/components/form/SelectNewPollTypeFormView.tsx @@ -0,0 +1,179 @@ +import {Text, StyleSheet, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, +} from '../../ui/BaseModal'; +import {PollKind} from '../../context/poll-context'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, + PlatformWrapper, +} from 'customization-api'; +import {getPollTypeIcon} from '../../helpers'; + +interface newPollType { + key: PollKind; + image: null; + title: string; + description: string; +} + +const newPollTypeConfig: newPollType[] = [ + { + key: PollKind.YES_NO, + image: null, + title: 'Yes or No Question', + description: + 'A straightforward question that requires a simple Yes or No answer.', + }, + { + key: PollKind.MCQ, + image: null, + title: 'Multiple Choice Question', + description: + 'A question with several predefined answer options, allowing users to select one or more responses.', + }, + // { + // key: PollKind.OPEN_ENDED, + // image: 'question', + // title: 'Open Ended Question', + // description: + // 'A question that invites users to provide a detailed, free-form response, encouraging more in-depth feedback.', + // }, +]; + +export default function SelectNewPollTypeFormView({ + setType, + onClose, +}: { + setType: React.Dispatch>; + onClose: () => void; +}) { + return ( + <> + + + + + + + + What type of question would you like to ask? + + + + {newPollTypeConfig.map((item: newPollType) => ( + + {(isHovered: boolean) => { + return ( + { + setType(item.key); + }} + style={[style.card, isHovered ? style.cardHover : {}]}> + + + + + + {item.title} + + + {item.description} + + + + ); + }} + + ))} + + + + + ); +} + +export const style = StyleSheet.create({ + section: { + display: 'flex', + flexDirection: 'column', + gap: 20, + }, + sectionHeader: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + pollTypeList: { + display: 'flex', + flexDirection: 'column', + gap: 12, + }, + card: { + padding: 12, + flexDirection: 'row', + gap: 20, + outlineStyle: 'none', + alignItems: 'center', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + borderRadius: 8, + width: '100%', + }, + cardHover: { + backgroundColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['10%'], + }, + cardImage: { + width: 100, + height: 60, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 8, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + cardImageHover: { + backgroundColor: $config.CARD_LAYER_4_COLOR, + }, + cardContent: { + display: 'flex', + flexDirection: 'column', + gap: 4, + flexShrink: 1, + }, + cardContentTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '700', + }, + cardContentDesc: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + }, +}); diff --git a/polling/components/form/form-config.ts b/polling/components/form/form-config.ts new file mode 100644 index 0000000..b91d0c2 --- /dev/null +++ b/polling/components/form/form-config.ts @@ -0,0 +1,124 @@ +import {nanoid} from 'nanoid'; +import {PollKind, PollItem, PollStatus} from '../../context/poll-context'; + +const POLL_DURATION = 600; // takes seconds + +const getPollExpiresAtTime = (interval: number): number => { + const t = new Date(); + const expiresAT = t.setSeconds(t.getSeconds() + interval); + return expiresAT; +}; + +const initPollForm = ( + kind: PollKind, + user: {uid: number; name: string}, +): PollItem => { + if (kind === PollKind.OPEN_ENDED) { + return { + id: nanoid(4), + type: PollKind.OPEN_ENDED, + status: PollStatus.LATER, + question: '', + answers: null, + options: null, + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + if (kind === PollKind.MCQ) { + return { + id: nanoid(4), + type: PollKind.MCQ, + status: PollStatus.LATER, + question: '', + answers: null, + options: [ + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + { + text: '', + value: '', + votes: [], + percent: '0', + }, + ], + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + if (kind === PollKind.YES_NO) { + return { + id: nanoid(4), + type: PollKind.YES_NO, + status: PollStatus.LATER, + question: '', + answers: null, + options: [ + { + text: 'Yes', + value: 'yes', + votes: [], + percent: '0', + }, + { + text: 'No', + value: 'no', + votes: [], + percent: '0', + }, + ], + multiple_response: false, + share_attendee: true, + share_host: true, + anonymous: false, + duration: false, + expiresAt: 0, + createdAt: Date.now(), + createdBy: {...user}, + }; + } + // If none of the above conditions are met, throw an error or return a default value + throw new Error(`Unknown PollKind: ${kind}`); +}; + +const getAttributeLengthInKb = (attribute: string): string => { + const b = attribute.length * 2; + const kb = (b / 1024).toFixed(2); + return kb; +}; + +const isAttributeLengthValid = (attribute: string) => { + if (getAttributeLengthInKb(attribute) > '8') { + return false; + } + return true; +}; + +export { + getPollExpiresAtTime, + initPollForm, + isAttributeLengthValid, + POLL_DURATION, +}; diff --git a/polling/components/form/poll-response-forms.tsx b/polling/components/form/poll-response-forms.tsx new file mode 100644 index 0000000..8934f89 --- /dev/null +++ b/polling/components/form/poll-response-forms.tsx @@ -0,0 +1,390 @@ +import { + Text, + View, + StyleSheet, + TextInput, + TouchableWithoutFeedback, +} from 'react-native'; +import React, {useState} from 'react'; +import {PollKind, PollStatus} from '../../context/poll-context'; +import { + ImageIcon, + Checkbox, + PrimaryButton, + ThemeConfig, + $config, + useLocalUid, + PlatformWrapper, +} from 'customization-api'; +import BaseRadioButton from '../../ui/BaseRadioButton'; +import { + PollOptionList, + PollOptionInputListItem, + PollItemFill, +} from '../poll-option-item-ui'; +import {PollFormButton, PollFormInput} from '../../hook/usePollForm'; + +function PollResponseFormComplete() { + return ( + + + + + + Thank you for your response + + + ); +} + +function PollRenderResponseFormBody( + props: PollFormInput & { + submitted: boolean; + submitting: boolean; + }, +): JSX.Element { + // Directly use switch case logic inside the render + switch (props.pollItem.type) { + // case PollKind.OPEN_ENDED: + // return ( + // + // ); + case PollKind.MCQ: + case PollKind.YES_NO: + return ; + default: + console.error('Unknown poll type:', props.pollItem.type); + return Unknown poll type; + } +} + +function PollResponseQuestionForm() { + const [answer, setAnswer] = useState(''); + + return ( + + + + + + ); +} + +function PollResponseMCQForm({ + pollItem, + selectedOptions, + submitted, + handleCheckboxToggle, + selectedOption, + handleRadioSelect, + submitting, +}: Partial & { + submitted: boolean; + submitting: boolean; +}) { + const localUid = useLocalUid(); + return ( + + + {pollItem.multiple_response + ? pollItem.options?.map((option, index) => { + const myVote = option.votes.some(item => item.uid === localUid); + const checked = selectedOptions.includes(option?.value) || myVote; + submitted = submitted || pollItem.status === PollStatus.FINISHED; + return ( + + + + {(isHovered: boolean) => ( + + <> + + + handleCheckboxToggle(option?.value) + } + /> + + + )} + + + + ); + }) + : pollItem.options?.map((option, index) => { + const myVote = option.votes.some(item => item.uid === localUid); + const checked = selectedOption === option.value || myVote; + submitted = submitted || pollItem.status === PollStatus.FINISHED; + return ( + + + + {(isHovered: boolean) => ( + + <> + + + + + )} + + + + ); + })} + + + ); +} + +function PollFormSubmitButton({ + buttonText, + submitDisabled, + onSubmit, + buttonStatus, +}: Partial) { + // Define the styles based on button states + const getButtonColor = () => { + switch (buttonStatus) { + case 'initial': + return {backgroundColor: $config.SEMANTIC_NEUTRAL}; + case 'selected': + return {backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR}; + case 'submitting': + return { + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + opacity: 0.7, + }; + case 'submitted': + return {backgroundColor: $config.SEMANTIC_SUCCESS}; + default: + return {}; + } + }; + return ( + { + if (buttonStatus === 'submitted') { + return; + } else { + onSubmit(); + } + }} + text={buttonText} + /> + ); +} + +export { + PollResponseQuestionForm, + PollResponseMCQForm, + PollResponseFormComplete, + PollRenderResponseFormBody, + PollFormSubmitButton, +}; + +export const style = StyleSheet.create({ + optionsForm: { + display: 'flex', + flexDirection: 'column', + gap: 20, + width: '100%', + }, + thankyouText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + }, + pFormTextarea: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.INPUT_FIELD_BORDER_COLOR, + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + height: 110, + outlineStyle: 'none', + padding: 16, + }, + responseActions: { + flex: 1, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + submittedBtn: { + backgroundColor: $config.SEMANTIC_SUCCESS, + cursor: 'default', + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + optionListItem: { + display: 'flex', + flexDirection: 'row', + padding: 12, + alignItems: 'center', + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_3_COLOR, + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + }, + pFormInput: { + flex: 1, + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + outlineStyle: 'none', + backgroundColor: $config.INPUT_FIELD_BACKGROUND_COLOR, + borderRadius: 9, + paddingVertical: 12, + }, + centerAlign: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + mediumHeight: { + height: 272, + }, + checkboxContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: 12, + width: '100%', + }, + checkBox: { + borderColor: $config.FONT_COLOR, + }, + checkboxVoted: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + backgroundColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + checkboxSubmittedAndVoted: { + borderColor: $config.FONT_COLOR, + backgroundColor: $config.FONT_COLOR, + }, + checkboxSubmittedAndNotVoted: { + borderColor: $config.FONT_COLOR, + }, +}); diff --git a/polling/components/modals/PollEndConfirmModal.tsx b/polling/components/modals/PollEndConfirmModal.tsx new file mode 100644 index 0000000..c3c2914 --- /dev/null +++ b/polling/components/modals/PollEndConfirmModal.tsx @@ -0,0 +1,106 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, + BaseModalActions, +} from '../../ui/BaseModal'; +import { + ThemeConfig, + $config, + TertiaryButton, + PrimaryButton, +} from 'customization-api'; +import {PollTaskRequestTypes, usePoll} from '../../context/poll-context'; + +interface PollConfirmModalProps { + pollId: string; + actionType: 'end' | 'delete'; // Define the type of action (end or delete) +} + +export default function PollConfirmModal({ + pollId, + actionType, +}: PollConfirmModalProps) { + const {handlePollTaskRequest, closeCurrentModal} = usePoll(); + + const modalTitle = actionType === 'end' ? 'End Poll?' : 'Delete Poll?'; + const description = + actionType === 'end' + ? 'This will stop the poll for everyone in this call.' + : 'This will permanently delete the poll and its results. This action cannot be undone.'; + + const confirmButtonText = + actionType === 'end' ? 'End for all' : 'Delete Poll'; + + return ( + + + + + + + {description} + + + + + + + + { + if (actionType === 'delete') { + handlePollTaskRequest(PollTaskRequestTypes.DELETE, pollId); + } + if (actionType === 'end') { + handlePollTaskRequest(PollTaskRequestTypes.FINISH, pollId); + } + }} + /> + + + + ); +} + +export const style = StyleSheet.create({ + section: { + padding: 20, + paddingBottom: 60, + }, + descriptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + btnText: { + color: $config.PRIMARY_ACTION_TEXT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '600', + textTransform: 'capitalize', + }, + textCenter: { + textAlign: 'center', + }, +}); diff --git a/polling/components/modals/PollFormWizardModal.tsx b/polling/components/modals/PollFormWizardModal.tsx new file mode 100644 index 0000000..6691524 --- /dev/null +++ b/polling/components/modals/PollFormWizardModal.tsx @@ -0,0 +1,187 @@ +import React, {useEffect, useState, useRef} from 'react'; +import {BaseModal} from '../../ui/BaseModal'; +import SelectNewPollTypeFormView from '../form/SelectNewPollTypeFormView'; +import DraftPollFormView from '../form/DraftPollFormView'; +import PreviewPollFormView from '../form/PreviewPollFormView'; +import { + PollItem, + PollKind, + PollStatus, + PollFormErrors, +} from '../../context/poll-context'; +import {usePoll} from '../../context/poll-context'; +import {initPollForm} from '../form/form-config'; +import {useLocalUid, useContent} from 'customization-api'; +import {log} from '../../helpers'; + +type FormWizardStep = 'SELECT' | 'DRAFT' | 'PREVIEW'; + +interface PollFormWizardModalProps { + formObject?: PollItem; // Optional prop to initialize form in edit mode + formStep?: FormWizardStep; +} + +export default function PollFormWizardModal({ + formObject, + formStep, +}: PollFormWizardModalProps) { + const {savePoll, sendPoll, closeCurrentModal} = usePoll(); + const [savedPollId, setSavedPollId] = useState(null); + + const [step, setStep] = useState( + formObject ? 'DRAFT' : 'SELECT', + ); + const [type, setType] = useState( + formObject ? formObject.type : PollKind.NONE, + ); + const [form, setForm] = useState(formObject || null); + const [formErrors, setFormErrors] = useState>({}); + + const localUid = useLocalUid(); + const localUidRef = useRef(localUid); + const {defaultContent} = useContent(); + const defaultContentRef = useRef(defaultContent); + + // Monitor savedPollId to send poll when it's updated + useEffect(() => { + if (savedPollId) { + sendPoll(savedPollId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savedPollId]); + + useEffect(() => { + try { + if (formObject) { + // If formObject is passed, skip the SELECT step and initialize the form + setForm(formObject); + if (formStep) { + setStep(formStep); + } else { + setStep('DRAFT'); + } + } else if (type !== PollKind.NONE) { + // Initialize the form for a new poll based on the selected type + const user = { + uid: localUidRef.current, + name: defaultContentRef?.current[localUidRef.current]?.name || 'user', + }; + setForm(initPollForm(type, user)); + setStep('DRAFT'); + } + } catch (error) { + log('Error while initializing form: ', error); + } + }, [type, formObject, formStep]); + + const onSave = (launch?: boolean) => { + try { + if (!validateForm()) { + return; + } + const payload = { + ...form, + status: launch ? PollStatus.ACTIVE : PollStatus.LATER, + }; + savePoll(payload); + if (launch) { + setSavedPollId(payload.id); + } + } catch (error) { + log('error while saving form: ', error); + } + }; + + const onEdit = () => { + setStep('DRAFT'); + }; + + const onPreview = () => { + if (validateForm()) { + setStep('PREVIEW'); + } + }; + + const validateForm = () => { + // 1. Check if form is null + if (!form) { + return false; + } + // 2. Start with an empty errors object + let errors: Partial = {}; + + // 3. Validate the question field + if (form.question.trim() === '') { + errors = { + ...errors, + question: {message: 'This field cannot be empty.'}, + }; + } + + // 4. Validate the options for MCQ type poll + if ( + form.type === PollKind.MCQ && + form.options && + (form.options.length === 0 || + form.options.some(item => item.text.trim() === '')) + ) { + errors = { + ...errors, + options: {message: 'Option can’t be empty.'}, + }; + } + // 5. Set formErrors to the collected errors + setFormErrors(errors); + + // 6. If there are no errors, return true, otherwise return false + return Object.keys(errors).length === 0; + }; + + const onClose = () => { + setFormErrors({}); + setForm(null); + setType(PollKind.NONE); + closeCurrentModal(); + }; + + function renderSwitch() { + switch (step) { + case 'SELECT': + return ( + + ); + case 'DRAFT': + return ( + form && ( + + ) + ); + case 'PREVIEW': + return ( + form && ( + + ) + ); + default: + return <>; + } + } + + return ( + + {renderSwitch()} + + ); +} diff --git a/polling/components/modals/PollItemNotFound.tsx b/polling/components/modals/PollItemNotFound.tsx new file mode 100644 index 0000000..774a00e --- /dev/null +++ b/polling/components/modals/PollItemNotFound.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {Text} from 'react-native'; +import { + BaseModal, + BaseModalTitle, + BaseModalCloseIcon, + BaseModalContent, +} from '../../ui/BaseModal'; +import {usePoll} from '../../context/poll-context'; +import {$config} from 'customization-api'; + +export default function PollItemNotFound() { + const {closeCurrentModal} = usePoll(); + + return ( + + + + + + + This poll has been deleted by the host. Your response was not + submitted. + + + + ); +} diff --git a/polling/components/modals/PollResponseFormModal.tsx b/polling/components/modals/PollResponseFormModal.tsx new file mode 100644 index 0000000..02e0a24 --- /dev/null +++ b/polling/components/modals/PollResponseFormModal.tsx @@ -0,0 +1,191 @@ +import React, { useState } from "react"; +import { Text, View, StyleSheet } from "react-native"; +import { + BaseModal, + BaseModalActions, + BaseModalCloseIcon, + BaseModalContent, + BaseModalTitle, +} from "../../ui/BaseModal"; +import { + PollResponseFormComplete, + PollRenderResponseFormBody, + PollFormSubmitButton, +} from "../form/poll-response-forms"; +import { + PollStatus, + PollTaskRequestTypes, + usePoll, +} from "../../context/poll-context"; +import { getPollTypeDesc } from "../../helpers"; +import { + ThemeConfig, + $config, + TertiaryButton, + useSidePanel, +} from "customization-api"; +import { usePollForm } from "../../hook/usePollForm"; +import { POLL_SIDEBAR_NAME } from "../../../polling-ui"; + +export default function PollResponseFormModal({ pollId }: { pollId: string }) { + const { + polls, + sendResponseToPoll, + closeCurrentModal, + handlePollTaskRequest, + } = usePoll(); + const { setSidePanel } = useSidePanel(); + const [hasResponded, setHasResponded] = useState(false); + + const pollItem = polls[pollId]; + + const onFormSubmit = (responses: string | string[]) => { + sendResponseToPoll(pollItem, responses); + }; + + const onFormSubmitComplete = () => { + if (pollItem.share_attendee || pollItem.share_host) { + handlePollTaskRequest(PollTaskRequestTypes.VIEW_DETAILS, pollItem.id); + } else { + setHasResponded(true); + } + }; + + const { + onSubmit, + selectedOption, + handleRadioSelect, + selectedOptions, + handleCheckboxToggle, + answer, + setAnswer, + buttonText, + buttonStatus, + submitDisabled, + } = usePollForm({ + pollItem, + initialSubmitted: false, + onFormSubmit, + onFormSubmitComplete, + }); + + const onClose = () => { + if (!hasResponded) { + setSidePanel(POLL_SIDEBAR_NAME); + closeCurrentModal(); + } else { + closeCurrentModal(); + } + }; + + return ( + + + + + + {hasResponded ? ( + + ) : ( + <> + {pollItem.status === PollStatus.FINISHED && ( + + + This poll has ended. You can no longer submit the response + + + )} + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + {pollItem.question} + + + + )} + + + {hasResponded && ( + + + + )} + + + + + + ); +} +export const style = StyleSheet.create({ + header: { + display: "flex", + flexDirection: "column", + gap: 8, + }, + heading: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: "600", + }, + info: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: "600", + lineHeight: 12, + }, + warning: { + color: $config.SEMANTIC_ERROR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: "600", + lineHeight: 12, + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + submittedBtn: { + backgroundColor: $config.SEMANTIC_SUCCESS, + cursor: "default", + }, + btnText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: "600", + textTransform: "capitalize", + }, +}); diff --git a/polling/components/modals/PollResultModal.tsx b/polling/components/modals/PollResultModal.tsx new file mode 100644 index 0000000..863ce0a --- /dev/null +++ b/polling/components/modals/PollResultModal.tsx @@ -0,0 +1,374 @@ +import {Text, StyleSheet, View} from 'react-native'; +import React from 'react'; +import { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalCloseIcon, + BaseModalActions, +} from '../../ui/BaseModal'; +import { + ThemeConfig, + $config, + UserAvatar, + TertiaryButton, + useContent, + useLocalUid, + ImageIcon, + isMobileUA, +} from 'customization-api'; +import { + PollItemOptionItem, + PollTaskRequestTypes, + usePoll, +} from '../../context/poll-context'; +import { + formatTimestampToTime, + calculateTotalVotes, + getPollTypeDesc, + capitalizeFirstLetter, + getPollTypeIcon, +} from '../../helpers'; +import {usePollPermissions} from '../../hook/usePollPermissions'; + +export default function PollResultModal({pollId}: {pollId: string}) { + const {polls, closeCurrentModal, handlePollTaskRequest} = usePoll(); + const localUid = useLocalUid(); + const {defaultContent} = useContent(); + + const pollItem = polls[pollId]; + const {canViewWhoVoted} = usePollPermissions({pollItem}); + + return ( + + + + + + + + + + {capitalizeFirstLetter(pollItem.question)} + + + Total Responses {calculateTotalVotes(pollItem.options)} + + + + + Created{' '} + + {formatTimestampToTime(pollItem.createdAt)}{' '} + + by{' '} + + {localUid === pollItem.createdBy.uid + ? 'You' + : defaultContent[pollItem.createdBy.uid]?.name || + pollItem.createdBy?.name || + 'user'} + + + {!isMobileUA() && } + + + + + + + {getPollTypeDesc(pollItem.type, pollItem.multiple_response)} + + + + + + + {pollItem.options?.map((option: PollItemOptionItem, index) => ( + + + + + {`Option ${index + 1}`} + + + {option.text} + + + + + {option.percent}% + + + + {option.votes.length} votes + + + + {canViewWhoVoted && ( + + {option.votes.length > 0 ? ( + option.votes.map((item, i) => ( + + + + + {defaultContent[item.uid]?.name || + item?.name || + 'user'} + + + + + Voted {formatTimestampToTime(item.timestamp)} + + + + )) + ) : ( + + No votes here + + )} + + )} + + ))} + {!canViewWhoVoted && ( + + Individual responses are anonymous + + )} + + + + + + + + + { + handlePollTaskRequest(PollTaskRequestTypes.EXPORT, pollItem.id); + }} + /> + + + + ); +} + +export const style = StyleSheet.create({ + resultContainer: { + display: 'flex', + flexDirection: 'column', + gap: 16, + backgroundColor: $config.BACKGROUND_COLOR, + }, + resultInfoContainer: { + paddingVertical: 12, + paddingHorizontal: 32, + backgroundColor: $config.CARD_LAYER_1_COLOR, + display: 'flex', + flexDirection: 'column', + minHeight: 68, + }, + resultInfoContainerGapWeb: { + gap: 4, + }, + resultInfoContainerGapMobile: { + gap: 10, + }, + headerWeb: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + }, + headerMobile: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + gap: 4, + }, + subheaderWeb: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + subheaderMobile: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 4, + }, + percentText: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + rowCenter: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + resultSummaryContainer: { + paddingVertical: 16, + paddingHorizontal: 20, + gap: 16, + }, + summaryCard: { + display: 'flex', + flexDirection: 'column', + borderRadius: 8, + overflow: 'hidden', + borderWidth: 1, + borderColor: $config.CARD_LAYER_2_COLOR, + }, + summaryCardHeader: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: $config.CARD_LAYER_2_COLOR, + paddingHorizontal: 20, + paddingVertical: 12, + }, + summaryCardBody: { + paddingHorizontal: 20, + paddingVertical: 12, + backgroundColor: $config.CARD_LAYER_1_COLOR, + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + summaryItem: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + titleAvatar: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + titleAvatarContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + titleAvatarContainerText: { + fontSize: ThemeConfig.FontSize.tiny, + lineHeight: 12, + fontWeight: '600', + color: $config.BACKGROUND_COLOR, + }, + questionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.medium, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 24, + fontWeight: '600', + flexBasis: '75%', + }, + totalText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 20, + fontWeight: '400', + }, + descriptionText: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + fontSize: ThemeConfig.FontSize.small, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 16, + fontWeight: '400', + fontStyle: 'italic', + }, + bold: { + fontWeight: '600', + }, + youText: { + color: $config.VIDEO_AUDIO_TILE_AVATAR_COLOR, + }, + light: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + smallText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.tiny, + fontWeight: '400', + lineHeight: 16, + }, + username: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.small, + fontWeight: '400', + lineHeight: 20, + }, + alignRight: { + textAlign: 'right', + }, + btnContainer: { + minWidth: 150, + height: 36, + borderRadius: 4, + }, + dot: { + width: 5, + height: 5, + borderRadius: 3, + backgroundColor: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.low, + }, + textCenter: { + textAlign: 'center', + }, + imageIconBox: { + width: 15, + height: 15, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}); diff --git a/polling/components/poll-option-item-ui.tsx b/polling/components/poll-option-item-ui.tsx new file mode 100644 index 0000000..89db63c --- /dev/null +++ b/polling/components/poll-option-item-ui.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import {Text, View, StyleSheet, DimensionValue} from 'react-native'; +import {ThemeConfig, $config, hexadecimalTransparency} from 'customization-api'; + +function PollOptionList({children}: {children: React.ReactNode}) { + return {children}; +} + +interface Props { + submitting: boolean; + submittedMyVote: boolean; + percent: string; +} +function PollItemFill({submitting, submittedMyVote, percent}: Props) { + return ( + <> + + + {`${percent}%`} + + + ); +} + +interface PollOptionInputListItem { + index: number; + checked: boolean; + hovered: boolean; + children: React.ReactChild; +} + +function PollOptionInputListItem({ + index, + checked, + hovered, + children, +}: PollOptionInputListItem) { + return ( + + {children} + + ); +} + +const OPTION_LIST_ITEM_PADDING = 12; + +const style = StyleSheet.create({ + optionsList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + width: '100%', + }, + optionListItem: { + display: 'flex', + flexDirection: 'row', + padding: OPTION_LIST_ITEM_PADDING, + alignItems: 'center', + justifyContent: 'space-between', + borderRadius: 8, + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_1_COLOR, + overflow: 'hidden', + width: '100%', + position: 'relative', + }, + optionListItemInput: { + backgroundColor: $config.CARD_LAYER_3_COLOR, + padding: 0, + }, + optionListItemChecked: { + borderColor: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + optionListItemHovered: { + borderColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['25%'], + }, + optionFillBackground: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + }, + optionFillText: { + position: 'absolute', + top: OPTION_LIST_ITEM_PADDING, + bottom: 0, + right: OPTION_LIST_ITEM_PADDING, + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 24, + }, + myVote: { + color: $config.PRIMARY_ACTION_BRAND_COLOR, + }, + pushRight: { + marginLeft: 'auto', + }, +}); + +export {PollOptionList, PollOptionInputListItem, PollItemFill}; diff --git a/polling/context/poll-context.tsx b/polling/context/poll-context.tsx new file mode 100644 index 0000000..ce41794 --- /dev/null +++ b/polling/context/poll-context.tsx @@ -0,0 +1,853 @@ +import React, { + createContext, + useReducer, + useEffect, + useState, + useMemo, + useRef, + useCallback, +} from "react"; +import { usePollEvents } from "./poll-events"; +import { + useLocalUid, + useRoomInfo, + useSidePanel, + SidePanelType, + useContent, + isWeb, +} from "customization-api"; +import { + getPollExpiresAtTime, + POLL_DURATION, +} from "../components/form/form-config"; +import { + addVote, + arrayToCsv, + calculatePercentage, + debounce, + downloadCsv, + log, + mergePolls, +} from "../helpers"; +import { POLL_SIDEBAR_NAME } from "../../polling-ui"; + +enum PollStatus { + ACTIVE = "ACTIVE", + FINISHED = "FINISHED", + LATER = "LATER", +} + +enum PollKind { + OPEN_ENDED = "OPEN_ENDED", + MCQ = "MCQ", + YES_NO = "YES_NO", + NONE = "NONE", +} + +enum PollModalType { + NONE = "NONE", + DRAFT_POLL = "DRAFT_POLL", + PREVIEW_POLL = "PREVIEW_POLL", + RESPOND_TO_POLL = "RESPOND_TO_POLL", + VIEW_POLL_RESULTS = "VIEW_POLL_RESULTS", + END_POLL_CONFIRMATION = "END_POLL_CONFIRMATION", + DELETE_POLL_CONFIRMATION = "DELETE_POLL_CONFIRMATION", +} + +interface PollModalState { + modalType: PollModalType; + id: string; +} + +enum PollTaskRequestTypes { + SAVE = "SAVE", + SEND = "SEND", + PUBLISH = "PUBLISH", + EXPORT = "EXPORT", + VIEW_DETAILS = "VIEW_DETAILS", + FINISH = "FINISH", + FINISH_CONFIRMATION = "FINISH_CONFIRMATION", + DELETE = "DELETE", + DELETE_CONFIRMATION = "DELETE_CONFIRMATION", + SHARE = "SHARE", + SYNC_COMPLETE = "SYNC_COMPLETE", +} + +interface PollItemOptionItem { + text: string; + value: string; + votes: Array<{ uid: number; name: string; timestamp: number }>; + percent: string; +} +interface PollItem { + id: string; + type: PollKind; + status: PollStatus; + question: string; + answers: Array<{ + uid: number; + response: string; + timestamp: number; + }> | null; + options: Array | null; + multiple_response: boolean; + share_attendee: boolean; + share_host: boolean; + anonymous: boolean; + duration: boolean; + expiresAt: number; + createdBy: { uid: number; name: string }; + createdAt: number; +} + +type Poll = Record; + +interface PollFormErrors { + question?: { + message: string; + }; + options?: { + message: string; + }; +} + +enum PollActionKind { + SAVE_POLL_ITEM = "SAVE_POLL_ITEM", + ADD_POLL_ITEM = "ADD_POLL_ITEM", + SEND_POLL_ITEM = "SEND_POLL_ITEM", + SUBMIT_POLL_ITEM_RESPONSES = "SUBMIT_POLL_ITEM_RESPONSES", + RECEIVE_POLL_ITEM_RESPONSES = "RECEIVE_POLL_ITEM_RESPONSES", + PUBLISH_POLL_ITEM = "PUBLISH_POLL_ITEM", + DELETE_POLL_ITEM = "DELETE_POLL_ITEM", + EXPORT_POLL_ITEM = "EXPORT_POLL_ITEM", + FINISH_POLL_ITEM = "FINISH_POLL_ITEM", + RESET = "RESET", + SYNC_COMPLETE = "SYNC_COMPLETE", +} + +type PollAction = + | { + type: PollActionKind.ADD_POLL_ITEM; + payload: { + item: PollItem; + }; + } + | { + type: PollActionKind.SAVE_POLL_ITEM; + payload: { item: PollItem }; + } + | { + type: PollActionKind.SEND_POLL_ITEM; + payload: { pollId: string }; + } + | { + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES; + payload: { + id: string; + responses: string | string[]; + user: { name: string; uid: number }; + timestamp: number; + }; + } + | { + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES; + payload: { + id: string; + responses: string | string[]; + user: { name: string; uid: number }; + timestamp: number; + }; + } + | { + type: PollActionKind.PUBLISH_POLL_ITEM; + payload: { pollId: string }; + } + | { + type: PollActionKind.FINISH_POLL_ITEM; + payload: { pollId: string }; + } + | { + type: PollActionKind.EXPORT_POLL_ITEM; + payload: { pollId: string }; + } + | { + type: PollActionKind.DELETE_POLL_ITEM; + payload: { pollId: string }; + } + | { + type: PollActionKind.RESET; + payload: null; + } + | { + type: PollActionKind.SYNC_COMPLETE; + payload: { + latestTask: PollTaskRequestTypes; + latestPollId: string; + }; + }; + +function pollReducer(state: Poll, action: PollAction): Poll { + switch (action.type) { + case PollActionKind.SAVE_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: { ...action.payload.item }, + }; + } + case PollActionKind.ADD_POLL_ITEM: { + const pollId = action.payload.item.id; + return { + ...state, + [pollId]: { ...action.payload.item }, + }; + } + case PollActionKind.SEND_POLL_ITEM: { + const pollId = action.payload.pollId; + return { + ...state, + [pollId]: { + ...state[pollId], + status: PollStatus.ACTIVE, + expiresAt: getPollExpiresAtTime(POLL_DURATION), + }, + }; + } + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: { + const { id: pollId, user, responses, timestamp } = action.payload; + const poll = state[pollId]; + if (poll.type === PollKind.OPEN_ENDED && typeof responses === "string") { + return { + ...state, + [pollId]: { + ...poll, + answers: poll.answers + ? [ + ...poll.answers, + { + ...user, + response: responses, + timestamp, + }, + ] + : [{ ...user, response: responses, timestamp }], + }, + }; + } + if ( + (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && + Array.isArray(responses) + ) { + const newCopyOptions = poll.options?.map((item) => ({ ...item })) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + user, + timestamp + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + return state; + } + case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: { + const { id: pollId, user, responses, timestamp } = action.payload; + const poll = state[pollId]; + if (poll.type === PollKind.OPEN_ENDED && typeof responses === "string") { + return { + ...state, + [pollId]: { + ...poll, + answers: poll.answers + ? [ + ...poll.answers, + { + ...user, + response: responses, + timestamp, + }, + ] + : [{ ...user, response: responses, timestamp }], + }, + }; + } + if ( + (poll.type === PollKind.MCQ || poll.type === PollKind.YES_NO) && + Array.isArray(responses) + ) { + const newCopyOptions = poll.options?.map((item) => ({ ...item })) || []; + const withVotesOptions = addVote( + responses, + newCopyOptions, + user, + timestamp + ); + const withPercentOptions = calculatePercentage(withVotesOptions); + return { + ...state, + [pollId]: { + ...poll, + options: withPercentOptions, + }, + }; + } + return state; + } + case PollActionKind.PUBLISH_POLL_ITEM: + // No action need just return the state + return state; + case PollActionKind.FINISH_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + return { + ...state, + [pollId]: { ...state[pollId], status: PollStatus.FINISHED }, + }; + } + } + return state; + case PollActionKind.EXPORT_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId && state[pollId]) { + const data = state[pollId].options || []; // Provide a fallback in case options is null + let csv = arrayToCsv(state[pollId].question, data); + downloadCsv(csv, "polls.csv"); + } + } + return state; + case PollActionKind.DELETE_POLL_ITEM: + { + const pollId = action.payload.pollId; + if (pollId) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [pollId]: _, ...newItems } = state; + return { + ...newItems, + }; + } + } + return state; + case PollActionKind.RESET: { + return {}; + } + default: { + return state; + } + } +} + +interface PollContextValue { + polls: Poll; + startPollForm: () => void; + editPollForm: (pollId: string) => void; + savePoll: (item: PollItem) => void; + sendPoll: (pollId: string) => void; + onPollReceived: ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + isInitialized: boolean + ) => void; + sendResponseToPoll: (item: PollItem, responses: string | string[]) => void; + onPollResponseReceived: ( + pollId: string, + responses: string | string[], + user: { + uid: number; + name: string; + }, + timestamp: number + ) => void; + sendPollResults: (pollId: string) => void; + modalState: PollModalState; + closeCurrentModal: () => void; + isHost: boolean; + handlePollTaskRequest: (task: PollTaskRequestTypes, pollId: string) => void; +} + +const PollContext = createContext(null); +PollContext.displayName = "PollContext"; + +function PollProvider({ children }: { children: React.ReactNode }) { + const [polls, dispatch] = useReducer(pollReducer, {}); + const [modalState, setModalState] = useState({ + modalType: PollModalType.NONE, + id: null, + }); + const [lastAction, setLastAction] = useState(null); + const { setSidePanel } = useSidePanel(); + const { + data: { isHost }, + } = useRoomInfo(); + const localUid = useLocalUid(); + const { defaultContent } = useContent(); + const { syncPollEvt, sendResponseToPollEvt } = usePollEvents(); + + const callDebouncedSyncPoll = useMemo( + () => debounce(syncPollEvt, 800), + [syncPollEvt] + ); + + const pollsRef = useRef(polls); + + useEffect(() => { + pollsRef.current = polls; // Update the ref whenever polls changes + }, [polls]); + + useEffect(() => { + // Delete polls created by the user + const deleteMyPolls = () => { + Object.values(pollsRef.current).forEach((poll) => { + if (poll.createdBy.uid === localUid) { + enhancedDispatch({ + type: PollActionKind.DELETE_POLL_ITEM, + payload: { pollId: poll.id }, + }); + } + }); + }; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + deleteMyPolls(); + event.returnValue = ""; // Chrome requires returnValue to be set + }; + if (isWeb()) { + window.addEventListener("beforeunload", handleBeforeUnload); + } + + return () => { + if (isWeb()) { + window.removeEventListener("beforeunload", handleBeforeUnload); + } else { + deleteMyPolls(); + } + }; + }, [localUid]); + + const enhancedDispatch = (action: PollAction) => { + log(`Dispatching action: ${action.type} with payload:`, action.payload); + dispatch(action); + setLastAction(action); + }; + + const closeCurrentModal = useCallback(() => { + log("Closing current modal."); + setModalState({ + modalType: PollModalType.NONE, + id: null, + }); + }, []); + + useEffect(() => { + log("useEffect for lastAction triggered", lastAction); + + if (!lastAction) { + log("No lastAction to process. Exiting useEffect."); + return; + } + if (!pollsRef?.current) { + log("PollsRef.current is undefined or null"); + return; + } + + try { + switch (lastAction.type) { + case PollActionKind.SAVE_POLL_ITEM: + if (lastAction?.payload?.item?.status === PollStatus.LATER) { + log("Handling SAVE_POLL_ITEM saving poll item and syncing states"); + const { item } = lastAction.payload; + syncPollEvt(pollsRef.current, item.id, PollTaskRequestTypes.SAVE); + closeCurrentModal(); + } + break; + case PollActionKind.SEND_POLL_ITEM: + { + log("Handling SEND_POLL_ITEM"); + const { pollId } = lastAction.payload; + if (pollId && pollsRef.current[pollId]) { + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.SEND); + closeCurrentModal(); + } else { + log("Invalid pollId or poll not found in state:", pollId); + } + } + break; + case PollActionKind.SUBMIT_POLL_ITEM_RESPONSES: + log("Handling SUBMIT_POLL_ITEM_RESPONSES"); + const { id, responses, user, timestamp } = lastAction.payload; + if (localUid === pollsRef.current[id]?.createdBy.uid) { + log( + "No need to send event. User is the poll creator. We only sync data" + ); + syncPollEvt(pollsRef.current, id, PollTaskRequestTypes.SAVE); + return; + } + if (localUid && user?.uid && pollsRef.current[id]) { + sendResponseToPollEvt( + pollsRef.current[id], + responses, + user, + timestamp + ); + } else { + log("Missing uid, localUid, or poll data for submit response."); + } + break; + case PollActionKind.RECEIVE_POLL_ITEM_RESPONSES: + log("Handling RECEIVE_POLL_ITEM_RESPONSES"); + const { id: receivedPollId } = lastAction.payload; + const pollCreator = pollsRef.current[receivedPollId]?.createdBy.uid; + if (localUid === pollCreator) { + log("Received poll response, user is the creator. Syncing..."); + callDebouncedSyncPoll( + pollsRef.current, + receivedPollId, + PollTaskRequestTypes.SAVE + ); + } + break; + case PollActionKind.PUBLISH_POLL_ITEM: + log("Handling PUBLISH_POLL_ITEM"); + { + const { pollId } = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.PUBLISH); + } + break; + case PollActionKind.FINISH_POLL_ITEM: + log("Handling FINISH_POLL_ITEM"); + { + const { pollId } = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.FINISH); + closeCurrentModal(); + } + break; + case PollActionKind.DELETE_POLL_ITEM: + log("Handling DELETE_POLL_ITEM"); + { + const { pollId } = lastAction.payload; + syncPollEvt(pollsRef.current, pollId, PollTaskRequestTypes.DELETE); + closeCurrentModal(); + } + break; + case PollActionKind.SYNC_COMPLETE: + log("Handling SYNC_COMPLETE"); + const { latestTask, latestPollId } = lastAction.payload; + if ( + latestPollId && + latestTask && + pollsRef.current[latestPollId] && + latestTask === PollTaskRequestTypes.SEND + ) { + setSidePanel(SidePanelType.None); + setModalState({ + modalType: PollModalType.RESPOND_TO_POLL, + id: latestPollId, + }); + } + break; + default: + log(`Unhandled action type: ${lastAction.type}`); + break; + } + } catch (error) { + log("Error processing last action:", error); + } + }, [ + lastAction, + localUid, + setSidePanel, + syncPollEvt, + sendResponseToPollEvt, + callDebouncedSyncPoll, + closeCurrentModal, + ]); + + const startPollForm = () => { + log("Opening draft poll modal."); + setModalState({ + modalType: PollModalType.DRAFT_POLL, + id: null, + }); + }; + + const editPollForm = (pollId: string) => { + if (polls[pollId]) { + log(`Editing poll form for pollId: ${pollId}`); + setModalState({ + modalType: PollModalType.DRAFT_POLL, + id: pollId, + }); + } else { + log(`Poll not found for edit: ${pollId}`); + } + }; + + const savePoll = (item: PollItem) => { + log("Saving poll item:", item); + enhancedDispatch({ + type: PollActionKind.SAVE_POLL_ITEM, + payload: { item: { ...item } }, + }); + }; + + const addPoll = (item: PollItem) => { + log("Adding poll item:", item); + enhancedDispatch({ + type: PollActionKind.ADD_POLL_ITEM, + payload: { item: { ...item } }, + }); + }; + + const sendPoll = (pollId: string) => { + if (!pollId || !polls[pollId]) { + log("Invalid pollId or poll not found for sending:", pollId); + return; + } + log(`Sending poll with id: ${pollId}`); + enhancedDispatch({ + type: PollActionKind.SEND_POLL_ITEM, + payload: { pollId }, + }); + }; + + const onPollReceived = ( + newPoll: Poll, + pollId: string, + task: PollTaskRequestTypes, + initialLoad: boolean + ) => { + log("onPollReceived", newPoll, pollId, task); + + if (!newPoll || !pollId) { + log("Invalid newPoll or pollId in onPollReceived:", { newPoll, pollId }); + return; + } + const { mergedPolls, deletedPollIds } = mergePolls(newPoll, polls); + + log("Merged polls:", mergedPolls); + log("Deleted poll IDs:", deletedPollIds); + + if (Object.keys(mergedPolls).length === 0) { + log("No polls left after merge. Resetting state."); + enhancedDispatch({ type: PollActionKind.RESET, payload: null }); + return; + } + + if (localUid === newPoll[pollId]?.createdBy.uid) { + log("I am the creator, no further action needed."); + return; + } + + deletedPollIds?.forEach((id: string) => { + log(`Deleting poll ID: ${id}`); + handlePollTaskRequest(PollTaskRequestTypes.DELETE, id); + }); + + log("Updating state with merged polls."); + Object.values(mergedPolls) + .filter((pollItem) => pollItem.status !== PollStatus.LATER) + .forEach((pollItem) => { + log(`Adding poll ID ${pollItem.id} with status ${pollItem.status}`); + addPoll(pollItem); + }); + + log("Is it an initial load ?:", initialLoad); + if (!initialLoad) { + enhancedDispatch({ + type: PollActionKind.SYNC_COMPLETE, + payload: { + latestTask: task, + latestPollId: pollId, + }, + }); + } else { + if (Object.keys(mergedPolls).length > 0) { + // Check if there is an active poll + log("It is an initial load."); + const activePoll = Object.values(mergedPolls).find( + (pollItem) => pollItem.status === PollStatus.ACTIVE + ); + if (activePoll) { + log("It is an initial load. There is an active poll"); + setSidePanel(POLL_SIDEBAR_NAME); + } else { + log("It is an initial load. There are no active poll"); + } + } + } + }; + + const sendResponseToPoll = (item: PollItem, responses: string | string[]) => { + log("Sending response to poll:", item, responses); + if ( + (item.type === PollKind.OPEN_ENDED && typeof responses === "string") || + (item.type !== PollKind.OPEN_ENDED && Array.isArray(responses)) + ) { + enhancedDispatch({ + type: PollActionKind.SUBMIT_POLL_ITEM_RESPONSES, + payload: { + id: item.id, + responses, + user: { + uid: localUid, + name: defaultContent[localUid]?.name || "user", + }, + timestamp: Date.now(), + }, + }); + } else { + throw new Error( + "sendResponseToPoll received incorrect type response. Unable to send poll response" + ); + } + }; + + const onPollResponseReceived = ( + pollId: string, + responses: string | string[], + user: { + uid: number; + name: string; + }, + timestamp: number + ) => { + log("Received poll response:", { pollId, responses, user, timestamp }); + enhancedDispatch({ + type: PollActionKind.RECEIVE_POLL_ITEM_RESPONSES, + payload: { + id: pollId, + responses, + user, + timestamp, + }, + }); + }; + + const sendPollResults = (pollId: string) => { + log(`Sending poll results for pollId: ${pollId}`); + syncPollEvt(polls, pollId, PollTaskRequestTypes.SHARE); + }; + + const handlePollTaskRequest = ( + task: PollTaskRequestTypes, + pollId: string + ) => { + if (!pollId || !polls[pollId]) { + log( + "handlePollTaskRequest: Invalid pollId or poll not found for handling", + pollId + ); + return; + } + if (!(task in PollTaskRequestTypes)) { + log("handlePollTaskRequest: Invalid valid task", task); + return; + } + log(`Handling poll task request: ${task} for pollId: ${pollId}`); + switch (task) { + case PollTaskRequestTypes.SEND: + if (polls[pollId].status === PollStatus.LATER) { + setModalState({ + modalType: PollModalType.PREVIEW_POLL, + id: pollId, + }); + } else { + sendPoll(pollId); + } + break; + case PollTaskRequestTypes.SHARE: + break; + case PollTaskRequestTypes.VIEW_DETAILS: + setModalState({ + modalType: PollModalType.VIEW_POLL_RESULTS, + id: pollId, + }); + break; + case PollTaskRequestTypes.PUBLISH: + enhancedDispatch({ + type: PollActionKind.PUBLISH_POLL_ITEM, + payload: { pollId }, + }); + break; + case PollTaskRequestTypes.DELETE_CONFIRMATION: + setModalState({ + modalType: PollModalType.DELETE_POLL_CONFIRMATION, + id: pollId, + }); + break; + case PollTaskRequestTypes.DELETE: + enhancedDispatch({ + type: PollActionKind.DELETE_POLL_ITEM, + payload: { pollId }, + }); + break; + case PollTaskRequestTypes.FINISH_CONFIRMATION: + setModalState({ + modalType: PollModalType.END_POLL_CONFIRMATION, + id: pollId, + }); + break; + case PollTaskRequestTypes.FINISH: + enhancedDispatch({ + type: PollActionKind.FINISH_POLL_ITEM, + payload: { pollId }, + }); + break; + case PollTaskRequestTypes.EXPORT: + enhancedDispatch({ + type: PollActionKind.EXPORT_POLL_ITEM, + payload: { pollId }, + }); + break; + default: + log(`Unhandled task type: ${task}`); + break; + } + }; + + const value = { + polls, + startPollForm, + editPollForm, + sendPoll, + savePoll, + onPollReceived, + onPollResponseReceived, + sendResponseToPoll, + sendPollResults, + handlePollTaskRequest, + modalState, + closeCurrentModal, + isHost, + }; + + return {children}; +} + +function usePoll() { + const context = React.useContext(PollContext); + if (!context) { + throw new Error("usePoll must be used within a PollProvider"); + } + return context; +} + +export { + PollProvider, + usePoll, + PollActionKind, + PollKind, + PollStatus, + PollModalType, + PollTaskRequestTypes, +}; + +export type { Poll, PollItem, PollFormErrors, PollItemOptionItem }; diff --git a/polling/context/poll-events.tsx b/polling/context/poll-events.tsx new file mode 100644 index 0000000..0895927 --- /dev/null +++ b/polling/context/poll-events.tsx @@ -0,0 +1,224 @@ +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + useMemo, + useRef, +} from 'react'; +import {Poll, PollItem, PollTaskRequestTypes, usePoll} from './poll-context'; +import {customEvents as events, PersistanceLevel} from 'customization-api'; +import {log} from '../helpers'; + +enum PollEventNames { + polls = 'POLLS', + pollResponse = 'POLL_RESPONSE', +} + +type sendResponseToPollEvtFunction = ( + item: PollItem, + responses: string | string[], + user: {name: string; uid: number}, + timestamp: number, +) => void; + +interface PollEventsContextValue { + syncPollEvt: ( + polls: Poll, + pollId: string, + task: PollTaskRequestTypes, + ) => void; + sendResponseToPollEvt: sendResponseToPollEvtFunction; +} + +const PollEventsContext = createContext(null); +PollEventsContext.displayName = 'PollEventsContext'; + +// Event Dispatcher +function PollEventsProvider({children}: {children?: React.ReactNode}) { + // Sync poll event handler + const syncPollEvt = useCallback( + (polls: Poll, pollId: string, task: PollTaskRequestTypes) => { + log('syncPollEvt called', {polls, pollId, task}); + try { + if (!polls || !pollId || !task) { + throw new Error('Invalid arguments provided to syncPollEvt.'); + } + events.send( + PollEventNames.polls, + JSON.stringify({ + state: {...polls}, + pollId: pollId, + task, + }), + PersistanceLevel.Channel, + ); + log('Poll sync successful', {pollId, task}); + } catch (error) { + console.error('Error while syncing poll: ', error); + } + }, + [], + ); + + // Send response to poll handler + const sendResponseToPollEvt: sendResponseToPollEvtFunction = useCallback( + (item, responses, user, timestamp) => { + log('sendResponseToPollEvt called', {item, responses, user, timestamp}); + try { + if (!item || !item.id || !responses || !user.uid) { + throw new Error( + 'Invalid arguments provided to sendResponseToPollEvt.', + ); + } + if (!item?.createdBy?.uid) { + throw new Error( + 'Poll createdBy is null, cannot send response to creator', + ); + } + events.send( + PollEventNames.pollResponse, + JSON.stringify({ + id: item.id, + responses, + user, + timestamp, + }), + PersistanceLevel.None, + item.createdBy.uid, + ); + log('Poll response sent successfully', {pollId: item.id}); + } catch (error) { + console.error('Error while sending a poll response: ', error); + } + }, + [], + ); + + const value = useMemo( + () => ({ + syncPollEvt, + sendResponseToPollEvt, + }), + [syncPollEvt, sendResponseToPollEvt], + ); + + return ( + + {children} + + ); +} + +function usePollEvents() { + const context = useContext(PollEventsContext); + if (!context) { + throw new Error('usePollEvents must be used within PollEventsProvider.'); + } + return context; +} + +// Event Subscriber +const PollEventsSubscriberContext = createContext(null); +PollEventsSubscriberContext.displayName = 'PollEventsContext'; + +function PollEventsSubscriber({children}: {children?: React.ReactNode}) { + const {onPollReceived, onPollResponseReceived} = usePoll(); + // State variable to track whether the initial load has occurred + // State variable to track whether the initial load has occurred + const [initialized, setInitialized] = useState(false); + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + + // Use refs to hold the stable references of callbacks + const onPollReceivedRef = useRef(onPollReceived); + const onPollResponseReceivedRef = useRef(onPollResponseReceived); + + // Keep refs updated with latest callbacks + useEffect(() => { + onPollReceivedRef.current = onPollReceived; + onPollResponseReceivedRef.current = onPollResponseReceived; + }, [onPollReceived, onPollResponseReceived]); + + useEffect(() => { + if (!onPollReceivedRef.current || !onPollResponseReceivedRef.current) { + log('PollEventsSubscriber ref not intialized.'); + return; + } + log('PollEventsSubscriber useEffect triggered.'); + log('PollEventsSubscriber is app initialized ?', initialized); + let initialLoadTimeout: ReturnType; + // Set initialLoadTimeout only if initialLoadComplete is false + if (!initialLoadComplete) { + log('Setting initial load timeout.'); + initialLoadTimeout = setTimeout(() => { + log('Initial load timeout reached. Marking initial load as complete.'); + setInitialLoadComplete(true); + }, 3000); // Adjust the timeout duration as necessary + } + + events.on(PollEventNames.polls, args => { + try { + log('PollEventNames.polls event received', args); + const {payload} = args; + const data = JSON.parse(payload); + const {state, pollId, task} = data; + log('Poll data received and parsed successfully:', data); + // Determine if it's the initial load or a runtime update + if (!initialized && !initialLoadComplete) { + log('Initial load detected.'); + // Call onPollReceived with an additional parameter or flag for initial load + onPollReceivedRef.current(state, pollId, task, true); // true indicates it's an initial load + setInitialized(true); + // Clear the initial load timeout since we have received the initial state + clearTimeout(initialLoadTimeout); + } else { + log('Runtime update detected'); + onPollReceivedRef.current(state, pollId, task, false); // false indicates it's a runtime update + } + // switch (action) { + // case PollEventActions.savePoll: + // log('on poll saved'); + // onPollReceived(state, pollId, task); + // break; + // case PollEventActions.sendPoll: + // log('on poll received'); + // onPollReceived(state, pollId, task); + // break; + // default: + // break; + // } + } catch (error) { + log('Error handling poll event:', error); + } + }); + events.on(PollEventNames.pollResponse, args => { + try { + log('PollEventNames.pollResponse event received', args); + const {payload} = args; + const data = JSON.parse(payload); + log('poll response received', data); + const {id, responses, user, timestamp} = data; + log('Poll response data parsed successfully:', data); + onPollResponseReceivedRef.current(id, responses, user, timestamp); + } catch (error) { + log('Error handling poll response event:', error); + } + }); + + return () => { + log('Cleaning up PollEventsSubscriber event listeners.'); + events.off(PollEventNames.polls); + events.off(PollEventNames.pollResponse); + clearTimeout(initialLoadTimeout); + }; + }, [initialized, initialLoadComplete]); + + return ( + + {children} + + ); +} + +export {usePollEvents, PollEventsProvider, PollEventsSubscriber}; diff --git a/polling/helpers.ts b/polling/helpers.ts new file mode 100644 index 0000000..2bab750 --- /dev/null +++ b/polling/helpers.ts @@ -0,0 +1,219 @@ +import { isMobileUA, isWeb } from "customization-api"; +import { Poll, PollItemOptionItem, PollKind } from "./context/poll-context"; +import pollIcons from "./poll-icons"; + +function log(...args: any[]) { + console.log("[Custom-Polling::]", ...args); +} + +function addVote( + responses: string[], + options: PollItemOptionItem[], + user: { name: string; uid: number }, + timestamp: number +): PollItemOptionItem[] { + return options.map((option: PollItemOptionItem) => { + // Count how many times the value appears in the strings array + const exists = responses.includes(option.value); + const isVoted = option.votes.find((item) => item.uid === user.uid); + if (exists && !isVoted) { + // Creating a new object explicitly + const newOption: PollItemOptionItem = { + ...option, + ...option, + votes: [ + ...option.votes, + { + ...user, + timestamp, + }, + ], + }; + return newOption; + } + // If no matches, return the option as is + return option; + }); +} + +function calculatePercentage( + options: PollItemOptionItem[] +): PollItemOptionItem[] { + const totalVotes = options.reduce( + (total, item) => total + item.votes.length, + 0 + ); + if (totalVotes === 0) { + // As none of the users have voted, there is no need to calulate the percentage, + // we can return the options as it is + return options; + } + return options.map((option: PollItemOptionItem) => { + let percentage = 0; + if (option.votes.length > 0) { + percentage = (option.votes.length / totalVotes) * 100; + } + // Creating a new object explicitly + const newOption: PollItemOptionItem = { + ...option, + percent: percentage.toFixed(0), + }; + return newOption; + }) as PollItemOptionItem[]; +} + +function arrayToCsv(question: string, data: PollItemOptionItem[]): string { + const headers = ["Option", "Votes", "Percent"]; // Define the headers + const rows = data.map((item) => { + const count = item.votes.length; + // Handle missing or undefined value + const voteText = item.text ? `"${item.text}"` : '""'; + const votesCount = count !== undefined ? count : "0"; + const votePercent = item.percent !== undefined ? `${item.percent}%` : "0%"; + + return `${voteText},${votesCount},${votePercent}`; + }); + // Include poll question at the top + const pollQuestion = `Poll Question: "${question}"`; + return [pollQuestion, "", headers.join(","), ...rows].join("\n"); +} + +function downloadCsv(data: string, filename: string = "data.csv"): void { + const blob = new Blob([data], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + + link.setAttribute("href", url); + link.setAttribute("download", filename); + link.setAttribute("target", "_blank"); + link.style.visibility = "hidden"; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +function capitalizeFirstLetter(sentence: string): string { + return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase(); +} + +function hasUserVoted(options: PollItemOptionItem[], uid: number): boolean { + // Loop through each option and check the votes array + return options.some((option) => + option.votes.some((vote) => vote.uid === uid) + ); +} + +type MergePollsResult = { + mergedPolls: Poll; + deletedPollIds: string[]; +}; + +function mergePolls(newPoll: Poll, oldPoll: Poll): MergePollsResult { + // Merge and discard absent properties + + // 1. Start with a copy of the current polls state + const mergedPolls: Poll = { ...oldPoll }; + + // 2. Array to track deleted poll IDs + const deletedPollIds: string[] = []; + + // 3. Add or update polls from newPolls + Object.keys(newPoll).forEach((pollId) => { + mergedPolls[pollId] = newPoll[pollId]; // Add or update each poll from newPolls + }); + + // 4. Remove polls that are not in newPolls and track deleted poll IDs + Object.keys(oldPoll).forEach((pollId) => { + if (!(pollId in newPoll)) { + delete mergedPolls[pollId]; // Delete polls that are no longer present in newPolls + deletedPollIds.push(pollId); // Track deleted poll ID + } + }); + + // 5. Return the merged polls and deleted poll IDs + return { mergedPolls, deletedPollIds }; +} + +function getPollTypeIcon(type: PollKind): string { + if (type === PollKind.OPEN_ENDED) { + return pollIcons.question; + } + if (type === PollKind.YES_NO) { + return pollIcons["like-dislike"]; + } + if (type === PollKind.MCQ) { + return pollIcons.mcq; + } + return pollIcons.question; +} + +function getPollTypeDesc(type: PollKind, multiple_response?: boolean): string { + if (type === PollKind.OPEN_ENDED) { + return "Open Ended"; + } + if (type === PollKind.YES_NO) { + return "Select Any One"; + } + if (type === PollKind.MCQ) { + if (multiple_response) { + return "MCQ - Select One or More"; + } + return "MCQ - Select Any One"; + } + return "None"; +} + +function formatTimestampToTime(timestamp: number): string { + // Create a new Date object using the timestamp + const date = new Date(timestamp); + // Get hours and minutes from the Date object + let hours = date.getHours(); + const minutes = date.getMinutes(); + // Determine if it's AM or PM + const ampm = hours >= 12 ? "PM" : "AM"; + // Convert hours to 12-hour format + hours = hours % 12; + hours = hours ? hours : 12; // The hour '0' should be '12' + // Format minutes to always have two digits + const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes; + // Construct the formatted time string + return `${hours}:${formattedMinutes} ${ampm}`; +} + +function calculateTotalVotes(options: Array): number { + // Use reduce to sum up the length of the votes array for each option + return options.reduce((total, option) => total + option.votes.length, 0); +} + +const debounce = void>( + func: T, + delay: number = 300 +) => { + let debounceTimer: ReturnType; + return function (this: ThisParameterType, ...args: Parameters) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +}; + +const isWebOnly = () => isWeb() && !isMobileUA(); + +export { + log, + mergePolls, + hasUserVoted, + downloadCsv, + arrayToCsv, + addVote, + calculatePercentage, + capitalizeFirstLetter, + getPollTypeDesc, + formatTimestampToTime, + calculateTotalVotes, + debounce, + getPollTypeIcon, + isWebOnly, +}; diff --git a/polling/hook/useButtonState.tsx b/polling/hook/useButtonState.tsx new file mode 100644 index 0000000..8ca5914 --- /dev/null +++ b/polling/hook/useButtonState.tsx @@ -0,0 +1,68 @@ +// useButtonState.ts +import {useState, useCallback, useRef, useEffect} from 'react'; + +interface useButtonStateReturn { + buttonText: string; + isSubmitting: boolean; + submitted: boolean; + handleSubmit: (submitFunction?: () => Promise | void) => void; + resetState: () => void; +} + +export function useButtonState( + initialText: string = 'Submit', + submittingText: string = 'Submitting...', + submittedText: string = 'Submitted', +): useButtonStateReturn { + const [buttonText, setButtonText] = useState(initialText); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setIsSubmitted] = useState(false); + const timeoutRef = useRef(null); // Reference to store timeout ID + + // Handles the submission process + const handleSubmit = useCallback( + async (submitFunction?: () => Promise | void) => { + setIsSubmitting(true); + setButtonText(submittingText); + + try { + // Execute the submit function if provided + if (submitFunction) { + await submitFunction(); + } + + // After submission, update the text to "Submitted" with delay + timeoutRef.current = setTimeout(() => { + setIsSubmitted(true); + setButtonText(submittedText); + }, 1000); + } catch (error) { + // Handle error (e.g., reset button text or show error) + setButtonText(initialText); + } finally { + // Restore the submit state after completion + timeoutRef.current = setTimeout(() => { + setIsSubmitting(false); + }, 1000); + } + }, + [initialText, submittingText, submittedText], + ); + + // Cleanup function to clear timeouts if the component unmounts + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Reset the state to the initial values + const resetState = () => { + setButtonText(initialText); + setIsSubmitting(false); + }; + + return {buttonText, isSubmitting, submitted, handleSubmit, resetState}; +} diff --git a/polling/hook/useCountdownTimer.tsx b/polling/hook/useCountdownTimer.tsx new file mode 100644 index 0000000..238f3ad --- /dev/null +++ b/polling/hook/useCountdownTimer.tsx @@ -0,0 +1,45 @@ +import {useEffect, useState, useRef} from 'react'; + +const useCountdown = (targetDate: number) => { + const countDownDate = new Date(targetDate).getTime(); + const intervalRef = useRef(null); // Add a ref to store the interval id + + const [countDown, setCountDown] = useState( + countDownDate - new Date().getTime(), + ); + + useEffect(() => { + intervalRef.current = setInterval(() => { + setCountDown(_ => { + const newCountDown = countDownDate - new Date().getTime(); + if (newCountDown <= 0) { + clearInterval(intervalRef.current!); + return 0; + } + return newCountDown; + }); + }, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [countDownDate]); + + return getReturnValues(countDown); +}; + +const getReturnValues = countDown => { + // calculate time left + const days = Math.floor(countDown / (1000 * 60 * 60 * 24)); + const hours = Math.floor( + (countDown % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60), + ); + const minutes = Math.floor((countDown % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((countDown % (1000 * 60)) / 1000); + + return [days, hours, minutes, seconds]; +}; + +export {useCountdown}; diff --git a/polling/hook/usePollForm.tsx b/polling/hook/usePollForm.tsx new file mode 100644 index 0000000..b794fa3 --- /dev/null +++ b/polling/hook/usePollForm.tsx @@ -0,0 +1,162 @@ +import {useState, useEffect, useRef, useCallback, SetStateAction} from 'react'; +import {PollItem, PollKind} from '../context/poll-context'; +// import {useLocalUid} from 'customization-api'; + +interface UsePollFormProps { + pollItem: PollItem; + initialSubmitted?: boolean; + onFormSubmit: (responses: string | string[]) => void; + onFormSubmitComplete?: () => void; +} + +interface PollFormInput { + pollItem: PollItem; + selectedOption: string | null; + selectedOptions: string[]; + handleRadioSelect: (option: string) => void; + handleCheckboxToggle: (option: string) => void; + answer: string; + setAnswer: React.Dispatch>; +} +interface PollFormButton { + onSubmit: () => void; + buttonVisible: boolean; + buttonStatus: ButtonStatus; + buttonText: string; + submitDisabled: boolean; +} +interface UsePollFormReturn + extends Omit, + PollFormButton {} + +type ButtonStatus = 'initial' | 'selected' | 'submitting' | 'submitted'; + +export function usePollForm({ + pollItem, + initialSubmitted = false, + onFormSubmit, + onFormSubmitComplete, +}: UsePollFormProps): UsePollFormReturn { + const [selectedOption, setSelectedOption] = useState(null); + const [selectedOptions, setSelectedOptions] = useState([]); + const [buttonVisible, setButtonVisible] = useState(!initialSubmitted); + + const [answer, setAnswer] = useState(''); + const [buttonStatus, setButtonStatus] = useState( + initialSubmitted ? 'submitted' : 'initial', + ); + + const timeoutRef = useRef(null); + // const localUid = useLocalUid(); + + // Set state for radio button selection + const handleRadioSelect = useCallback((option: string) => { + setSelectedOption(option); + setButtonStatus('selected'); // Mark the button state as selected + }, []); + + // Set state for checkbox toggle + const handleCheckboxToggle = useCallback((value: string) => { + setSelectedOptions(prevSelectedOptions => { + const newSelectedOptions = prevSelectedOptions.includes(value) + ? prevSelectedOptions.filter(option => option !== value) + : [...prevSelectedOptions, value]; + setButtonStatus(newSelectedOptions.length > 0 ? 'selected' : 'initial'); + return newSelectedOptions; + }); + }, []); + + // Handle form submission + const onSubmit = useCallback(() => { + setButtonStatus('submitting'); + + // Logic to handle form submission + if (pollItem.multiple_response) { + if (selectedOptions.length === 0) { + return; + } + onFormSubmit(selectedOptions); + } else { + if (!selectedOption) { + return; + } + onFormSubmit([selectedOption]); + } + + // Simulate submission delay and complete the process + timeoutRef.current = setTimeout(() => { + setButtonStatus('submitted'); + + // Trigger the form submit complete callback, if provided + if (onFormSubmitComplete) { + timeoutRef.current = setTimeout(() => { + // Call the onFormSubmitComplete callback + onFormSubmitComplete(); + // Hide the button after submission + setButtonVisible(false); + }, 2000); + } else { + // If no callback is provided, immediately hide the button without waiting + setButtonVisible(false); + // Time for displaying "Submitted" before calling onFormSubmitComplete + } + }, 1000); + }, [ + selectedOption, + selectedOptions, + pollItem, + onFormSubmit, + onFormSubmitComplete, + ]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Derive button text from button status + const buttonText = (() => { + switch (buttonStatus) { + case 'initial': + return 'Submit'; + case 'selected': + return 'Submit'; + case 'submitting': + return 'Submitting...'; + case 'submitted': + return 'Submitted'; + } + })(); + + // Define when the submit button should be disabled + const submitDisabled = + buttonStatus === 'submitting' || + buttonStatus === 'submitted' || + (pollItem.type === PollKind.OPEN_ENDED && answer?.trim() === '') || + (pollItem.type === PollKind.YES_NO && !selectedOption) || + (pollItem.type === PollKind.MCQ && + !pollItem.multiple_response && + !selectedOption) || + (pollItem.type === PollKind.MCQ && + pollItem.multiple_response && + selectedOptions.length === 0); + + return { + selectedOption, + selectedOptions, + handleRadioSelect, + handleCheckboxToggle, + onSubmit, + buttonVisible, + buttonStatus, + buttonText, + answer, + setAnswer, + submitDisabled, + }; +} + +export type {PollFormInput, PollFormButton}; diff --git a/polling/hook/usePollPermissions.tsx b/polling/hook/usePollPermissions.tsx new file mode 100644 index 0000000..fbca9e2 --- /dev/null +++ b/polling/hook/usePollPermissions.tsx @@ -0,0 +1,73 @@ +import {useMemo} from 'react'; +import {useLocalUid, useRoomInfo} from 'customization-api'; +import {PollItem, PollStatus} from '../context/poll-context'; +import {isWebOnly} from '../helpers'; + +interface PollPermissions { + canCreate: boolean; + canEdit: boolean; + canEnd: boolean; + canViewWhoVoted: boolean; + canViewVotesPercent: boolean; + canViewPollDetails: boolean; +} + +interface UsePollPermissionsProps { + pollItem?: PollItem; // The current poll object +} + +export const usePollPermissions = ({ + pollItem, +}: UsePollPermissionsProps): PollPermissions => { + const localUid = useLocalUid(); + const { + data: {isHost}, + } = useRoomInfo(); + // Calculate permissions using useMemo to optimize performance + const permissions = useMemo(() => { + // Check if the current user is the creator of the poll + const isPollCreator = pollItem?.createdBy.uid === localUid || false; + // Determine if the user is both a host and the creator of the poll + const isPollHost = isHost && isPollCreator; + // Determine if the user is a host but not the creator of the poll (co-host) + // const isPollCoHost = isHost && !isPollCreator; + // Determine if the user is an attendee (not a host and not the creator) + const isPollAttendee = !isHost && !isPollCreator; + + // Determine if the user can create the poll (only the host can create) + const canCreate = isHost && isWebOnly(); + // Determine if the user can edit the poll (only the poll host can edit) + const canEdit = isPollHost && isWebOnly(); + // Determine if the user can end the poll (only the poll host can end an active poll) + const canEnd = isPollHost && pollItem?.status === PollStatus.ACTIVE; + + // Determine if the user can view the percentage of votes + // - Hosts can always view the percentage of votes + // - Co-hosts and attendees can view it if share_host or share_attendee is true respectively + const canViewVotesPercent = true; + // isPollHost || + // (isPollCoHost && pollItem.share_host) || + // (isPollAttendee && pollItem.share_attendee); + + // Determine if the user can view poll details (all hosts can view details, attendees cannot) + const canViewPollDetails = true; + // isPollHost || isPollCoHost; + + // Determine if the user can view who voted + // - If `pollItem.anonymous` is true, no one can view who voted + // - If `pollItem.anonymous` is false, only hosts and co-hosts can view who voted, attendees cannot + const canViewWhoVoted = !isPollAttendee; + // canViewPollDetails && !pollItem?.anonymous; + + return { + canCreate, + canEdit, + canEnd, + canViewVotesPercent, + canViewWhoVoted, + canViewPollDetails, + }; + }, [localUid, pollItem, isHost]); + + return permissions; +}; diff --git a/polling/poll-icons.ts b/polling/poll-icons.ts new file mode 100644 index 0000000..ba5422a --- /dev/null +++ b/polling/poll-icons.ts @@ -0,0 +1,31 @@ +interface PollIconsInterface { + mcq: string; + 'like-dislike': string; + question: string; + 'bar-chart': string; + anonymous: string; + 'stop-watch': string; + group: string; + 'co-host': string; +} + +const pollIcons: PollIconsInterface = { + mcq: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN182NjY0OCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzY2NjQ4KSI+CjxwYXRoIGlkPSJjaGVja2xpc3QiIGQ9Ik01LjUyNDggMTYuMTczOUw5LjA3NDggMTIuNjIzOUM5LjI3NDggMTIuNDIzOSA5LjUwODE0IDEyLjMyODEgOS43NzQ4IDEyLjMzNjRDMTAuMDQxNSAxMi4zNDQ4IDEwLjI3NDggMTIuNDQ4OSAxMC40NzQ4IDEyLjY0ODlDMTAuNjU4MSAxMi44NDg5IDEwLjc0OTggMTMuMDgyMyAxMC43NDk4IDEzLjM0ODlDMTAuNzQ5OCAxMy42MTU2IDEwLjY1ODEgMTMuODQ4OSAxMC40NzQ4IDE0LjA0ODlMNi4yNDk4IDE4LjI5ODlDNi4wNDk4IDE4LjQ5ODkgNS44MTY0NyAxOC41OTg5IDUuNTQ5OCAxOC41OTg5QzUuMjgzMTQgMTguNTk4OSA1LjA0OTggMTguNDk4OSA0Ljg0OTggMTguMjk4OUwyLjY5OTggMTYuMTQ4OUMyLjUxNjQ3IDE1Ljk2NTYgMi40MjQ4IDE1LjczMjMgMi40MjQ4IDE1LjQ0ODlDMi40MjQ4IDE1LjE2NTYgMi41MTY0NyAxNC45MzIzIDIuNjk5OCAxNC43NDg5QzIuODgzMTQgMTQuNTY1NiAzLjExNjQ3IDE0LjQ3MzkgMy4zOTk4IDE0LjQ3MzlDMy42ODMxNCAxNC40NzM5IDMuOTE2NDcgMTQuNTY1NiA0LjA5OTggMTQuNzQ4OUw1LjUyNDggMTYuMTczOVpNNS41MjQ4IDguMTczOTRMOS4wNzQ4IDQuNjIzOTRDOS4yNzQ4IDQuNDIzOTQgOS41MDgxNCA0LjMyODEgOS43NzQ4IDQuMzM2NDRDMTAuMDQxNSA0LjM0NDc3IDEwLjI3NDggNC40NDg5NCAxMC40NzQ4IDQuNjQ4OTRDMTAuNjU4MSA0Ljg0ODk0IDEwLjc0OTggNS4wODIyNyAxMC43NDk4IDUuMzQ4OTRDMTAuNzQ5OCA1LjYxNTYgMTAuNjU4MSA1Ljg0ODk0IDEwLjQ3NDggNi4wNDg5NEw2LjI0OTggMTAuMjk4OUM2LjA0OTggMTAuNDk4OSA1LjgxNjQ3IDEwLjU5ODkgNS41NDk4IDEwLjU5ODlDNS4yODMxNCAxMC41OTg5IDUuMDQ5OCAxMC40OTg5IDQuODQ5OCAxMC4yOTg5TDIuNjk5OCA4LjE0ODk0QzIuNTE2NDcgNy45NjU2IDIuNDI0OCA3LjczMjI3IDIuNDI0OCA3LjQ0ODk0QzIuNDI0OCA3LjE2NTYgMi41MTY0NyA2LjkzMjI3IDIuNjk5OCA2Ljc0ODk0QzIuODgzMTQgNi41NjU2IDMuMTE2NDcgNi40NzM5NCAzLjM5OTggNi40NzM5NEMzLjY4MzE0IDYuNDczOTQgMy45MTY0NyA2LjU2NTYgNC4wOTk4IDYuNzQ4OTRMNS41MjQ4IDguMTczOTRaTTEzLjk5OTggMTYuOTk4OUMxMy43MTY1IDE2Ljk5ODkgMTMuNDc5IDE2LjkwMzEgMTMuMjg3MyAxNi43MTE0QzEzLjA5NTYgMTYuNTE5OCAxMi45OTk4IDE2LjI4MjMgMTIuOTk5OCAxNS45OTg5QzEyLjk5OTggMTUuNzE1NiAxMy4wOTU2IDE1LjQ3ODEgMTMuMjg3MyAxNS4yODY0QzEzLjQ3OSAxNS4wOTQ4IDEzLjcxNjUgMTQuOTk4OSAxMy45OTk4IDE0Ljk5ODlIMjAuOTk5OEMyMS4yODMxIDE0Ljk5ODkgMjEuNTIwNiAxNS4wOTQ4IDIxLjcxMjMgMTUuMjg2NEMyMS45MDQgMTUuNDc4MSAyMS45OTk4IDE1LjcxNTYgMjEuOTk5OCAxNS45OTg5QzIxLjk5OTggMTYuMjgyMyAyMS45MDQgMTYuNTE5OCAyMS43MTIzIDE2LjcxMTRDMjEuNTIwNiAxNi45MDMxIDIxLjI4MzEgMTYuOTk4OSAyMC45OTk4IDE2Ljk5ODlIMTMuOTk5OFpNMTMuOTk5OCA4Ljk5ODk0QzEzLjcxNjUgOC45OTg5NCAxMy40NzkgOC45MDMxIDEzLjI4NzMgOC43MTE0NEMxMy4wOTU2IDguNTE5NzcgMTIuOTk5OCA4LjI4MjI3IDEyLjk5OTggNy45OTg5NEMxMi45OTk4IDcuNzE1NiAxMy4wOTU2IDcuNDc4MSAxMy4yODczIDcuMjg2NDRDMTMuNDc5IDcuMDk0NzcgMTMuNzE2NSA2Ljk5ODk0IDEzLjk5OTggNi45OTg5NEgyMC45OTk4QzIxLjI4MzEgNi45OTg5NCAyMS41MjA2IDcuMDk0NzcgMjEuNzEyMyA3LjI4NjQ0QzIxLjkwNCA3LjQ3ODEgMjEuOTk5OCA3LjcxNTYgMjEuOTk5OCA3Ljk5ODk0QzIxLjk5OTggOC4yODIyNyAyMS45MDQgOC41MTk3NyAyMS43MTIzIDguNzExNDRDMjEuNTIwNiA4LjkwMzEgMjEuMjgzMSA4Ljk5ODk0IDIwLjk5OTggOC45OTg5NEgxMy45OTk4WiIgZmlsbD0iI0JEQ0ZEQiIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'like-dislike': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN181Nzk5NSIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzU3OTk1KSI+CjxwYXRoIGlkPSJ0aHVtYnNfdXBfZG93biIgZD0iTTAuNTg3NSAxMy40MTQxQzAuOTc5MTY3IDEzLjgwNTcgMS40NSAxNC4wMDE2IDIgMTQuMDAxNkg4LjI1QzguNTUgMTQuMDAxNiA4LjgyOTE3IDEzLjkyMjQgOS4wODc1IDEzLjc2NDFDOS4zNDU4MyAxMy42MDU3IDkuNTMzMzMgMTMuMzg0OSA5LjY1IDEzLjEwMTZMMTEuOSA3LjgwMTU2QzExLjkzMzMgNy43MTgyMyAxMS45NTgzIDcuNjMwNzMgMTEuOTc1IDcuNTM5MDZDMTEuOTkxNyA3LjQ0NzQgMTIgNy4zNTE1NiAxMiA3LjI1MTU2VjYuMDAxNTZDMTIgNS43MTgyMyAxMS45MDQyIDUuNDgwNzMgMTEuNzEyNSA1LjI4OTA2QzExLjUyMDggNS4wOTc0IDExLjI4MzMgNS4wMDE1NiAxMSA1LjAwMTU2SDUuOEw3LjEyNSAyLjEyNjU2QzcuMjA4MzMgMS42MDk5IDcuMDkxNjcgMS4xODQ5IDYuNzc1IDAuODUxNTYyQzYuNDU4MzMgMC41MTgyMjkgNi4wODMzMyAwLjM1MTU2MiA1LjY1IDAuMzUxNTYyQzUuNDY2NjcgMC4zNTE1NjIgNS4yODMzMyAwLjM4OTA2MiA1LjEgMC40NjQwNjJDNC45MTY2NyAwLjUzOTA2MiA0Ljc1IDAuNjUxNTYzIDQuNiAwLjgwMTU2M0wwLjQ1IDQuOTUxNTZDMC4zMTY2NjcgNS4wODQ5IDAuMjA4MzMzIDUuMjQzMjMgMC4xMjUgNS40MjY1NkMwLjA0MTY2NjcgNS42MDk5IDAgNS44MDE1NiAwIDYuMDAxNTZWMTIuMDAxNkMwIDEyLjU1MTYgMC4xOTU4MzMgMTMuMDIyNCAwLjU4NzUgMTMuNDE0MVoiIGZpbGw9IiNCRENGREIiLz4KPHBhdGggaWQ9InRodW1ic191cF9kb3duXzIiIGQ9Ik0xMyAxOUMxMi43MTY3IDE5IDEyLjQ3OTIgMTguOTA0MiAxMi4yODc1IDE4LjcxMjVDMTIuMDk1OCAxOC41MjA4IDEyIDE4LjI4MzMgMTIgMThWMTYuNzVDMTIgMTYuNjUgMTIuMDA4MyAxNi41NTQyIDEyLjAyNSAxNi40NjI1QzEyLjA0MTcgMTYuMzcwOCAxMi4wNjY3IDE2LjI4MzMgMTIuMSAxNi4yTDE0LjM1IDEwLjlDMTQuNDgzMyAxMC42MTY3IDE0LjY3NSAxMC4zOTU4IDE0LjkyNSAxMC4yMzc1QzE1LjE3NSAxMC4wNzkyIDE1LjQ1IDEwIDE1Ljc1IDEwSDIyQzIyLjU1IDEwIDIzLjAyMDggMTAuMTk1OCAyMy40MTI1IDEwLjU4NzVDMjMuODA0MiAxMC45NzkyIDI0IDExLjQ1IDI0IDEyVjE4QzI0IDE4LjIgMjMuOTYyNSAxOC4zODc1IDIzLjg4NzUgMTguNTYyNUMyMy44MTI1IDE4LjczNzUgMjMuNyAxOC45IDIzLjU1IDE5LjA1TDE5LjQgMjMuMkMxOS4yNSAyMy4zNSAxOS4wODMzIDIzLjQ2MjUgMTguOSAyMy41Mzc1QzE4LjcxNjcgMjMuNjEyNSAxOC41MzMzIDIzLjY1IDE4LjM1IDIzLjY1QzE3LjkxNjcgMjMuNjUgMTcuNTQxNyAyMy40ODMzIDE3LjIyNSAyMy4xNUMxNi45MDgzIDIyLjgxNjcgMTYuNzkxNyAyMi4zOTE3IDE2Ljg3NSAyMS44NzVMMTguMiAxOUgxM1oiIGZpbGw9IiNCRENGREIiLz4KPC9nPgo8L2c+Cjwvc3ZnPgo=', + question: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9InBvbGwtaWNvbnMiPgo8bWFzayBpZD0ibWFzazBfOTYzN182NjYzMCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjRkY0MTREIi8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzY2NjMwKSI+CjxwYXRoIGlkPSJWZWN0b3IiIGQ9Ik02LjI1IDEzLjg3NUgxMy43NVYxMi4zNzVINi4yNVYxMy44NzVaTTYuMjUgMTAuODc1SDE3Ljc1VjkuMzc1SDYuMjVWMTAuODc1Wk02LjI1IDcuODc1SDE3Ljc1VjYuMzc1SDYuMjVWNy44NzVaTTQuMzA3NzUgMTcuNzVDMy44MTA1OCAxNy43NSAzLjM4NSAxNy41NzMgMy4wMzEgMTcuMjE5QzIuNjc3IDE2Ljg2NSAyLjUgMTYuNDM5NCAyLjUgMTUuOTQyM1Y0LjMwNzc1QzIuNSAzLjgxMDU4IDIuNjc3IDMuMzg1IDMuMDMxIDMuMDMxQzMuMzg1IDIuNjc3IDMuODEwNTggMi41IDQuMzA3NzUgMi41SDE5LjY5MjNDMjAuMTg5NCAyLjUgMjAuNjE1IDIuNjc3IDIwLjk2OSAzLjAzMUMyMS4zMjMgMy4zODUgMjEuNSAzLjgxMDU4IDIxLjUgNC4zMDc3NVYxNS45NDIzQzIxLjUgMTYuNDM5NCAyMS4zMjMgMTYuODY1IDIwLjk2OSAxNy4yMTlDMjAuNjE1IDE3LjU3MyAyMC4xODk0IDE3Ljc1IDE5LjY5MjMgMTcuNzVIMTQuNDgyN0wxMi43NDggMjAuMzU1N0MxMi42NTczIDIwLjQ5MjkgMTIuNTQ3NyAyMC41OTU4IDEyLjQxOTIgMjAuNjY0NUMxMi4yOTA5IDIwLjczMyAxMi4xNTEyIDIwLjc2NzMgMTIgMjAuNzY3M0MxMS44NDg4IDIwLjc2NzMgMTEuNzA5MSAyMC43MzMgMTEuNTgwOCAyMC42NjQ1QzExLjQ1MjMgMjAuNTk1OCAxMS4zNDI3IDIwLjQ5MjkgMTEuMjUyIDIwLjM1NTdMOS41MTcyNSAxNy43NUg0LjMwNzc1WiIgZmlsbD0iI0JEQ0ZEQiIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'bar-chart': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzMiAzMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggaWQ9ImJhcl9jaGFydCIgZD0iTTI2LjQ2MTMgMzJDMjUuOTE1MiAzMiAyNS40NTcyIDMxLjgxNTEgMjUuMDg3NSAzMS40NDUzQzI0LjcxOCAzMS4wNzU5IDI0LjUzMzMgMzAuNjE4IDI0LjUzMzMgMzAuMDcxNVYyMS4zMzMzQzI0LjUzMzMgMjAuNzg3MiAyNC43MTggMjAuMzI5MiAyNS4wODc1IDE5Ljk1OTVDMjUuNDU3MiAxOS41OSAyNS45MTUyIDE5LjQwNTMgMjYuNDYxMyAxOS40MDUzSDMwLjA3MTVDMzAuNjE4IDE5LjQwNTMgMzEuMDc1OSAxOS41OSAzMS40NDUzIDE5Ljk1OTVDMzEuODE1MSAyMC4zMjkyIDMyIDIwLjc4NzIgMzIgMjEuMzMzM1YzMC4wNzE1QzMyIDMwLjYxOCAzMS44MTUxIDMxLjA3NTkgMzEuNDQ1MyAzMS40NDUzQzMxLjA3NTkgMzEuODE1MSAzMC42MTggMzIgMzAuMDcxNSAzMkgyNi40NjEzWk0xNC4xOTQ3IDMyQzEzLjY0ODUgMzIgMTMuMTkwNiAzMS44MTUxIDEyLjgyMDggMzEuNDQ1M0MxMi40NTE0IDMxLjA3NTkgMTIuMjY2NyAzMC42MTggMTIuMjY2NyAzMC4wNzE1VjEuOTI4NTNDMTIuMjY2NyAxLjM4MjA0IDEyLjQ1MTQgMC45MjQwOSAxMi44MjA4IDAuNTU0NjY4QzEzLjE5MDYgMC4xODQ4OSAxMy42NDg1IDAgMTQuMTk0NyAwSDE3LjgwNTNDMTguMzUxNSAwIDE4LjgwOTQgMC4xODQ4OSAxOS4xNzkyIDAuNTU0NjY4QzE5LjU0ODYgMC45MjQwOSAxOS43MzMzIDEuMzgyMDQgMTkuNzMzMyAxLjkyODUzVjMwLjA3MTVDMTkuNzMzMyAzMC42MTggMTkuNTQ4NiAzMS4wNzU5IDE5LjE3OTIgMzEuNDQ1M0MxOC44MDk0IDMxLjgxNTEgMTguMzUxNSAzMiAxNy44MDUzIDMySDE0LjE5NDdaTTEuOTI4NTMgMzJDMS4zODIwNCAzMiAwLjkyNDA4OSAzMS44MTUxIDAuNTU0NjY2IDMxLjQ0NTNDMC4xODQ4ODkgMzEuMDc1OSAwIDMwLjYxOCAwIDMwLjA3MTVWMTIuMzk5NUMwIDExLjg0MzQgMC4xODQ4ODkgMTEuMzgxMyAwLjU1NDY2NiAxMS4wMTMzQzAuOTI0MDg5IDEwLjY0NTcgMS4zODIwNCAxMC40NjE5IDEuOTI4NTMgMTAuNDYxOUg1LjUzODY3QzYuMDg0OCAxMC40NjE5IDYuNTQyNzYgMTAuNjQ2NiA2LjkxMjUzIDExLjAxNkM3LjI4MTk2IDExLjM4NTQgNy40NjY2NyAxMS44NDM0IDcuNDY2NjcgMTIuMzg5OVYzMC4wNjE5QzcuNDY2NjcgMzAuNjE4MyA3LjI4MTk2IDMxLjA4MDQgNi45MTI1MyAzMS40NDhDNi41NDI3NiAzMS44MTYgNi4wODQ4IDMyIDUuNTM4NjcgMzJIMS45Mjg1M1oiIGZpbGw9IiMxRDFEMUQiLz4KPC9zdmc+Cg==', + anonymous: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0Fub255bW91cyI+CjxtYXNrIGlkPSJtYXNrMF85NjM3XzUzMzM5IiBzdHlsZT0ibWFzay10eXBlOmFscGhhIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSIwIiB5PSIwIiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiPgo8cmVjdCBpZD0iQm91bmRpbmcgYm94IiB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIGZpbGw9IiNEOUQ5RDkiLz4KPC9tYXNrPgo8ZyBtYXNrPSJ1cmwoI21hc2swXzk2MzdfNTMzMzkpIj4KPHBhdGggaWQ9InZpc2liaWxpdHlfbG9jayIgZD0iTTEzLjgzMTcgMTYuNTkyNkMxMy43MTI4IDE2LjU5MjYgMTMuNjEzMSAxNi41NTIzIDEzLjUzMjYgMTYuNDcxOUMxMy40NTIxIDE2LjM5MTQgMTMuNDExOSAxNi4yOTE2IDEzLjQxMTkgMTYuMTcyOFYxMy4yOTNDMTMuNDExOSAxMy4xNzU1IDEzLjQ1NzggMTMuMDczOSAxMy41NDk2IDEyLjk4ODRDMTMuNjQxNiAxMi45MDMgMTMuNzQ5IDEyLjg2NzIgMTMuODcxOSAxMi44ODExSDE0LjM3MTlWMTEuODgxMUMxNC4zNzE5IDExLjUwMTYgMTQuNTA3NiAxMS4xNzY5IDE0Ljc3ODggMTAuOTA2N0MxNS4wNTAxIDEwLjYzNjQgMTUuMzc2MiAxMC41MDEzIDE1Ljc1NzEgMTAuNTAxM0MxNi4xMzgxIDEwLjUwMTMgMTYuNDYxIDEwLjYzNjQgMTYuNzI1OSAxMC45MDY3QzE2Ljk5MDkgMTEuMTc2OSAxNy4xMjM0IDExLjUwMTYgMTcuMTIzNCAxMS44ODExVjEyLjg4MTFIMTcuNjIzNEMxNy43NDkxIDEyLjg4MTEgMTcuODU3MSAxMi45MTk2IDE3Ljk0NzYgMTIuOTk2N0MxOC4wMzgxIDEzLjA3MzkgMTguMDgzNCAxMy4xNzI3IDE4LjA4MzQgMTMuMjkzVjE2LjE3MjhDMTguMDgzNCAxNi4yOTE2IDE4LjA0MzEgMTYuMzkxNCAxNy45NjI2IDE2LjQ3MTlDMTcuODgyMSAxNi41NTIzIDE3Ljc4MjQgMTYuNTkyNiAxNy42NjM0IDE2LjU5MjZIMTMuODMxN1pNMTUuMDI1NyAxMi44ODExSDE2LjQ2OTZWMTEuODgxMUMxNi40Njk2IDExLjY2ODYgMTYuNDAzNSAxMS40OTQ1IDE2LjI3MTMgMTEuMzU4OEMxNi4xMzkxIDExLjIyMyAxNS45Njc0IDExLjE1NTEgMTUuNzU2MSAxMS4xNTUxQzE1LjU0NDkgMTEuMTU1MSAxNS4zNzAzIDExLjIyMyAxNS4yMzI0IDExLjM1ODhDMTUuMDk0NiAxMS40OTQ1IDE1LjAyNTcgMTEuNjY4NiAxNS4wMjU3IDExLjg4MTFWMTIuODgxMVpNMTAuMDAwMSAxMi4wMDEzQzkuNDQ0NTEgMTIuMDAxMyA4Ljk3MjI4IDExLjgwNjkgOC41ODM0IDExLjQxOEM4LjE5NDUxIDExLjAyOTEgOC4wMDAwNiAxMC41NTY5IDguMDAwMDYgMTAuMDAxM0M4LjAwMDA2IDkuNDQ1NzUgOC4xOTQ1MSA4Ljk3MzUyIDguNTgzNCA4LjU4NDY0QzguOTcyMjggOC4xOTU3NSA5LjQ0NDUxIDguMDAxMyAxMC4wMDAxIDguMDAxM0MxMC41NTU2IDguMDAxMyAxMS4wMjc4IDguMTk1NzUgMTEuNDE2NyA4LjU4NDY0QzExLjgwNTYgOC45NzM1MiAxMi4wMDAxIDkuNDQ1NzUgMTIuMDAwMSAxMC4wMDEzQzEyLjAwMDEgMTAuNTU2OSAxMS44MDU2IDExLjAyOTEgMTEuNDE2NyAxMS40MThDMTEuMDI3OCAxMS44MDY5IDEwLjU1NTYgMTIuMDAxMyAxMC4wMDAxIDEyLjAwMTNaTTEwLjAwMDEgMTUuNTg0NkM4LjI4NjA0IDE1LjU4NDYgNi42ODM5NSAxNS4xMzg5IDUuMTkzODEgMTQuMjQ3M0MzLjcwMzUzIDEzLjM1NTcgMi41NjMxMiAxMi4xNDU2IDEuNzcyNTYgMTAuNjE3MUMxLjcxODEyIDEwLjUxOTggMS42NzgzMyAxMC40MjA5IDEuNjUzMTkgMTAuMzIwNUMxLjYyODA1IDEwLjIyMDEgMS42MTU0OCAxMC4xMTM3IDEuNjE1NDggMTAuMDAxM0MxLjYxNTQ4IDkuODg4OTQgMS42MjgwNSA5Ljc4MjU1IDEuNjUzMTkgOS42ODIxNEMxLjY3ODMzIDkuNTgxNzIgMS43MTgxMiA5LjQ4MjgzIDEuNzcyNTYgOS4zODU0N0MyLjU2MzEyIDcuODU3IDMuNzAzNTMgNi42NDY5MyA1LjE5MzgxIDUuNzU1MjZDNi42ODM5NSA0Ljg2MzczIDguMjg2MDQgNC40MTc5NyAxMC4wMDAxIDQuNDE3OTdDMTAuODg3NiA0LjQxNzk3IDExLjczOTUgNC41MzcxMyAxMi41NTU5IDQuNzc1NDdDMTMuMzcyMyA1LjAxMzY2IDE0LjEzNDEgNS4zNTAxOSAxNC44NDEzIDUuNzg1MDVDMTUuMzk1NSA2LjEyMTcyIDE1LjkwNzcgNi41MDk1NyAxNi4zNzggNi45NDg1OUMxNi44NDgzIDcuMzg3NjIgMTcuMjcyNSA3Ljg3NzQxIDE3LjY1MDcgOC40MTc5N0MxNy43OTI4IDguNjI3NDEgMTcuOCA4Ljg0NjQ0IDE3LjY3MjQgOS4wNzUwNUMxNy41NDQ3IDkuMzAzNjYgMTcuMzUzMiA5LjQxNzk3IDE3LjA5NzggOS40MTc5N0gxNS43NTE3QzE1LjIzODcgOS40MTc5NyAxNC43NjE5IDkuNDg2MzcgMTQuMzIxMyA5LjYyMzE4QzEzLjg4MDYgOS43NTk4NCAxMy40ODQgOS45NjQ5OCAxMy4xMzE1IDEwLjIzODZDMTMuMTM2OCAxMC4xOTQ3IDEzLjE0MDggMTAuMTUzNyAxMy4xNDM0IDEwLjExNTdDMTMuMTQ2MiAxMC4wNzc2IDEzLjE0NzYgMTAuMDM5NSAxMy4xNDc2IDEwLjAwMTNDMTMuMTQ3NiA5LjEyNyAxMi44NDE0IDguMzgzODcgMTIuMjI5IDcuNzcxOTNDMTEuNjE2NyA3LjE1OTg0IDEwLjg3MyA2Ljg1MzggOS45OTgxOSA2Ljg1MzhDOS4xMjMzMyA2Ljg1MzggOC4zODAzNCA3LjE1OTk4IDcuNzY5MjMgNy43NzIzNEM3LjE1ODEyIDguMzg0NzEgNi44NTI1NiA5LjEyODMyIDYuODUyNTYgMTAuMDAzMkM2Ljg1MjU2IDEwLjg3OCA3LjE1ODYgMTEuNjIxIDcuNzcwNjkgMTIuMjMyMUM4LjM4MjYzIDEyLjg0MzIgOS4xMjU3NiAxMy4xNDg4IDEwLjAwMDEgMTMuMTQ4OEMxMC4zNjEyIDEzLjE0ODggMTAuNzEgMTMuMDkwNSAxMS4wNDY1IDEyLjk3NEMxMS4zODMgMTIuODU3NiAxMS42ODQ5IDEyLjY5NjkgMTEuOTUxOSAxMi40OTE3QzExLjk0MTIgMTIuNTIzNyAxMS45MzE5IDEyLjU3MTggMTEuOTI0IDEyLjYzNjFDMTEuOTE2IDEyLjcwMDMgMTEuOTExOSAxMi43NjU5IDExLjkxMTkgMTIuODMzVjE0Ljc5MjZDMTEuOTExOSAxNC45NzAzIDExLjg1NjkgMTUuMTI3MiAxMS43NDY3IDE1LjI2MzJDMTEuNjM2NiAxNS4zOTkzIDExLjQ5MzUgMTUuNDc4NyAxMS4zMTc0IDE1LjUwMTNDMTEuMTAyNSAxNS41MjkxIDEwLjg4NzYgMTUuNTQ5OSAxMC42NzI2IDE1LjU2MzhDMTAuNDU3NyAxNS41Nzc3IDEwLjIzMzUgMTUuNTg0NiAxMC4wMDAxIDE1LjU4NDZaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'stop-watch': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L1N0b3BfV2F0Y2giPgo8bWFzayBpZD0ibWFzazBfOTYzN181MzMwNiIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjRDlEOUQ5Ii8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzUzMzA2KSI+CjxwYXRoIGlkPSJhbGFybV9vbiIgZD0iTTkuMTQ1NzkgMTEuNTk1M0w3Ljg4OTMzIDEwLjMzODlDNy43NTQwNiAxMC4yMDExIDcuNTk1NzkgMTAuMTMwOCA3LjQxNDU0IDEwLjEyOEM3LjIzMzI5IDEwLjEyNTQgNy4wNzEwNyAxMC4xOTM1IDYuOTI3ODcgMTAuMzMyNEM2Ljc5ODU3IDEwLjQ3MTQgNi43MzM5MiAxMC42MzE0IDYuNzMzOTIgMTAuODEyNEM2LjczMzkyIDEwLjk5MzQgNi43OTg1NyAxMS4xNTM0IDYuOTI3ODcgMTEuMjkyNEw4LjY4MSAxMy4wNTg0QzguODE2NTYgMTMuMTk1MSA4Ljk3NDYxIDEzLjI2MzQgOS4xNTUxNyAxMy4yNjM0QzkuMzM1ODYgMTMuMjYzNCA5LjQ5NDYxIDEzLjE5NTEgOS42MzE0MiAxMy4wNTg0TDEzLjEwNTggOS41ODQwNkMxMy4yNDM2IDkuNDQ4OTIgMTMuMzEzOCA5LjI4ODUxIDEzLjMxNjQgOS4xMDI4MUMxMy4zMTkxIDguOTE3MjYgMTMuMjQ5OSA4Ljc1Mjg4IDEzLjEwODkgOC42MDk2OUMxMi45Njc5IDguNDY2NDkgMTIuODA1NiA4LjM5NDkgMTIuNjIxOCA4LjM5NDlDMTIuNDM4MSA4LjM5NDkgMTIuMjc0NiA4LjQ2NjQ5IDEyLjEzMTQgOC42MDk2OUw5LjE0NTc5IDExLjU5NTNaTTkuOTk5OTYgMTcuNTgyNEM5LjA4NjQ5IDE3LjU4MjQgOC4yMzA1OCAxNy40MDk3IDcuNDMyMjUgMTcuMDY0M0M2LjYzMzkyIDE2LjcxODkgNS45Mzc4MSAxNi4yNDkxIDUuMzQzOTIgMTUuNjU1MUM0Ljc0OTg5IDE1LjA2MTIgNC4yODAxNyAxNC4zNjUxIDMuOTM0NzUgMTMuNTY2OEMzLjU4OTMzIDEyLjc2ODQgMy40MTY2MiAxMS45MTI1IDMuNDE2NjIgMTAuOTk5MUMzLjQxNjYyIDEwLjA4NTYgMy41ODkzMyA5LjIyOTY5IDMuOTM0NzUgOC40MzEzNUM0LjI4MDE3IDcuNjMzMDIgNC43NDk4OSA2LjkzNjkxIDUuMzQzOTIgNi4zNDMwMkM1LjkzNzgxIDUuNzQ4OTkgNi42MzM5MiA1LjI3OTI3IDcuNDMyMjUgNC45MzM4NUM4LjIzMDU4IDQuNTg4NDQgOS4wODY0OSA0LjQxNTczIDkuOTk5OTYgNC40MTU3M0MxMC45MTM0IDQuNDE1NzMgMTEuNzY5MyA0LjU4ODQ0IDEyLjU2NzcgNC45MzM4NUMxMy4zNjYgNS4yNzkyNyAxNC4wNjIxIDUuNzQ4OTkgMTQuNjU2IDYuMzQzMDJDMTUuMjUgNi45MzY5MSAxNS43MTk3IDcuNjMzMDIgMTYuMDY1MiA4LjQzMTM1QzE2LjQxMDYgOS4yMjk2OSAxNi41ODMzIDEwLjA4NTYgMTYuNTgzMyAxMC45OTkxQzE2LjU4MzMgMTEuOTEyNSAxNi40MTA2IDEyLjc2ODQgMTYuMDY1MiAxMy41NjY4QzE1LjcxOTcgMTQuMzY1MSAxNS4yNSAxNS4wNjEyIDE0LjY1NiAxNS42NTUxQzE0LjA2MjEgMTYuMjQ5MSAxMy4zNjYgMTYuNzE4OSAxMi41Njc3IDE3LjA2NDNDMTEuNzY5MyAxNy40MDk3IDEwLjkxMzQgMTcuNTgyNCA5Ljk5OTk2IDE3LjU4MjRaTTIuMjQyMDQgNi40NDEzNUMyLjEyMTIxIDYuMzIwNjYgMi4wNjA3OSA2LjE5MTYzIDIuMDYwNzkgNi4wNTQyN0MyLjA2MDc5IDUuOTE3MDUgMi4xMjEyMSA1Ljc4ODA5IDIuMjQyMDQgNS42Njc0TDQuNjg5MTIgMy4yMjAzMUM0LjgwNDU0IDMuMTA0OSA0LjkzMjE4IDMuMDQ1ODcgNS4wNzIwNCAzLjA0MzIzQzUuMjEyMDQgMy4wNDA0NSA1LjM0MjM5IDMuMDk5NDggNS40NjMwOCAzLjIyMDMxQzUuNTgzNzggMy4zNDEwMSA1LjY0NDEyIDMuNDY5OTcgNS42NDQxMiAzLjYwNzE5QzUuNjQ0MTIgMy43NDQ1NSA1LjU4Mzc4IDMuODczNTggNS40NjMwOCAzLjk5NDI3TDIuOTk1MTcgNi40NjIxOUMyLjg3OTc1IDYuNTc3NiAyLjc1NTU4IDYuNjMzMTYgMi42MjI2NyA2LjYyODg1QzIuNDg5NjEgNi42MjQ1NSAyLjM2Mjc0IDYuNTYyMDUgMi4yNDIwNCA2LjQ0MTM1Wk0xNy43Nzg3IDYuNDQxMzVDMTcuNjU4IDYuNTYyMDUgMTcuNTI5MSA2LjYyMjQgMTcuMzkxOCA2LjYyMjRDMTcuMjU0NSA2LjYyMjQgMTcuMTI1NCA2LjU2MjA1IDE3LjAwNDcgNi40NDEzNUwxNC41NTc3IDMuOTk0MjdDMTQuNDQyMyAzLjg3ODg1IDE0LjM4MzIgMy43NTEyMiAxNC4zODA2IDMuNjExMzVDMTQuMzc3OSAzLjQ3MTM1IDE0LjQzNyAzLjM0MTAxIDE0LjU1NzcgMy4yMjAzMUMxNC42Nzg0IDMuMDk5NDggMTQuODA3NCAzLjAzOTA2IDE0Ljk0NDcgMy4wMzkwNkMxNS4wODIgMy4wMzkwNiAxNS4yMTA5IDMuMDk5NDggMTUuMzMxNiAzLjIyMDMxTDE3Ljc3ODcgNS42ODgyM0MxNy44OTQxIDUuODAzNjUgMTcuOTUzMiA1LjkyNzgxIDE3Ljk1NTggNi4wNjA3M0MxNy45NTg2IDYuMTkzNzkgMTcuODk5NSA2LjMyMDY2IDE3Ljc3ODcgNi40NDEzNVoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPC9nPgo8L3N2Zz4K', + group: + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0F0dGVuZGVlIj4KPG1hc2sgaWQ9Im1hc2swXzk2MzdfNTMzMTciIHN0eWxlPSJtYXNrLXR5cGU6YWxwaGEiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHg9IjAiIHk9IjAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+CjxyZWN0IGlkPSJCb3VuZGluZyBib3giIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI0Q5RDlEOSIvPgo8L21hc2s+CjxnIG1hc2s9InVybCgjbWFzazBfOTYzN181MzMxNykiPgo8cGF0aCBpZD0iZ3JvdXBzIiBkPSJNMi4wNTQ2NiAxNC4zMjM4QzEuODc4MTQgMTQuMzIzOCAxLjcyNzY1IDE0LjI2MTYgMS42MDMyMSAxNC4xMzczQzEuNDc4OSAxNC4wMTI5IDEuNDE2NzUgMTMuODYyNCAxLjQxNjc1IDEzLjY4NTlWMTMuNDMyOEMxLjQxNjc1IDEyLjkzMzkgMS42NzE1NCAxMi41MjcxIDIuMTgxMTIgMTIuMjEyM0MyLjY5MDg1IDExLjg5NzggMy4zNjkzMiAxMS43NDA1IDQuMjE2NTQgMTEuNzQwNUM0LjM4NDE4IDExLjc0MDUgNC41NDU5OCAxMS43NDg3IDQuNzAxOTYgMTEuNzY1M0M0Ljg1NzkzIDExLjc4MTggNS4wMTUwMSAxMS44MDM5IDUuMTczMjEgMTEuODMxN0M1LjAzODYyIDEyLjA0OTggNC45MzU1IDEyLjI4NTMgNC44NjM4MyAxMi41Mzg0QzQuNzkyMyAxMi43OTE2IDQuNzU2NTQgMTMuMDQyNyA0Ljc1NjU0IDEzLjI5MTdWMTQuMzIzOEgyLjA1NDY2Wk02LjU4OTg3IDE0LjMyMzhDNi40MDA3MSAxNC4zMjM4IDYuMjQxMjYgMTQuMjU4NSA2LjExMTU0IDE0LjEyOEM1Ljk4MTY4IDEzLjk5NzYgNS45MTY3NSAxMy44Mzk1IDUuOTE2NzUgMTMuNjUzOFYxMy4zMjM4QzUuOTE2NzUgMTIuOTg5MiA2LjAwNTQzIDEyLjY5MTYgNi4xODI3OSAxMi40MzExQzYuMzYwMTUgMTIuMTcwNCA2LjYzNDE4IDExLjk0MDcgNy4wMDQ4NyAxMS43NDIxQzcuMzc1NTcgMTEuNTQzNCA3LjgxMDcxIDExLjM5NzggOC4zMTAyOSAxMS4zMDU1QzguODA5NzMgMTEuMjEzIDkuMzc1MzYgMTEuMTY2NyAxMC4wMDcyIDExLjE2NjdDMTAuNjM4MSAxMS4xNjY3IDExLjE5OTkgMTEuMjEzIDExLjY5MjQgMTEuMzA1NUMxMi4xODQ5IDExLjM5NzggMTIuNjE2NSAxMS41NDM0IDEyLjk4NzIgMTEuNzQyMUMxMy4zNTggMTEuOTI2OSAxMy42MzM0IDEyLjE1NDMgMTMuODEzNCAxMi40MjQ0QzEzLjk5MzQgMTIuNjk0NiAxNC4wODM0IDEyLjk5NDQgMTQuMDgzNCAxMy4zMjM4VjEzLjY1MzhDMTQuMDgzNCAxMy44Mzk1IDE0LjAxODEgMTMuOTk3NiAxMy44ODc2IDE0LjEyOEMxMy43NTcyIDE0LjI1ODUgMTMuNTk5MSAxNC4zMjM4IDEzLjQxMzQgMTQuMzIzOEg2LjU4OTg3Wk0xNS4yNDM2IDE0LjMyMzhWMTMuMjkyOEMxNS4yNDM2IDEzLjAzNDYgMTUuMjA2NSAxMi43ODQ1IDE1LjEzMjQgMTIuNTQyNkMxNS4wNTgxIDEyLjMwMDYgMTQuOTUzNiAxMi4wNjM3IDE0LjgxOSAxMS44MzE3QzE0Ljk1NDcgMTEuODAzOSAxNS4wOTcxIDExLjc4MTggMTUuMjQ2MSAxMS43NjUzQzE1LjM5NTIgMTEuNzQ4NyAxNS41NzcgMTEuNzQwNSAxNS43OTE3IDExLjc0MDVDMTYuNjM5IDExLjc0MDUgMTcuMzE2MSAxMS44OTkxIDE3LjgyMyAxMi4yMTY1QzE4LjMyOTkgMTIuNTMzNyAxOC41ODM0IDEyLjkzOTEgMTguNTgzNCAxMy40MzI4VjEzLjY4NTlDMTguNTgzNCAxMy44NjI0IDE4LjUyMTMgMTQuMDEyOSAxOC4zOTcgMTQuMTM3M0MxOC4yNzI1IDE0LjI2MTYgMTguMTIyIDE0LjMyMzggMTcuOTQ1NSAxNC4zMjM4SDE1LjI0MzZaTTQuMjE0MjUgMTAuNzY0NEMzLjg2MzE0IDEwLjc2NDQgMy41NjU3OCAxMC42NDIxIDMuMzIyMTYgMTAuMzk3NkMzLjA3ODU1IDEwLjE1MyAyLjk1Njc1IDkuODU1NCAyLjk1Njc1IDkuNTA0ODRDMi45NTY3NSA5LjE2MzA0IDMuMDc5MTEgOC44NjkwMSAzLjMyMzgzIDguNjIyNzZDMy41Njg0MSA4LjM3NjUxIDMuODY1OTggOC4yNTMzOCA0LjIxNjU0IDguMjUzMzhDNC41NTgzNSA4LjI1MzM4IDQuODUzNjkgOC4zNzY1MSA1LjEwMjU4IDguNjIyNzZDNS4zNTE2MSA4Ljg2OTAxIDUuNDc2MTIgOS4xNjM4NyA1LjQ3NjEyIDkuNTA3MzRDNS40NzYxMiA5Ljg1MjkgNS4zNTMwNyAxMC4xNDg4IDUuMTA2OTYgMTAuMzk1MUM0Ljg2MDk4IDEwLjY0MTMgNC41NjM0MSAxMC43NjQ0IDQuMjE0MjUgMTAuNzY0NFpNMTUuNzkxNyAxMC43NjQ0QzE1LjQ0NDUgMTAuNzY0NCAxNS4xNDc4IDEwLjY0MTMgMTQuOTAxNSAxMC4zOTUxQzE0LjY1NTMgMTAuMTQ4OCAxNC41MzIyIDkuODUyOSAxNC41MzIyIDkuNTA3MzRDMTQuNTMyMiA5LjE2Mzg3IDE0LjY1NTMgOC44NjkwMSAxNC45MDE1IDguNjIyNzZDMTUuMTQ3OCA4LjM3NjUxIDE1LjQ0NSA4LjI1MzM4IDE1Ljc5MzIgOC4yNTMzOEMxNi4xMzk1IDguMjUzMzggMTYuNDM1NyA4LjM3NjUxIDE2LjY4MiA4LjYyMjc2QzE2LjkyODIgOC44NjkwMSAxNy4wNTEzIDkuMTYzMDQgMTcuMDUxMyA5LjUwNDg0QzE3LjA1MTMgOS44NTU0IDE2LjkyODYgMTAuMTUzIDE2LjY4MyAxMC4zOTc2QzE2LjQzNzQgMTAuNjQyMSAxNi4xNDA0IDEwLjc2NDQgMTUuNzkxNyAxMC43NjQ0Wk0xMC4wMDMgMTAuMTY2N0M5LjQ3MjE3IDEwLjE2NjcgOS4wMjAwOCA5Ljk4MDU0IDguNjQ2NzUgOS42MDgxOEM4LjI3MzQyIDkuMjM1ODIgOC4wODY3NSA4Ljc4MzczIDguMDg2NzUgOC4yNTE5M0M4LjA4Njc1IDcuNzIzMTggOC4yNzI4NiA3LjI3MjM0IDguNjQ1MDggNi44OTk0M0M5LjAxNzQ0IDYuNTI2MzcgOS40Njk2IDYuMzM5ODQgMTAuMDAxNSA2LjMzOTg0QzEwLjUzMDIgNi4zMzk4NCAxMC45ODEgNi41MjY0NCAxMS4zNTQgNi44OTk2NEMxMS43MjcgNy4yNzI2OSAxMS45MTM0IDcuNzIyOTcgMTEuOTEzNCA4LjI1MDQ3QzExLjkxMzQgOC43ODExNiAxMS43MjY5IDkuMjMzMjUgMTEuMzUzOCA5LjYwNjcyQzEwLjk4MDYgOS45ODAwNSAxMC41MzA0IDEwLjE2NjcgMTAuMDAzIDEwLjE2NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjwvZz4KPC9zdmc+Cg==', + 'co-host': + 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgaWQ9Ikljb24mIzYwO01lZGl1bSYjNjI7L0NvLUhvc3QiPgo8bWFzayBpZD0ibWFzazBfOTYzN181MzMyOCIgc3R5bGU9Im1hc2stdHlwZTphbHBoYSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeD0iMCIgeT0iMCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj4KPHJlY3QgaWQ9IkJvdW5kaW5nIGJveCIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBmaWxsPSIjRDlEOUQ5Ii8+CjwvbWFzaz4KPGcgbWFzaz0idXJsKCNtYXNrMF85NjM3XzUzMzI4KSI+CjxwYXRoIGlkPSJncm91cCIgZD0iTTIuNjY1MjggMTMuOTg2N0MyLjY2NTI4IDEzLjY2ODQgMi43NDEzOSAxMy4zODkzIDIuODkzNjIgMTMuMTQ5NEMzLjA0NTg0IDEyLjkwOTYgMy4yNTg1NSAxMi43MDk4IDMuNTMxNzQgMTIuNTUwM0M0LjIxMjU3IDEyLjE0OSA0LjkyOTAzIDExLjgzMyA1LjY4MTEyIDExLjYwMjFDNi40MzMzNCAxMS4zNzE0IDcuMjg5MSAxMS4yNTYxIDguMjQ4NDEgMTEuMjU2MUM5LjIwNzg1IDExLjI1NjEgMTAuMDYzNiAxMS4zNzE0IDEwLjgxNTcgMTEuNjAyMUMxMS41Njc5IDExLjgzMyAxMi4yODQ1IDEyLjE0OSAxMi45NjUzIDEyLjU1MDNDMTMuMjM4NSAxMi43MDk4IDEzLjQ1MTIgMTIuOTA5NiAxMy42MDM0IDEzLjE0OTRDMTMuNzU1NiAxMy4zODkzIDEzLjgzMTcgMTMuNjY4NCAxMy44MzE3IDEzLjk4NjdWMTQuMzIzNEMxMy44MzE3IDE0LjYxNzEgMTMuNzI2IDE0Ljg3MzggMTMuNTE0NSAxNS4wOTM0QzEzLjMwMjkgMTUuMzEzIDEzLjA0MjIgMTUuNDIyOCAxMi43MzI0IDE1LjQyMjhIMy43NjQ0NUMzLjQ1NDczIDE1LjQyMjggMy4xOTQxIDE1LjMxNyAyLjk4MjU3IDE1LjEwNTVDMi43NzEwNSAxNC44OTM4IDIuNjY1MjggMTQuNjMzMSAyLjY2NTI4IDE0LjMyMzRWMTMuOTg2N1pNMTUuMDcyMiAxNS40MjI4QzE1LjE0OTEgMTUuMjQ1NCAxNS4yMTE2IDE1LjA2NjIgMTUuMjU5NyAxNC44ODUxQzE1LjMwNzcgMTQuNzAzOSAxNS4zMzE3IDE0LjUxNjcgMTUuMzMxNyAxNC4zMjM0VjE0LjA1MDlDMTUuMzMxNyAxMy41MzE3IDE1LjIzMjYgMTMuMDU1NSAxNS4wMzQyIDEyLjYyMjNDMTQuODM1OCAxMi4xODkzIDE0LjU3MzggMTEuODM3IDE0LjI0ODQgMTEuNTY1NUMxNC42NDA1IDExLjY3NjYgMTUuMDI4NiAxMS44MTI1IDE1LjQxMjggMTEuOTczMkMxNS43OTY4IDEyLjEzNCAxNi4xNzUzIDEyLjMyNjYgMTYuNTQ4MiAxMi41NTA5QzE2Ljc4NDMgMTIuNjgzNCAxNi45NzQ1IDEyLjg4NDcgMTcuMTE4NiAxMy4xNTQ4QzE3LjI2MjggMTMuNDI0OCAxNy4zMzQ5IDEzLjcyMzUgMTcuMzM0OSAxNC4wNTA5VjE0LjMyMzRDMTcuMzM0OSAxNC42MzMxIDE3LjIyOTEgMTQuODkzOCAxNy4wMTc2IDE1LjEwNTVDMTYuODA2IDE1LjMxNyAxNi41NDU0IDE1LjQyMjggMTYuMjM1NyAxNS40MjI4SDE1LjA3MjJaTTguMjQ4NDEgOS43NDMxOEM3LjUyOTY2IDkuNzQzMTggNi45MTk0NSA5LjQ5MjM0IDYuNDE3NzggOC45OTA2OEM1LjkxNjEyIDguNDg4ODcgNS42NjUyOCA3Ljg3ODU5IDUuNjY1MjggNy4xNTk4NEM1LjY2NTI4IDYuNDQxMDkgNS45MTYxMiA1LjgzMDg5IDYuNDE3NzggNS4zMjkyMkM2LjkxOTQ1IDQuODI3NDEgNy41Mjk2NiA0LjU3NjUxIDguMjQ4NDEgNC41NzY1MUM4Ljk2NzE2IDQuNTc2NTEgOS41Nzc0NCA0LjgyNzQxIDEwLjA3OTIgNS4zMjkyMkMxMC41ODA5IDUuODMwODkgMTAuODMxNyA2LjQ0MTA5IDEwLjgzMTcgNy4xNTk4NEMxMC44MzE3IDcuODc4NTkgMTAuNTgwOSA4LjQ4ODg3IDEwLjA3OTIgOC45OTA2OEM5LjU3NzQ0IDkuNDkyMzQgOC45NjcxNiA5Ljc0MzE4IDguMjQ4NDEgOS43NDMxOFpNMTQuMTEwNSA3LjE1OTg0QzE0LjExMDUgNy44Nzg1OSAxMy44NTk3IDguNDg4ODcgMTMuMzU4IDguOTkwNjhDMTIuODU2MyA5LjQ5MjM0IDEyLjI0NjEgOS43NDMxOCAxMS41Mjc0IDkuNzQzMThDMTEuNDc1IDkuNzQzMTggMTEuNDQ1NiA5Ljc0NjM3IDExLjQzOTIgOS43NTI3NkMxMS40MzI3IDkuNzU5MTUgMTEuNDAzMyA5Ljc1NjUxIDExLjM1MTEgOS43NDQ4NEMxMS42NTE5IDkuMzkxMzcgMTEuODkwNiA4Ljk5ODE4IDEyLjA2NzIgOC41NjUyNkMxMi4yNDM1IDguMTMyMzQgMTIuMzMxNyA3LjY2MzU5IDEyLjMzMTcgNy4xNTkwMUMxMi4zMzE3IDYuNjU0MjkgMTIuMjQxNyA2LjE4NzQxIDEyLjA2MTcgNS43NTgzOUMxMS44ODE3IDUuMzI5NSAxMS42NDQ5IDQuOTM1MDUgMTEuMzUxMSA0LjU3NTA1QzExLjM4NzQgNC41NzM5NCAxMS40MTY3IDQuNTczOTQgMTEuNDM5MiA0LjU3NTA1QzExLjQ2MTYgNC41NzYwMiAxMS40OTEgNC41NzY1MSAxMS41Mjc0IDQuNTc2NTFDMTIuMjQ2MSA0LjU3NjUxIDEyLjg1NjMgNC44Mjc0MSAxMy4zNTggNS4zMjkyMkMxMy44NTk3IDUuODMwODkgMTQuMTEwNSA2LjQ0MTA5IDE0LjExMDUgNy4xNTk4NFoiIGZpbGw9IndoaXRlIi8+CjwvZz4KPC9nPgo8L3N2Zz4K', +}; + +export default pollIcons; +export type {PollIconsInterface}; diff --git a/polling/ui/BaseAccordian.tsx b/polling/ui/BaseAccordian.tsx new file mode 100644 index 0000000..f8f8597 --- /dev/null +++ b/polling/ui/BaseAccordian.tsx @@ -0,0 +1,157 @@ +import React, {useState, ReactNode, useEffect} from 'react'; +import { + View, + Text, + TouchableOpacity, + LayoutAnimation, + UIManager, + Platform, + StyleSheet, +} from 'react-native'; +import {ThemeConfig, $config, ImageIcon} from 'customization-api'; + +// Enable Layout Animation for Android +if (Platform.OS === 'android') { + UIManager.setLayoutAnimationEnabledExperimental && + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +// TypeScript Interfaces +interface BaseAccordionProps { + children: ReactNode; +} + +interface BaseAccordionHeaderProps { + title: string; + expandIcon?: React.ReactNode; + id: string; + isOpen: boolean; // Pass this prop explicitly + onPress: () => void; // Handle toggle functionality + children: React.ReactNode; +} + +interface BaseAccordionContentProps { + children: ReactNode; +} + +// Main Accordion Component to render multiple AccordionItems +const BaseAccordion: React.FC = ({children}) => { + return {children}; +}; + +// AccordionItem Component to manage isOpen state +const BaseAccordionItem: React.FC<{children: ReactNode; open?: boolean}> = ({ + children, + open = false, +}) => { + const [isOpen, setIsOpen] = useState(open); + + useEffect(() => { + setIsOpen(open); + }, [open]); + + const toggleAccordion = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setIsOpen(!isOpen); + }; + + // Separate AccordionHeader and AccordionContent components from children + const header = React.Children.toArray(children).find( + (child: any) => child.type === BaseAccordionHeader, + ); + const content = React.Children.toArray(children).find( + (child: any) => child.type === BaseAccordionContent, + ); + + return ( + + {/* Clone and pass props to AccordionHeader */} + {header && + React.cloneElement(header as React.ReactElement, { + isOpen, // Pass the isOpen state + onPress: toggleAccordion, // Pass the toggleAccordion function + })} + {isOpen && content} + + ); +}; + +// AccordionHeader Component for the Accordion Header +const BaseAccordionHeader: React.FC> = ({ + title, + isOpen, + onPress, + children, +}) => { + return ( + + + {title} + {children && {children}} + + + + + + ); +}; + +// AccordionContent Component for the Accordion Content +const BaseAccordionContent: React.FC = ({ + children, +}) => { + return {children}; +}; + +export { + BaseAccordion, + BaseAccordionItem, + BaseAccordionHeader, + BaseAccordionContent, +}; + +// Styles for Accordion Components +const styles = StyleSheet.create({ + accordionContainer: { + // marginVertical: 10, + }, + accordionItem: { + marginBottom: 8, + borderRadius: 8, + }, + accordionHeader: { + paddingVertical: 8, + paddingHorizontal: 19, + backgroundColor: $config.CARD_LAYER_3_COLOR, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + height: 36, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + }, + expandIcon: { + marginLeft: 8, + }, + accordionContent: { + paddingVertical: 20, + paddingHorizontal: 12, + backgroundColor: $config.CARD_LAYER_1_COLOR, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + accordionTitle: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '700', + lineHeight: 12, + }, +}); diff --git a/polling/ui/BaseButtonWithToggle.tsx b/polling/ui/BaseButtonWithToggle.tsx new file mode 100644 index 0000000..3e5d9ba --- /dev/null +++ b/polling/ui/BaseButtonWithToggle.tsx @@ -0,0 +1,104 @@ +import {StyleSheet, Text, View, TouchableOpacity} from 'react-native'; +import React from 'react'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, +} from 'customization-api'; +import Toggle from '../../../src/atoms/Toggle'; +// import Tooltip from '../../../src/atoms/Tooltip'; +import PlatformWrapper from '../../../src/utils/PlatformWrapper'; + +interface Props { + text: string; + value: boolean; + onPress: (value: boolean) => void; + tooltip?: boolean; + tooltTipText?: string; + hoverEffect?: boolean; + icon?: string; +} + +const BaseButtonWithToggle = ({ + text, + value, + onPress, + hoverEffect = false, + icon, +}: Props) => { + return ( + + {/* { + return ( */} + + {(isHovered: boolean) => { + return ( + { + onPress(value); + }}> + + + {text} + + + { + onPress(toggle); + }} + /> + + + ); + }} + + {/* ); + }} + /> */} + + ); +}; + +export default BaseButtonWithToggle; + +const styles = StyleSheet.create({ + toggleButton: { + width: '100%', + }, + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 8, + borderRadius: 4, + }, + hover: { + backgroundColor: $config.SEMANTIC_NEUTRAL + hexadecimalTransparency['25%'], + }, + text: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.tiny, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 12, + fontWeight: '400', + marginRight: 12, + }, + centerRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, +}); diff --git a/polling/ui/BaseModal.tsx b/polling/ui/BaseModal.tsx new file mode 100644 index 0000000..9ea9b1f --- /dev/null +++ b/polling/ui/BaseModal.tsx @@ -0,0 +1,217 @@ +import { + Modal, + View, + StyleSheet, + Text, + TouchableWithoutFeedback, + ScrollView, +} from 'react-native'; +import React, {ReactNode} from 'react'; +import { + ThemeConfig, + hexadecimalTransparency, + IconButton, + isMobileUA, + $config, +} from 'customization-api'; + +interface TitleProps { + title?: string; + children?: ReactNode | ReactNode[]; +} + +function BaseModalTitle({title, children}: TitleProps) { + return ( + + {title && ( + + {title} + + )} + {children} + + ); +} + +interface ContentProps { + children: ReactNode; + noPadding?: boolean; +} + +function BaseModalContent({children, noPadding}: ContentProps) { + return ( + + + {children} + + + ); +} + +interface ActionProps { + children: ReactNode; + alignRight?: boolean; +} +function BaseModalActions({children, alignRight}: ActionProps) { + return ( + + {children} + + ); +} + +type BaseModalProps = { + visible?: boolean; + onClose: () => void; + children: ReactNode; + width?: number; + cancelable?: boolean; +}; + +const BaseModal = ({ + children, + visible = false, + width = 650, + cancelable = false, + onClose, +}: BaseModalProps) => { + return ( + + + { + cancelable && onClose(); + }}> + + + + {children} + + + + ); +}; + +type BaseModalCloseIconProps = { + onClose: () => void; +}; + +const BaseModalCloseIcon = ({onClose}: BaseModalCloseIconProps) => { + return ( + + + + ); +}; +export { + BaseModal, + BaseModalTitle, + BaseModalContent, + BaseModalActions, + BaseModalCloseIcon, +}; + +const style = StyleSheet.create({ + baseModalContainer: { + flex: 1, + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + baseModal: { + zIndex: 2, + backgroundColor: $config.CARD_LAYER_1_COLOR, + borderWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + borderRadius: ThemeConfig.BorderRadius.large, + shadowColor: $config.HARD_CODED_BLACK_COLOR, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 5, + minWidth: 340, + maxWidth: '90%', + minHeight: 220, + maxHeight: '80%', // Set a maximum height for the modal + overflow: 'hidden', + }, + baseModalBody: { + flex: 1, + }, + baseBackdrop: { + zIndex: 1, + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: + $config.HARD_CODED_BLACK_COLOR + hexadecimalTransparency['60%'], + }, + scrollgrow: { + flexGrow: 1, + }, + header: { + display: 'flex', + paddingHorizontal: 20, + paddingVertical: 12, + alignItems: 'center', + gap: 20, + height: 60, + justifyContent: 'space-between', + flexDirection: 'row', + borderBottomWidth: 1, + borderColor: $config.CARD_LAYER_3_COLOR, + }, + title: { + color: $config.FONT_COLOR + ThemeConfig.EmphasisPlus.high, + fontSize: ThemeConfig.FontSize.xLarge, + fontFamily: ThemeConfig.FontFamily.sansPro, + lineHeight: 32, + fontWeight: '600', + letterSpacing: -0.48, + }, + content: { + padding: 20, + gap: 20, + display: 'flex', + }, + noPadding: { + padding: 0, + }, + actions: { + display: 'flex', + flexDirection: 'row', + height: 60, + paddingVertical: 12, + paddingHorizontal: 16, + gap: 16, + alignItems: 'center', + flexShrink: 0, + borderTopWidth: 1, + borderTopColor: $config.CARD_LAYER_3_COLOR, + backgroundColor: $config.CARD_LAYER_2_COLOR, + }, + alignRight: { + justifyContent: 'flex-end', + }, +}); diff --git a/polling/ui/BaseMoreButton.tsx b/polling/ui/BaseMoreButton.tsx new file mode 100644 index 0000000..39a6bed --- /dev/null +++ b/polling/ui/BaseMoreButton.tsx @@ -0,0 +1,44 @@ +import {StyleSheet, View} from 'react-native'; +import React, {forwardRef} from 'react'; +import {hexadecimalTransparency, IconButton} from 'customization-api'; + +interface MoreMenuProps { + setActionMenuVisible: (f: boolean) => void; +} + +const BaseMoreButton = forwardRef( + ({setActionMenuVisible}, ref) => { + return ( + + { + setActionMenuVisible(true); + }} + /> + + ); + }, +); + +export {BaseMoreButton}; + +const style = StyleSheet.create({ + hoverEffect: { + backgroundColor: + $config.CARD_LAYER_5_COLOR + hexadecimalTransparency['25%'], + borderRadius: 18, + }, + iconContainerStyle: { + padding: 4, + borderRadius: 18, + }, +}); diff --git a/polling/ui/BaseRadioButton.tsx b/polling/ui/BaseRadioButton.tsx new file mode 100644 index 0000000..4ecad6a --- /dev/null +++ b/polling/ui/BaseRadioButton.tsx @@ -0,0 +1,124 @@ +import { + TouchableOpacity, + View, + StyleSheet, + Text, + StyleProp, + TextStyle, +} from 'react-native'; +import React from 'react'; +import { + ThemeConfig, + $config, + ImageIcon, + hexadecimalTransparency, +} from 'customization-api'; + +interface Props { + option: { + label: string; + value: string; + }; + checked: boolean; + onChange: (option: string) => void; + labelStyle?: StyleProp; + filledColor?: string; + tickColor?: string; + disabled?: boolean; + ignoreDisabledStyle?: boolean; // Type for custom style prop +} +export default function BaseRadioButton(props: Props) { + const { + option, + checked, + onChange, + disabled = false, + labelStyle = {}, + filledColor = '', + tickColor = '', + ignoreDisabledStyle = false, + } = props; + return ( + { + if (disabled) { + return; + } + onChange(option.value); + }}> + + {checked && ( + + + + )} + + {option.label} + + ); +} + +const style = StyleSheet.create({ + optionsContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + padding: 12, + }, + disabledContainer: { + opacity: 0.5, + }, + radioCircle: { + height: 22, + width: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: $config.FONT_COLOR, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + disabledCircle: { + borderColor: $config.FONT_COLOR + hexadecimalTransparency['50%'], + }, + radioFilled: { + height: 22, + width: 22, + borderRadius: 12, + backgroundColor: $config.FONT_COLOR, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + optionText: { + color: $config.FONT_COLOR, + fontSize: ThemeConfig.FontSize.normal, + fontFamily: ThemeConfig.FontFamily.sansPro, + fontWeight: '400', + lineHeight: 24, + marginLeft: 10, + }, +});