mirror of https://github.com/jitsi/jitsi-meet
feat(polls) Ability to create polls inside Jitsi (#9166)
* feat(polls) Added boilerplate code for polls feature * feat(polls) Implemented simple poll creation and answer modals in web app feat(polls) Added button to create a poll in toolbar feat(polls) Added Modal to answer an incoming poll feat(polls) Implemented basic client-side sending and reception of polls feat(polls): linked Poll creation to poll answering fix(polls) Linted code feat(polls.create) Added fields for question and answers (#3) * feat(polls.create) Added fields for question and answers + keyboard navigation * feat(polls.create) Minor changes, added some comments feat(PollAnswer Component): Component to display modal to answer poll #1 (#2) * fix(polls) removing necessity of current_poll_id variable * fix(polls) linting, polls are now updated when an answer is sent * feat(polls answer) added translation * fix(polls answer) remove extra comments, fixed typo * improvement (polls answer) use useSelector instead of mapStateToProps. cleaner code * fix (polls create) renamed sender to senderId * fix (polls answer) turned arrow function into useCallBack feat(PollResults Component): Component to display poll results (#1) * feat(PollResults Component): fist version of the component * feat(detailed votes): Display the detailed results of a poll * feat(Poll results): Use display name instead of ids in detailed results mode * fix(Poll): change title to question * fix(Poll type): import Poll type from types.js * fix(Poll): change title to question * fix(Poll): get participants out of the map * fix(Poll): replace filter with find feat(polls.create) Added "+" and "x" buttons in poll creation form + improved keyboard navigation a bit feat (polls) Answer modal now display results in real time after validation or skip feat(polls.create) Minor improvements to poll creation form feat(poll result) Added default message when trying to display no answer fix (polls) result windows is now small by default fix (polls) sanitizes imports to allow startup on react native * feat(polls.native) Implemented native toolbar button & poll create modal feat( poll native) added poll creation button in native toolbar improvement(polls) only one file used for PollCreateButton feat (polls native) added an example dialog feat (polls native) added possibility to create and delete options in poll creation improvement (polls) better styling for PollCreateDialog * feat(polls) Added ability to drag&drop answers in web poll creation form * feat(polls) Added native poll answer modal + chat integration, refactored components Merge branch 'polls-native' of https://github.com/jade-guiton/jitsi-meet into polls-native improvement (poll) Better styling for poll answer, now uses icons feat(poll.PollResults): Add native version of PollResults feat(poll.PollResults): Post results in chat in Native fix(poll.PollResults): Fix linter error in ChatMessage feat(polls.native) Improved styling for native poll answer dialog (required some internal changes) * fix(polls) Heavily refactored and added bars to poll results, other minor changes fix(poll.create): Move title to Dialog title feat(poll.create) Minor changes to poll creation / answer dialogs fix(poll.create) Refactored and improved translations feat(poll) Improved CSS for modals in web version fix(poll.pollcreate): Fix button size in native fix(polls) Refactored poll results component and other minor changes fix (polls) remove double import refactor(poll) Heavily refactored poll results (native + web) feat(polls.results) Added percentage bars and vote counts in web poll results, minor changes to mobile poll results * fix(polls) Fixes and linting fix(polls) Reformatted and fixed some linter and Flow errors fix(polls.results) Fixed voter list border appearing with 0 voters * feat(polls): Add modal with detailed votes that can be open from the result summary in the chat * fix(polls) Fixes, refactorings, and minor design changes feat(polls.results): Refactored poll chat message and improved design in web app feat(polls.results) Same as last commit, but for mobile version refactor(polls.results) Refactored PollResultsMessage and removed unnecessary prop in PollResults fix(polls.results) Fixed all remaining linter and Flow errors improvement(polls) removed console logs, added comments fix (polls) linting fix(polls.results) Fixed bug with poll chat message displaying the wrong name feat(polls.results) Minor improvement on poll results display (web) fix(poll.results): Use getParticipantDisplayName to get participant name and avoid empty string as name * Feat(poll.results): Remember voters names to display after they left the conference (#10) * feat(poll.results): Add the sender name in Poll object to remember names if participants leave the conference. Names are also updated if changed * refactor(poll.results): Refactor the memorization of the names of voters to use the same logic as in the chat * refactor(poll.results): use Map instead of Array.From( * refactor(poll.answer): change the way names are stored in poll answers to persist if participant left the call * Update react/features/polls/components/AbstractPollAnswerDialog.js * Update react/features/polls/components/AbstractPollCreateDialog.js * refactor(poll.answer): use voterName instead of senderName to avoid confusion with senderId the id of the sender of the poll * improvement(polls) Simplified poll answer voter name logic Co-authored-by: Fabien Zucchet <fabien.zucchet@student-cs.fr> Co-authored-by: Jade Guiton <guiton.jade@gmail.com> * fix(poll.native): Fix UI overflow when asking long questions & long options in the mobile app (#11) Co-authored-by: Fabien Zucchet <fabien.zucchet@student-cs.fr> * fix(polls) Fixed close button behavior in answer and results dialog (#12) * fix(polls) Fixed close button behavior in answer and results dialog * fix(polls) Fixed linter error * fix(polls) Added a poll queue to avoid overwriting open modals (#13) * fix(polls) Added a poll queue to avoid overwriting open modals * fix(polls) Updated documentation for action RECEIVE_POLL * Refactor(poll.chatresults): Add message in chat with hidden results until the participant has answered (#14) * refactor(poll.chat): Display poll results in chat when the poll is created instead of when the participant has ansered * refactor(poll.chat): Hide results until the participant has answered, skipped or canceled a responde to the poll * Use getParticipantDisplayName instead of only getStore() * Hide results also in native * fix(polls) Fixed previous merge Co-authored-by: Fabien Zucchet <fabien.zucchet@student-cs.fr> Co-authored-by: Jade Guiton <jade.guiton@centralesupelec.fr> * minor improvements (polls) refactor (polls) uniformized string for command names refactor (polls) changed pollId type to number everywhere * feat(polls) Added persistence to polls using sendMessage instead of sendCommandOnce (#16) * feat(polls) Using sendMessage instead of sendCommandOnce, switched poll IDs to string, and ability to receive old polls from backend * improvement(polls) Linted everything, fixed Flow errors, and added Prosody plugin for polls * improvement(polls) Historic polls are now displayed in chronological order * (polls) Minor improvements (#17) * renaming (polls) Renaming senderId -> voterID for voters * improvement (polls) sender's name is now provided with poll * comments (polls) updated comments for senderName types * fix(polls) Finished merging with json-messages feature * fix(polls) Fixed incorrect json-message sent with 0 polls Co-authored-by: Jade Guiton <guiton.jade@gmail.com> * Move polls to tab (#23) * Draft(polls): Move polls to polls-pane ; first version for web * Draft(polls): Move polls to polls-pane ; clean styled.js and remove Participant objects * fix missing newline at the end of file * Change behaviour to allow answer poll later * Fix(polls): change pollId type from number to string for consistency * feat(polls-pane): Ability to answer to a poll in polls-pane * feat(polls-pane): Ability to create to a poll in polls-pane * feat (polls.pane) display a notification when a new poll arrives * refactor(polls-pane): Update CSS to have a design closer to the mockups * fix(poll.vote count): Fix votes counting when computing percentage * fix(poll.vote count): Fix votes counting when computing percentage * refresh fork with jitsi/jitsi-meet * design (polls) Better look for poll creation * refactor(polls pane): Move polls-pane as a chat tab * Remove the first version of the polls-pane and the button to open it * Fix notifications and typo * Translate new polls tab in chat * Change polls_pane to polls-pane * Remove unless functions * Remove usage of styled.js * Improve responsiveness * Separate web and native logic * Remove Create a Poll button in web toolbox * improvement (polls) added auto scrolling to bottom when a new poll arrives * Add tabs to swicth between polls and chat in native * Add AbstractPollsPane * Add AbstractPollCreate * Add AbstractPollAnswer * Add PollAnswer, PollItem and PollList for native * Add PollCreate for native * Remove dialogs in web and native * Remove dialog queue * Remove useless files * Move _polls.scss outside dialog folder * Add possibility to skip answer * Add (useless for now) see details link * Add possibility to show detailed results for a poll * Resize progress bar to make details display * refactor, design (polls) better style to native design chat * fix (polls) Removed unecessary files * translate (polls) added french translation to empty polls * design fix (polls.native) 'show details' now correctly switch between progress bar and voters mode * Change See detailed results for Show details and add cursor: pointer * Fix progress bars not aligned with text * fix (polls.native) added autoselection of newly created option * Remove poll answer * improvement(polls.create) Improved web poll creation form marginally * improvement(polls.change) Simplified answer removal by reusing poll-answer command * fix linter * Fix(translation): update translation Co-authored-by: Fabien Zucchet <fabien.zucchet@student-cs.fr> Co-authored-by: spineki <marras.antoine@gmail.com> Co-authored-by: Fabien Zucchet <fabien.zucchet@viarezo.fr> * Merge pull request #22 from jade-guiton/polls-with-notification feat (polls) chat notification badge now display the sum of unread messages and unread polls fix(translation): Fix missing translation Fix flow error * Cleaned up, fixed, and uniformized translations * Small improvements to PollAnswer and PollResult + Much refactoring Specifically: - "Change vote" button now says "Vote" if voting was skipped - Clicking on "Change vote" resets the voting form to the last submitted answers instead of a blank slate - The "answered" field of Polls was replaced by "showResults" and "lastVote" - The "setAnsweredStatus" action was replaced by "registerVote" and "retractVote" - Some newly unreachable/useless code was removed - "showDetails" state is now handled by AbstractPollResults instead of PollItem * fix(polls tab): change tab underline color to #525252 * fix(poll create): Enforce at least two options to create a poll * fix(poll create): change 'remove option' color to #E04757 * fix(poll create): Update Poll create CSS to adapt to design * fix(poll answer): Adapt CSS to make poll answer closer to mockup * fix(poll result): Udpdate poll result CSS to match mockups * fix(poll result): Udpdate poll result CSS to match mockups * fix(poll create): Display 'remove option' only when there is at least 3 options * fix(polls button): Add hover, active, focus and disabled state to polls buttons * Last improvements for web * Native design fixes * Fix rebase issue in land/main.json * Fix french translation after rebase * Fixmobile behaviour * Fixed keyboard navigation in web poll creation form * Fixed Flow error related to "no polls" icon in PollsList * fix(polls): Enabled polls Prosody module in Debian config files * doc(polls) Added comments to the Prosody module code * fix(polls): Switched from using an internal LJM event to ones from the public API * Capitalize I of setIsPollsTabFocused * extract the 2 button modes into a const * remove extra new lines * Rename CLOSE_POLL_TAB for POLL_TAB_CLOSED for clarity * Rename answers2 for answersParsed for clarity * use switch instead of if/else chain * improve syntax for localId fetching * Refactor: Use BUTTON_MODE.CONTAINED variable instead of 'contained' * Disable send poll button if not enough data is provided in the form (#30) * Feat: Add notification badge on chat and poll tabs (#31) * Feat: Add notification badge on chat and poll tabs * Add badge equivalent for native * Update displayNameForm text to mention polls (#34) * Disable polls UI with a config in config.js (#33) * Change remove option text color from red to grey (#32) Co-authored-by: spineki <marras.antoine@gmail.com> Co-authored-by: Fabien Zucchet <fabien.zucchet@student-cs.fr> Co-authored-by: Fabien Zucchet <80532941+fabienzucchet@users.noreply.github.com> Co-authored-by: Fabien Zucchet <fabien.zucchet@viarezo.fr>pull/9723/head jitsi-meet_6180
parent
c123ff9e15
commit
8c82c0f56e
@ -0,0 +1,448 @@ |
||||
.poll-dialog { |
||||
font-size: 1rem; |
||||
|
||||
h1, span, li, strong { |
||||
color: #bce; |
||||
} |
||||
ol { |
||||
margin: 0; |
||||
} |
||||
} |
||||
|
||||
.poll-question-field { |
||||
padding: 8px 16px; |
||||
padding-bottom: 24px; |
||||
border-bottom: 1px solid #525252; |
||||
} |
||||
|
||||
.poll-header { |
||||
padding: 8px 16px; |
||||
} |
||||
|
||||
.poll-answer-container{ |
||||
padding: 8px; |
||||
background: #3D3D3D; |
||||
border-radius: 3px; |
||||
margin-bottom: 8px; |
||||
} |
||||
|
||||
.poll-answer-field-list, .poll-answer-list, .poll-result-list { |
||||
list-style-type: none; |
||||
padding: 0 16px; |
||||
margin: 0; |
||||
} |
||||
|
||||
ol.poll-result-list { |
||||
margin-bottom: 1.5em; |
||||
} |
||||
|
||||
.poll-result-list > li { |
||||
margin-bottom: 8px; |
||||
} |
||||
|
||||
.poll-answer-field { |
||||
flex-direction: column; |
||||
align-items: stretch; |
||||
margin-bottom: 16; |
||||
|
||||
} |
||||
|
||||
.poll-answer-field:last-child { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.poll-create-option-row { |
||||
display: 'flex'; |
||||
margin-bottom: 4; |
||||
} |
||||
|
||||
// Needeed to override atlaskit default blue color |
||||
.poll-create-container .jsYMHu { |
||||
background: #292929; |
||||
border-color: #808090; |
||||
color: white // #808090 |
||||
} |
||||
|
||||
.poll-add-button { |
||||
display: flex; |
||||
justify-content: center; |
||||
padding: 8px 16px; |
||||
} |
||||
|
||||
.poll-remove-option-button { |
||||
background: 0 0; |
||||
border: none; |
||||
color: #8B8B8B; |
||||
padding-left: 0; |
||||
} |
||||
|
||||
.poll-create-add-option { |
||||
border: none; |
||||
background-color: #292929; |
||||
padding: 3px; |
||||
width: 100%; |
||||
} |
||||
|
||||
.poll-icon-button, .poll-drag-handle { |
||||
.jitsi-icon svg { |
||||
fill: #bce; |
||||
} |
||||
} |
||||
|
||||
.poll-drag-handle { |
||||
background-color: transparent; |
||||
border: none; |
||||
cursor: grab; |
||||
padding-left: 8; |
||||
display: flex; |
||||
} |
||||
|
||||
.poll-dragged { |
||||
opacity: 0.5; |
||||
* { |
||||
cursor: grabbing !important; |
||||
} |
||||
} |
||||
|
||||
.poll-question { |
||||
font-size: 1.2em; |
||||
font-weight: 600; |
||||
margin-bottom: 0.5em; |
||||
} |
||||
|
||||
.poll-answer-voters { |
||||
font-size: 1em; |
||||
font-weight: lighter; |
||||
list-style-type: none; |
||||
border: #616161 solid 1px; |
||||
border-radius: 3px; |
||||
padding: 2px 6px; |
||||
margin: 4px 0px 12px; |
||||
background-color: #616161; |
||||
} |
||||
|
||||
.poll-answer-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
} |
||||
|
||||
.poll-answer-vote-name { |
||||
flex-shrink: 1; |
||||
overflow-wrap: anywhere |
||||
} |
||||
|
||||
.poll-answer-vote-count-container{ |
||||
display: flex; |
||||
} |
||||
|
||||
.poll-answer-vote-count { |
||||
margin-left: 10px; |
||||
white-space: nowrap; |
||||
flex: 1; |
||||
text-align: right; |
||||
} |
||||
|
||||
.poll-answer-short-results{ |
||||
display: flex; |
||||
min-width: 10em; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.poll-bar-container, .poll-bar { |
||||
border-radius: 3px; |
||||
height: 6px; |
||||
} |
||||
|
||||
.poll-bar-container { |
||||
background-color: #616161; |
||||
max-width: 160px; |
||||
margin-top: 3px; |
||||
flex: 1; |
||||
} |
||||
|
||||
.poll-bar { |
||||
background-color: #246FE5; |
||||
} |
||||
|
||||
.poll-message-footer { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
font-size: 12px; |
||||
margin-top: 5px; |
||||
} |
||||
|
||||
.poll-notice { |
||||
font-weight: 100; |
||||
margin-right: 10px; |
||||
} |
||||
|
||||
.poll-show-details { |
||||
background-color: transparent; |
||||
border: none; |
||||
|
||||
&:hover { |
||||
text-decoration: underline; |
||||
} |
||||
} |
||||
|
||||
.poll-result-links { |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
} |
||||
|
||||
a.poll-detail-link, a.poll-change-vote-link { |
||||
color: #246FE5; |
||||
cursor: pointer; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.polls-pane-content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
font-weight: 600; |
||||
height: 85%; |
||||
align-items: stretch; |
||||
} |
||||
|
||||
.pane-content{ |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: center; |
||||
align-items: center; |
||||
width: 100%; |
||||
height: 100%; |
||||
} |
||||
|
||||
.empty-pane-icon { |
||||
width: 50%; |
||||
padding: 24px; |
||||
} |
||||
|
||||
.empty-pane-icon svg { |
||||
fill: #3D3D3D; |
||||
width: 100%; |
||||
height: auto; |
||||
} |
||||
|
||||
.empty-pane-message { |
||||
text-align: center; |
||||
} |
||||
|
||||
.poll-results { |
||||
color: white; |
||||
} |
||||
|
||||
.poll-answer { |
||||
h1, strong ,span { |
||||
color: white; |
||||
} |
||||
} |
||||
|
||||
.poll-results, .poll-answer { |
||||
margin-bottom: 16px; |
||||
background: #292929; |
||||
border-radius: 8px; |
||||
padding: 12px 8px; |
||||
border-width: thin; |
||||
border-style: solid; |
||||
border-color: #616161; |
||||
} |
||||
|
||||
.poll-create-label { |
||||
color: white; |
||||
margin-bottom: 4; |
||||
display: flex; |
||||
} |
||||
|
||||
.expandable-input{ |
||||
resize: none; |
||||
width: 100%; |
||||
height: 40px; |
||||
box-sizing: border-box; |
||||
overflow: hidden; |
||||
border: 1px solid #666666; |
||||
background-color: #141414; |
||||
color: #FFF; |
||||
border-radius: 6px; |
||||
padding: 10px 16px; |
||||
} |
||||
|
||||
.poll-container { |
||||
box-sizing: border-box; |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
position: relative; |
||||
padding: 16px; |
||||
|
||||
& > * + *:not(.ignore-child) { |
||||
margin-top: 16px; |
||||
} |
||||
|
||||
&::-webkit-scrollbar { |
||||
display: none; |
||||
} |
||||
} |
||||
|
||||
.poll-create-header { |
||||
font-size: 20px; |
||||
margin: 20px 16px; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.poll-create-container { |
||||
padding: 8px 0; |
||||
} |
||||
|
||||
.poll-footer { |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
padding: 8px 16px; |
||||
height: 40px; |
||||
align-items: stretch; |
||||
|
||||
& > *:not(:last-child) { |
||||
margin-right: 16px; |
||||
} |
||||
} |
||||
|
||||
.poll-primary-button { |
||||
align-items: center; |
||||
background-color: #0056E0; |
||||
border: 0; |
||||
border-radius: 6px; |
||||
display: flex; |
||||
font-weight: unset; |
||||
justify-content: center; |
||||
font-size: 15px; |
||||
flex: 1; |
||||
|
||||
&:hover { |
||||
background-color: #246FE5; |
||||
} |
||||
|
||||
&:active { |
||||
background-color: #0045B3; |
||||
} |
||||
|
||||
&:focus { |
||||
background-color: #0045B3; |
||||
border: 3px solid #99BBF3; |
||||
} |
||||
|
||||
&:disabled { |
||||
background-color: #00225A; |
||||
color: #858585; |
||||
} |
||||
|
||||
& > *:not(:last-child) { |
||||
margin-right: 8px; |
||||
} |
||||
} |
||||
|
||||
.poll-secondary-button { |
||||
align-items: center; |
||||
background-color: #3D3D3D; |
||||
border: 0; |
||||
border-radius: 6px; |
||||
display: flex; |
||||
font-weight: unset; |
||||
justify-content: center; |
||||
font-size: 15px; |
||||
height: 40px; |
||||
width: 100%; |
||||
|
||||
&:hover { |
||||
background-color: #525252; |
||||
} |
||||
|
||||
&:active { |
||||
background-color: #292929; |
||||
} |
||||
|
||||
&:focus { |
||||
background-color: #292929; |
||||
border: 3px solid #858585; |
||||
} |
||||
|
||||
&:disabled { |
||||
background-color: #141414; |
||||
color: #858585; |
||||
} |
||||
|
||||
& > *:not(:last-child) { |
||||
margin-right: 8px; |
||||
} |
||||
} |
||||
|
||||
.poll-small-primary-button { |
||||
align-items: center; |
||||
background-color: #0056E0; |
||||
border: 0; |
||||
border-radius: 6px; |
||||
display: flex; |
||||
font-weight: unset; |
||||
justify-content: center; |
||||
font-size: 15px; |
||||
height: 40px; |
||||
width: 50%; |
||||
|
||||
&:hover { |
||||
background-color: #246FE5; |
||||
} |
||||
|
||||
&:active { |
||||
background-color: #0045B3; |
||||
} |
||||
|
||||
&:focus { |
||||
background-color: #0045B3; |
||||
border: 3px solid #99BBF3; |
||||
} |
||||
|
||||
&:disabled { |
||||
background-color: #00225A; |
||||
color: #858585; |
||||
} |
||||
|
||||
& > *:not(:last-child) { |
||||
margin-right: 8px; |
||||
} |
||||
} |
||||
|
||||
.poll-small-secondary-button { |
||||
align-items: center; |
||||
background-color: #3D3D3D; |
||||
border: 0; |
||||
border-radius: 6px; |
||||
display: flex; |
||||
font-weight: unset; |
||||
justify-content: center; |
||||
font-size: 15px; |
||||
height: 40px; |
||||
width: 50%; |
||||
|
||||
&:hover { |
||||
background-color: #525252; |
||||
} |
||||
|
||||
&:active { |
||||
background-color: #292929; |
||||
} |
||||
|
||||
&:focus { |
||||
background-color: #292929; |
||||
border: 3px solid #858585; |
||||
} |
||||
|
||||
&:disabled { |
||||
background-color: #141414; |
||||
color: #858585; |
||||
} |
||||
|
||||
& > *:not(:last-child) { |
||||
margin-right: 8px; |
||||
} |
||||
} |
@ -0,0 +1,55 @@ |
||||
// @flow
|
||||
|
||||
/** |
||||
* The type of the action which signals that a new Poll was received. |
||||
* |
||||
* { |
||||
* type: RECEIVE_POLL, |
||||
* poll: Poll, |
||||
* pollId: string, |
||||
* notify: boolean |
||||
* } |
||||
* |
||||
*/ |
||||
export const RECEIVE_POLL = 'RECEIVE_POLL'; |
||||
|
||||
/** |
||||
* The type of the action which signals that a new Answer was received. |
||||
* |
||||
* { |
||||
* type: RECEIVE_ANSWER, |
||||
* answer: Answer, |
||||
* pollId: string, |
||||
* } |
||||
*/ |
||||
export const RECEIVE_ANSWER = 'RECEIVE_ANSWER'; |
||||
|
||||
/** |
||||
* The type of the action which registers a vote. |
||||
* |
||||
* { |
||||
* type: REGISTER_VOTE, |
||||
* answers: Array<boolean> | null, |
||||
* pollId: string |
||||
* } |
||||
*/ |
||||
export const REGISTER_VOTE = 'REGISTER_VOTE'; |
||||
|
||||
/** |
||||
* The type of the action which retracts a vote. |
||||
* |
||||
* { |
||||
* type: RETRACT_VOTE, |
||||
* pollId: string, |
||||
* } |
||||
*/ |
||||
export const RETRACT_VOTE = 'RETRACT_VOTE'; |
||||
|
||||
/** |
||||
* The type of the action triggered when the poll tab in chat pane is closed |
||||
* |
||||
* { |
||||
* type: RESET_NB_UNREAD_POLLS, |
||||
* } |
||||
*/ |
||||
export const RESET_NB_UNREAD_POLLS = 'RESET_NB_UNREAD_POLLS'; |
@ -0,0 +1,99 @@ |
||||
// @flow
|
||||
|
||||
import { |
||||
RESET_NB_UNREAD_POLLS, |
||||
RECEIVE_ANSWER, |
||||
RECEIVE_POLL, |
||||
REGISTER_VOTE, |
||||
RETRACT_VOTE |
||||
} from './actionTypes'; |
||||
import type { Answer, Poll } from './types'; |
||||
|
||||
/** |
||||
* Action to signal that a new poll was received. |
||||
* |
||||
* @param {string} pollId - The id of the incoming poll. |
||||
* @param {Poll} poll - The incoming Poll object. |
||||
* @param {boolean} notify - Whether to send or not a notification. |
||||
* @returns {{ |
||||
* type: RECEIVE_POLL, |
||||
* poll: Poll, |
||||
* pollId: string, |
||||
* notify: boolean |
||||
* }} |
||||
*/ |
||||
export const receivePoll = (pollId: string, poll: Poll, notify: boolean) => { |
||||
return { |
||||
type: RECEIVE_POLL, |
||||
poll, |
||||
pollId, |
||||
notify |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Action to signal that a new answer was received. |
||||
* |
||||
* @param {string} pollId - The id of the incoming poll. |
||||
* @param {Answer} answer - The incoming Answer object. |
||||
* @returns {{ |
||||
* type: RECEIVE_ANSWER, |
||||
* answer: Answer, |
||||
* pollId: string |
||||
* }} |
||||
*/ |
||||
export const receiveAnswer = (pollId: string, answer: Answer) => { |
||||
return { |
||||
type: RECEIVE_ANSWER, |
||||
answer, |
||||
pollId |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Action to register a vote on a poll. |
||||
* |
||||
* @param {string} pollId - The id of the poll. |
||||
* @param {?Array<boolean>} answers - The new answers. |
||||
* @returns {{ |
||||
* type: REGISTER_VOTE, |
||||
* answers: ?Array<boolean>, |
||||
* pollId: string |
||||
* }} |
||||
*/ |
||||
export const registerVote = (pollId: string, answers: Array<boolean> | null) => { |
||||
return { |
||||
type: REGISTER_VOTE, |
||||
answers, |
||||
pollId |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Action to retract a vote on a poll. |
||||
* |
||||
* @param {string} pollId - The id of the poll. |
||||
* @returns {{ |
||||
* type: RETRACT_VOTE, |
||||
* pollId: string |
||||
* }} |
||||
*/ |
||||
export const retractVote = (pollId: string) => { |
||||
return { |
||||
type: RETRACT_VOTE, |
||||
pollId |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Action to signal the closing of the polls tab. |
||||
* |
||||
* @returns {{ |
||||
* type: POLL_TAB_CLOSED |
||||
* }} |
||||
*/ |
||||
export function resetNbUnreadPollsMessages() { |
||||
return { |
||||
type: RESET_NB_UNREAD_POLLS |
||||
}; |
||||
} |
@ -0,0 +1,101 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useState } from 'react'; |
||||
import type { AbstractComponent } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
|
||||
import { getLocalParticipant, getParticipantById } from '../../base/participants'; |
||||
import { registerVote } from '../actions'; |
||||
import { COMMAND_ANSWER_POLL } from '../constants'; |
||||
import type { Poll } from '../types'; |
||||
|
||||
/** |
||||
* The type of the React {@code Component} props of inheriting component. |
||||
*/ |
||||
type InputProps = { |
||||
pollId: string, |
||||
}; |
||||
|
||||
/* |
||||
* Props that will be passed by the AbstractPollAnswer to its |
||||
* concrete implementations (web/native). |
||||
**/ |
||||
export type AbstractProps = { |
||||
checkBoxStates: Function, |
||||
poll: Poll, |
||||
setCheckbox: Function, |
||||
skipAnswer: Function, |
||||
submitAnswer: Function, |
||||
t: Function, |
||||
}; |
||||
|
||||
/** |
||||
* Higher Order Component taking in a concrete PollAnswer component and |
||||
* augmenting it with state/behavior common to both web and native implementations. |
||||
* |
||||
* @param {React.AbstractComponent} Component - The concrete component. |
||||
* @returns {React.AbstractComponent} |
||||
*/ |
||||
const AbstractPollAnswer = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => { |
||||
|
||||
const { pollId } = props; |
||||
|
||||
const conference: Object = useSelector(state => state['features/base/conference'].conference); |
||||
|
||||
const poll: Poll = useSelector(state => state['features/polls'].polls[pollId]); |
||||
|
||||
const { id: localId } = useSelector(getLocalParticipant); |
||||
|
||||
const [ checkBoxStates, setCheckBoxState ] = useState(() => { |
||||
if (poll.lastVote !== null) { |
||||
return [ ...poll.lastVote ]; |
||||
} |
||||
|
||||
return new Array(poll.answers.length).fill(false); |
||||
}); |
||||
|
||||
const setCheckbox = useCallback((index, state) => { |
||||
const newCheckBoxStates = [ ...checkBoxStates ]; |
||||
|
||||
newCheckBoxStates[index] = state; |
||||
setCheckBoxState(newCheckBoxStates); |
||||
}, [ checkBoxStates ]); |
||||
|
||||
const dispatch = useDispatch(); |
||||
|
||||
const localParticipant = useSelector(state => getParticipantById(state, localId)); |
||||
const localName: string = localParticipant.name ? localParticipant.name : 'Fellow Jitster'; |
||||
|
||||
const submitAnswer = useCallback(() => { |
||||
conference.sendMessage({ |
||||
type: COMMAND_ANSWER_POLL, |
||||
pollId, |
||||
voterId: localId, |
||||
voterName: localName, |
||||
answers: checkBoxStates |
||||
}); |
||||
|
||||
dispatch(registerVote(pollId, checkBoxStates)); |
||||
|
||||
return false; |
||||
}, [ pollId, localId, localName, checkBoxStates, conference ]); |
||||
|
||||
const skipAnswer = useCallback(() => { |
||||
dispatch(registerVote(pollId, null)); |
||||
|
||||
}, [ pollId ]); |
||||
|
||||
const { t } = useTranslation(); |
||||
|
||||
return (<Component |
||||
checkBoxStates = { checkBoxStates } |
||||
poll = { poll } |
||||
setCheckbox = { setCheckbox } |
||||
skipAnswer = { skipAnswer } |
||||
submitAnswer = { submitAnswer } |
||||
t = { t } />); |
||||
|
||||
}; |
||||
|
||||
export default AbstractPollAnswer; |
@ -0,0 +1,135 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useState } from 'react'; |
||||
import type { AbstractComponent } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
import { useSelector } from 'react-redux'; |
||||
|
||||
import { getParticipantDisplayName } from '../../base/participants'; |
||||
import { COMMAND_NEW_POLL } from '../constants'; |
||||
|
||||
/** |
||||
* The type of the React {@code Component} props of inheriting component. |
||||
*/ |
||||
type InputProps = { |
||||
setCreateMode: boolean => void, |
||||
}; |
||||
|
||||
/* |
||||
* Props that will be passed by the AbstractPollCreate to its |
||||
* concrete implementations (web/native). |
||||
**/ |
||||
export type AbstractProps = InputProps & { |
||||
answers: Array<string>, |
||||
question: string, |
||||
setQuestion: string => void, |
||||
setAnswer: (number, string) => void, |
||||
addAnswer: ?number => void, |
||||
moveAnswer: (number, number) => void, |
||||
removeAnswer: number => void, |
||||
onSubmit: Function, |
||||
isSubmitDisabled: boolean, |
||||
t: Function, |
||||
}; |
||||
|
||||
/** |
||||
* Higher Order Component taking in a concrete PollCreate component and |
||||
* augmenting it with state/behavior common to both web and native implementations. |
||||
* |
||||
* @param {React.AbstractComponent} Component - The concrete component. |
||||
* @returns {React.AbstractComponent} |
||||
*/ |
||||
const AbstractPollCreate = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => { |
||||
|
||||
const { setCreateMode } = props; |
||||
|
||||
const [ question, setQuestion ] = useState(''); |
||||
|
||||
const [ answers, setAnswers ] = useState([ '', '' ]); |
||||
|
||||
const setAnswer = useCallback((i, answer) => { |
||||
const newAnswers = [ ...answers ]; |
||||
|
||||
newAnswers[i] = answer; |
||||
setAnswers(newAnswers); |
||||
}); |
||||
|
||||
const addAnswer = useCallback((i: ?number) => { |
||||
|
||||
const newAnswers = [ ...answers ]; |
||||
|
||||
newAnswers.splice(typeof i === 'number' ? i : answers.length, 0, ''); |
||||
setAnswers(newAnswers); |
||||
}); |
||||
|
||||
const moveAnswer = useCallback((i, j) => { |
||||
const newAnswers = [ ...answers ]; |
||||
|
||||
const answer = answers[i]; |
||||
|
||||
newAnswers.splice(i, 1); |
||||
newAnswers.splice(j, 0, answer); |
||||
setAnswers(newAnswers); |
||||
}); |
||||
|
||||
const removeAnswer = useCallback(i => { |
||||
if (answers.length <= 2) { |
||||
return; |
||||
} |
||||
const newAnswers = [ ...answers ]; |
||||
|
||||
newAnswers.splice(i, 1); |
||||
setAnswers(newAnswers); |
||||
}); |
||||
|
||||
const conference = useSelector(state => state['features/base/conference'].conference); |
||||
const myId = conference.myUserId(); |
||||
const myName = useSelector(state => getParticipantDisplayName(state, myId)); |
||||
|
||||
const onSubmit = useCallback(ev => { |
||||
if (ev) { |
||||
ev.preventDefault(); |
||||
} |
||||
|
||||
const filteredAnswers = answers.filter(answer => answer.trim().length > 0); |
||||
|
||||
if (filteredAnswers.length < 2) { |
||||
return; |
||||
} |
||||
|
||||
conference.sendMessage({ |
||||
type: COMMAND_NEW_POLL, |
||||
pollId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36), |
||||
senderId: myId, |
||||
senderName: myName, |
||||
question, |
||||
answers: filteredAnswers |
||||
}); |
||||
|
||||
setCreateMode(false); |
||||
|
||||
}, [ conference, question, answers ]); |
||||
|
||||
// Check if the poll create form can be submitted i.e. if the send button should be disabled.
|
||||
const isSubmitDisabled |
||||
= question.trim().length <= 0 // If no question is provided
|
||||
|| answers.filter(answer => answer.trim().length > 0).length < 2; // If not enough options are provided
|
||||
|
||||
const { t } = useTranslation(); |
||||
|
||||
return (<Component |
||||
addAnswer = { addAnswer } |
||||
answers = { answers } |
||||
isSubmitDisabled = { isSubmitDisabled } |
||||
moveAnswer = { moveAnswer } |
||||
onSubmit = { onSubmit } |
||||
question = { question } |
||||
removeAnswer = { removeAnswer } |
||||
setAnswer = { setAnswer } |
||||
setCreateMode = { setCreateMode } |
||||
setQuestion = { setQuestion } |
||||
t = { t } />); |
||||
|
||||
}; |
||||
|
||||
export default AbstractPollCreate; |
@ -0,0 +1,124 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react'; |
||||
import type { AbstractComponent } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
|
||||
import { getLocalParticipant, getParticipantById } from '../../base/participants/functions'; |
||||
import { retractVote } from '../actions'; |
||||
import { COMMAND_ANSWER_POLL } from '../constants'; |
||||
|
||||
/** |
||||
* The type of the React {@code Component} props of inheriting component. |
||||
*/ |
||||
type InputProps = { |
||||
|
||||
/** |
||||
* ID of the poll to display |
||||
*/ |
||||
pollId: string, |
||||
}; |
||||
|
||||
export type AnswerInfo = { |
||||
name: string, |
||||
percentage: number, |
||||
voters?: Array<{ id: number, name: string }>, |
||||
voterCount: number |
||||
}; |
||||
|
||||
/** |
||||
* The type of the React {@code Component} props of {@link AbstractPollResults}. |
||||
*/ |
||||
export type AbstractProps = { |
||||
answers: Array<AnswerInfo>, |
||||
changeVote: Function, |
||||
showDetails: boolean, |
||||
question: string, |
||||
t: Function, |
||||
toggleIsDetailed: Function, |
||||
haveVoted: boolean, |
||||
}; |
||||
|
||||
/** |
||||
* Higher Order Component taking in a concrete PollResult component and |
||||
* augmenting it with state/behavior common to both web and native implementations. |
||||
* |
||||
* @param {React.AbstractComponent} Component - The concrete component. |
||||
* @returns {React.AbstractComponent} |
||||
*/ |
||||
const AbstractPollResults = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => { |
||||
const { pollId } = props; |
||||
|
||||
const pollDetails = useSelector(state => state['features/polls'].polls[pollId]); |
||||
|
||||
const [ showDetails, setShowDetails ] = useState(false); |
||||
const toggleIsDetailed = useCallback(() => { |
||||
setShowDetails(!showDetails); |
||||
}); |
||||
|
||||
const answers: Array<AnswerInfo> = useMemo(() => { |
||||
const voterSet = new Set(); |
||||
|
||||
// Getting every voters ID that participates to the poll
|
||||
for (const answer of pollDetails.answers) { |
||||
for (const [ voterId ] of answer.voters) { |
||||
voterSet.add(voterId); |
||||
} |
||||
} |
||||
|
||||
const totalVoters = voterSet.size; |
||||
|
||||
return pollDetails.answers.map(answer => { |
||||
const percentage = totalVoters === 0 ? 0 : Math.round(answer.voters.size / totalVoters * 100); |
||||
|
||||
let voters = null; |
||||
|
||||
if (showDetails) { |
||||
voters = [ ...answer.voters ].map(([ id, name ]) => { |
||||
return { |
||||
id, |
||||
name |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
return { |
||||
name: answer.name, |
||||
percentage, |
||||
voters, |
||||
voterCount: answer.voters.size |
||||
}; |
||||
}); |
||||
}, [ pollDetails.answers, showDetails ]); |
||||
|
||||
const dispatch = useDispatch(); |
||||
|
||||
const conference: Object = useSelector(state => state['features/base/conference'].conference); |
||||
const localId = useSelector(state => getLocalParticipant(state).id); |
||||
const localParticipant = useSelector(state => getParticipantById(state, localId)); |
||||
const localName: string = localParticipant ? localParticipant.name : 'Fellow Jitster'; |
||||
const changeVote = useCallback(() => { |
||||
conference.sendMessage({ |
||||
type: COMMAND_ANSWER_POLL, |
||||
pollId, |
||||
voterId: localId, |
||||
voterName: localName, |
||||
answers: new Array(pollDetails.answers.length).fill(false) |
||||
}); |
||||
dispatch(retractVote(pollId)); |
||||
}, [ pollId, localId, localName, pollDetails ]); |
||||
|
||||
const { t } = useTranslation(); |
||||
|
||||
return (<Component |
||||
answers = { answers } |
||||
changeVote = { changeVote } |
||||
haveVoted = { pollDetails.lastVote !== null } |
||||
question = { pollDetails.question } |
||||
showDetails = { showDetails } |
||||
t = { t } |
||||
toggleIsDetailed = { toggleIsDetailed } />); |
||||
}; |
||||
|
||||
export default AbstractPollResults; |
@ -0,0 +1,44 @@ |
||||
// @flow
|
||||
|
||||
import React, { useState } from 'react'; |
||||
import type { AbstractComponent } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
/* |
||||
* Props that will be passed by the AbstractPollsPane to its |
||||
* concrete implementations (web/native). |
||||
**/ |
||||
export type AbstractProps = { |
||||
createMode: boolean, |
||||
onCreate: void => void, |
||||
setCreateMode: boolean => void, |
||||
t: Function, |
||||
}; |
||||
|
||||
/** |
||||
* Higher Order Component taking in a concrete PollsPane component and |
||||
* augmenting it with state/behavior common to both web and native implementations. |
||||
* |
||||
* @param {React.AbstractComponent} Component - The concrete component. |
||||
* @returns {React.AbstractComponent} |
||||
*/ |
||||
const AbstractPollsPane = (Component: AbstractComponent<AbstractProps>) => () => { |
||||
|
||||
const [ createMode, setCreateMode ] = useState(false); |
||||
|
||||
const onCreate = () => { |
||||
setCreateMode(true); |
||||
}; |
||||
|
||||
const { t } = useTranslation(); |
||||
|
||||
return (<Component |
||||
createMode = { createMode } |
||||
/* eslint-disable react/jsx-no-bind */ |
||||
onCreate = { onCreate } |
||||
setCreateMode = { setCreateMode } |
||||
t = { t } />); |
||||
|
||||
}; |
||||
|
||||
export default AbstractPollsPane; |
@ -0,0 +1,3 @@ |
||||
// @flow
|
||||
|
||||
export * from './native'; |
@ -0,0 +1,3 @@ |
||||
// @flow
|
||||
|
||||
export * from './web'; |
@ -0,0 +1,3 @@ |
||||
// @flow
|
||||
|
||||
export * from './_'; |
@ -0,0 +1,69 @@ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
import { Switch, Text, View } from 'react-native'; |
||||
import { Button } from 'react-native-paper'; |
||||
|
||||
import { BUTTON_MODES } from '../../../chat/constants'; |
||||
import AbstractPollAnswer from '../AbstractPollAnswer'; |
||||
import type { AbstractProps } from '../AbstractPollAnswer'; |
||||
|
||||
import { chatStyles, dialogStyles } from './styles'; |
||||
|
||||
|
||||
const PollAnswer = (props: AbstractProps) => { |
||||
|
||||
const { |
||||
checkBoxStates, |
||||
poll, |
||||
setCheckbox, |
||||
skipAnswer, |
||||
submitAnswer, |
||||
t |
||||
} = props; |
||||
|
||||
return ( |
||||
<View> |
||||
<View> |
||||
<Text style = { dialogStyles.question } >{ poll.question }</Text> |
||||
</View> |
||||
<View style = { chatStyles.answerContent }> |
||||
{poll.answers.map((answer, index) => ( |
||||
<View |
||||
key = { index } |
||||
style = { chatStyles.switchRow } > |
||||
<Switch |
||||
/* eslint-disable react/jsx-no-bind */ |
||||
onValueChange = { state => setCheckbox(index, state) } |
||||
value = { checkBoxStates[index] } /> |
||||
<Text>{answer.name}</Text> |
||||
</View> |
||||
))} |
||||
</View> |
||||
<View style = { chatStyles.buttonRow }> |
||||
<Button |
||||
color = '#3D3D3D' |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { skipAnswer } |
||||
style = { chatStyles.pollCreateButton } > |
||||
{t('polls.answer.skip')} |
||||
</Button> |
||||
<Button |
||||
color = '#17a0db' |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { submitAnswer } |
||||
style = { chatStyles.pollCreateButton } > |
||||
{t('polls.answer.submit')} |
||||
</Button> |
||||
</View> |
||||
</View> |
||||
|
||||
); |
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollAnswer to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollAnswer(PollAnswer); |
@ -0,0 +1,185 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'; |
||||
import { View, TextInput, FlatList, TouchableOpacity } from 'react-native'; |
||||
import { Button } from 'react-native-paper'; |
||||
|
||||
import { Icon, IconClose } from '../../../base/icons'; |
||||
import { BUTTON_MODES } from '../../../chat/constants'; |
||||
import AbstractPollCreate from '../AbstractPollCreate'; |
||||
import type { AbstractProps } from '../AbstractPollCreate'; |
||||
|
||||
import { chatStyles, dialogStyles } from './styles'; |
||||
|
||||
const PollCreate = (props: AbstractProps) => { |
||||
|
||||
|
||||
const { |
||||
addAnswer, |
||||
answers, |
||||
isSubmitDisabled, |
||||
onSubmit, |
||||
question, |
||||
removeAnswer, |
||||
setAnswer, |
||||
setCreateMode, |
||||
setQuestion, |
||||
t |
||||
} = props; |
||||
|
||||
const answerListRef = useRef(null); |
||||
|
||||
/* |
||||
* This ref stores the Array of answer input fields, allowing us to focus on them. |
||||
* This array is maintained by registerfieldRef and the useEffect below. |
||||
*/ |
||||
const answerInputs = useRef([]); |
||||
const registerFieldRef = useCallback((i, input) => { |
||||
if (input === null) { |
||||
return; |
||||
} |
||||
answerInputs.current[i] = input; |
||||
}, |
||||
[ answerInputs ] |
||||
); |
||||
|
||||
useEffect(() => { |
||||
answerInputs.current = answerInputs.current.slice(0, answers.length); |
||||
}, [ answers ]); |
||||
|
||||
/* |
||||
* This state allows us to requestFocus asynchronously, without having to worry |
||||
* about whether a newly created input field has been rendered yet or not. |
||||
*/ |
||||
const [ lastFocus, requestFocus ] = useState(null); |
||||
|
||||
useEffect(() => { |
||||
if (lastFocus === null) { |
||||
return; |
||||
} |
||||
const input = answerInputs.current[lastFocus]; |
||||
|
||||
if (input === undefined) { |
||||
return; |
||||
} |
||||
input.focus(); |
||||
|
||||
}, [ answerInputs, lastFocus ]); |
||||
|
||||
|
||||
const onQuestionKeyDown = useCallback(() => { |
||||
answerInputs.current[0].focus(); |
||||
}); |
||||
|
||||
// Called on keypress in answer fields
|
||||
const onAnswerKeyDown = useCallback((index: number, ev) => { |
||||
const { key } = ev.nativeEvent; |
||||
const currentText = answers[index]; |
||||
|
||||
if (key === 'Backspace' && currentText === '' && answers.length > 1) { |
||||
removeAnswer(index); |
||||
requestFocus(index > 0 ? index - 1 : 0); |
||||
} |
||||
}, [ answers, addAnswer, removeAnswer, requestFocus ]); |
||||
|
||||
/* eslint-disable react/no-multi-comp */ |
||||
const createIconButton = (icon, onPress, style) => ( |
||||
<TouchableOpacity |
||||
activeOpacity = { 0.8 } |
||||
onPress = { onPress } |
||||
style = { [ dialogStyles.buttonContainer, style ] }> |
||||
<Icon |
||||
size = { 24 } |
||||
src = { icon } |
||||
style = { dialogStyles.icon } /> |
||||
</TouchableOpacity> |
||||
); |
||||
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */ |
||||
const renderListItem = ({ index }: { index: number }) => |
||||
|
||||
// padding to take into account the two default options
|
||||
( |
||||
<View |
||||
style = { dialogStyles.optionContainer }> |
||||
<TextInput |
||||
blurOnSubmit = { false } |
||||
multiline = { true } |
||||
onChangeText = { text => setAnswer(index, text) } |
||||
onKeyPress = { ev => onAnswerKeyDown(index, ev) } |
||||
placeholder = { t('polls.create.answerPlaceholder', { index: index + 1 }) } |
||||
ref = { input => registerFieldRef(index, input) } |
||||
style = { dialogStyles.field } |
||||
value = { answers[index] } /> |
||||
|
||||
{answers.length > 2 |
||||
&& createIconButton(IconClose, () => removeAnswer(index)) |
||||
} |
||||
</View> |
||||
); |
||||
|
||||
return ( |
||||
<View style = { chatStyles.pollCreateContainer }> |
||||
<View style = { chatStyles.pollCreateSubContainer }> |
||||
<TextInput |
||||
autoFocus = { true } |
||||
blurOnSubmit = { false } |
||||
multiline = { true } |
||||
onChangeText = { setQuestion } |
||||
onSubmitEditing = { onQuestionKeyDown } |
||||
placeholder = { t('polls.create.questionPlaceholder') } |
||||
style = { dialogStyles.question } |
||||
value = { question } /> |
||||
<FlatList |
||||
blurOnSubmit = { true } |
||||
data = { answers } |
||||
extraData = { answers } |
||||
keyExtractor = { (item, index) => index.toString() } |
||||
ref = { answerListRef } |
||||
renderItem = { renderListItem } /> |
||||
|
||||
<Button |
||||
color = '#3D3D3D' |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { () => { |
||||
// adding and answer
|
||||
addAnswer(); |
||||
requestFocus(answers.length); |
||||
} } |
||||
style = { chatStyles.pollCreateAddButton }> |
||||
{t('polls.create.addOption')} |
||||
</Button> |
||||
</View> |
||||
|
||||
<View |
||||
style = { chatStyles.buttonRow }> |
||||
|
||||
<Button |
||||
color = '#3D3D3D' |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { () => setCreateMode(false) } |
||||
style = { chatStyles.pollCreateButton } > |
||||
{t('polls.create.cancel')} |
||||
</Button> |
||||
|
||||
<Button |
||||
color = '#17a0db' |
||||
disabled = { isSubmitDisabled } |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { onSubmit } |
||||
style = { chatStyles.pollCreateButton } > |
||||
{t('polls.create.send')} |
||||
</Button> |
||||
</View> |
||||
</View> |
||||
); |
||||
|
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollCreate to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollCreate(PollCreate); |
@ -0,0 +1,40 @@ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
import { View } from 'react-native'; |
||||
import { useSelector } from 'react-redux'; |
||||
|
||||
import { shouldShowResults } from '../../functions'; |
||||
|
||||
import { chatStyles } from './styles'; |
||||
|
||||
import { PollAnswer, PollResults } from '.'; |
||||
|
||||
type Props = { |
||||
|
||||
/** |
||||
* Id of the poll |
||||
*/ |
||||
pollId: string, |
||||
|
||||
} |
||||
|
||||
const PollItem = ({ pollId }: Props) => { |
||||
const showResults = useSelector(state => shouldShowResults(state, pollId)); |
||||
|
||||
return ( |
||||
<View |
||||
style = { chatStyles.pollItemContainer }> |
||||
{ showResults |
||||
? <PollResults |
||||
key = { pollId } |
||||
pollId = { pollId } /> |
||||
: <PollAnswer |
||||
pollId = { pollId } /> |
||||
} |
||||
|
||||
</View> |
||||
); |
||||
}; |
||||
|
||||
export default PollItem; |
@ -0,0 +1,120 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react'; |
||||
import { View, Text, FlatList, TouchableOpacity } from 'react-native'; |
||||
|
||||
import AbstractPollResults from '../AbstractPollResults'; |
||||
import type { AbstractProps, AnswerInfo } from '../AbstractPollResults'; |
||||
|
||||
import { chatStyles, dialogStyles, resultsStyles } from './styles'; |
||||
|
||||
|
||||
/** |
||||
* Component that renders the poll results. |
||||
* |
||||
* @param {Props} props - The passed props. |
||||
* @returns {React.Node} |
||||
*/ |
||||
const PollResults = (props: AbstractProps) => { |
||||
const { |
||||
answers, |
||||
changeVote, |
||||
haveVoted, |
||||
showDetails, |
||||
question, |
||||
t, |
||||
toggleIsDetailed |
||||
} = props; |
||||
|
||||
/* eslint-disable react/no-multi-comp */ |
||||
/** |
||||
* Render a header summing up answer information. |
||||
* |
||||
* @param {string} answer - The name of the answer. |
||||
* @param {number} percentage - The percentage of voters. |
||||
* @param {number} nbVotes - The number of collected votes. |
||||
* @returns {React.Node} |
||||
*/ |
||||
const renderHeader = (answer: string, percentage: number, nbVotes: number) => ( |
||||
<View style = { resultsStyles.answerHeader }> |
||||
<Text style = { resultsStyles.answer }>{ answer }</Text> |
||||
<Text style = { resultsStyles.answer }>({nbVotes}) {percentage}%</Text> |
||||
|
||||
{/* <Text style = { resultsStyles.answer }>{ answer } - { percentage }%</Text> |
||||
<Text style = { resultsStyles.answerVoteCount }> |
||||
{ t('polls.answer.vote', { count: nbVotes }) } |
||||
</Text> */} |
||||
</View> |
||||
); |
||||
|
||||
/** |
||||
* Render voters of and answer |
||||
* @param {AnswerInfo} answer - the answer info |
||||
* @returns {React.Node} |
||||
*/ |
||||
const renderRow = useCallback((answer: AnswerInfo) => { |
||||
const { name, percentage, voters, voterCount } = answer; |
||||
|
||||
if (showDetails) { |
||||
return ( |
||||
<View style = { resultsStyles.answerContainer }> |
||||
{ renderHeader(name, percentage, voterCount) } |
||||
{ voters && voterCount > 0 |
||||
&& <View style = { resultsStyles.voters }> |
||||
{voters.map(({ id, name: voterName }) => |
||||
<Text key = { id }>{ voterName }</Text> |
||||
)} |
||||
</View>} |
||||
</View> |
||||
); |
||||
} |
||||
|
||||
|
||||
// else, we display a simple list
|
||||
// We add a progress bar by creating an empty view of width equal to percentage.
|
||||
return ( |
||||
<View style = { resultsStyles.answerContainer }> |
||||
{ renderHeader(answer.name, percentage, voterCount) } |
||||
<View style = { resultsStyles.barContainer }> |
||||
<View style = { [ resultsStyles.bar, { width: `${percentage}%` } ] } /> |
||||
</View> |
||||
</View> |
||||
); |
||||
|
||||
}, [ showDetails ]); |
||||
|
||||
/* eslint-disable react/jsx-no-bind */ |
||||
return ( |
||||
<View> |
||||
<View> |
||||
<Text style = { dialogStyles.question } >{ question }</Text> |
||||
</View> |
||||
<FlatList |
||||
data = { answers } |
||||
keyExtractor = { (item, index) => index.toString() } |
||||
renderItem = { answer => renderRow(answer.item) } /> |
||||
<View style = { chatStyles.bottomLinks }> |
||||
<TouchableOpacity onPress = { toggleIsDetailed }> |
||||
<Text |
||||
style = { chatStyles.toggleText }> |
||||
{showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')} |
||||
</Text> |
||||
</TouchableOpacity> |
||||
<TouchableOpacity onPress = { changeVote }> |
||||
<Text |
||||
style = { chatStyles.toggleText }> |
||||
{haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')} |
||||
</Text> |
||||
</TouchableOpacity> |
||||
</View> |
||||
|
||||
</View> |
||||
); |
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollResults to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollResults(PollResults); |
@ -0,0 +1,53 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from '@atlaskit/checkbox'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
import { FlatList } from 'react-native'; |
||||
import { Text } from 'react-native-paper'; |
||||
import { useSelector } from 'react-redux'; |
||||
|
||||
import { chatStyles } from './styles'; |
||||
|
||||
import { PollItem } from '.'; |
||||
|
||||
|
||||
const PollsList = () => { |
||||
|
||||
const polls = useSelector(state => state['features/polls'].polls); |
||||
const { t } = useTranslation(); |
||||
const listPolls = Object.keys(polls); |
||||
|
||||
const renderItem = useCallback(({ item }) => ( |
||||
<PollItem |
||||
key = { item } |
||||
pollId = { item } />) |
||||
, []); |
||||
|
||||
const flatlistRef = useRef(); |
||||
|
||||
const scrollToBottom = () => { |
||||
flatlistRef.current.scrollToEnd({ animating: true }); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
scrollToBottom(); |
||||
}, [ polls ]); |
||||
|
||||
return ( |
||||
<> |
||||
{listPolls.length === 0 |
||||
&& <Text style = { chatStyles.noPollText } > |
||||
{t('polls.results.empty')} |
||||
</Text>} |
||||
<FlatList |
||||
data = { listPolls } |
||||
extraData = { listPolls } |
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
keyExtractor = { (item, index) => index.toString() } |
||||
ref = { flatlistRef } |
||||
renderItem = { renderItem } /> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default PollsList; |
@ -0,0 +1,45 @@ |
||||
/* eslint-disable react-native/no-color-literals */ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
import { View } from 'react-native'; |
||||
import { Button } from 'react-native-paper'; |
||||
|
||||
import { BUTTON_MODES } from '../../../chat/constants'; |
||||
import AbstractPollsPane from '../AbstractPollsPane'; |
||||
import type { AbstractProps } from '../AbstractPollsPane'; |
||||
|
||||
import PollCreate from './PollCreate'; |
||||
import PollsList from './PollsList'; |
||||
import { chatStyles } from './styles'; |
||||
|
||||
const PollsPane = (props: AbstractProps) => { |
||||
|
||||
const { createMode, onCreate, setCreateMode, t } = props; |
||||
|
||||
return ( |
||||
<View style = { chatStyles.PollPane }> |
||||
{ createMode |
||||
? <PollCreate setCreateMode = { setCreateMode } /> |
||||
: <View style = { chatStyles.PollPaneContent }> |
||||
{/* <View /> */} |
||||
<PollsList /> |
||||
<Button |
||||
color = '#17a0db' |
||||
mode = { BUTTON_MODES.CONTAINED } |
||||
onPress = { onCreate } |
||||
style = { chatStyles.createPollButton } > |
||||
{t('polls.create.create')} |
||||
</Button> |
||||
</View>} |
||||
</View> |
||||
); |
||||
}; |
||||
|
||||
|
||||
/* |
||||
* We apply AbstractPollsPane to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollsPane(PollsPane); |
@ -0,0 +1,7 @@ |
||||
// @flow
|
||||
|
||||
export { default as PollResults } from './PollResults'; |
||||
export { default as PollsPane } from './PollsPane'; |
||||
export { default as PollItem } from './PollItem'; |
||||
export { default as PollAnswer } from './PollAnswer'; |
||||
export { default as PollCreate } from './PollCreate'; |
@ -0,0 +1,195 @@ |
||||
// @flow
|
||||
|
||||
import { schemeColor } from '../../../base/color-scheme'; |
||||
import { ColorPalette, createStyleSheet } from '../../../base/styles'; |
||||
|
||||
export const answerStyles = createStyleSheet({ |
||||
question: { |
||||
fontSize: 24, |
||||
fontWeight: 'bold', |
||||
marginBottom: 6 |
||||
}, |
||||
answer: { |
||||
flexDirection: 'row', |
||||
alignItems: 'center', |
||||
marginBottom: 3 |
||||
}, |
||||
option: { |
||||
flexShrink: 1 |
||||
} |
||||
}); |
||||
|
||||
export const dialogStyles = createStyleSheet({ |
||||
question: { |
||||
fontSize: 16, |
||||
fontWeight: 'bold', |
||||
marginVertical: 4 |
||||
}, |
||||
|
||||
optionContainer: { |
||||
flexDirection: 'row' |
||||
}, |
||||
|
||||
field: { |
||||
borderBottomWidth: 1, |
||||
borderColor: ColorPalette.blue, |
||||
fontSize: 14, |
||||
flexGrow: 1, |
||||
paddingBottom: 0, |
||||
flexShrink: 1 |
||||
}, |
||||
|
||||
buttonContainer: { |
||||
justifyContent: 'flex-end', |
||||
alignItems: 'center' |
||||
}, |
||||
|
||||
icon: { |
||||
color: ColorPalette.white, |
||||
backgroundColor: ColorPalette.blue, |
||||
borderRadius: 5, |
||||
margin: 0 |
||||
}, |
||||
|
||||
plusButton: { |
||||
marginTop: 8 |
||||
} |
||||
}); |
||||
|
||||
export const resultsStyles = createStyleSheet({ |
||||
title: { |
||||
fontSize: 24, |
||||
fontWeight: 'bold' |
||||
}, |
||||
|
||||
barContainer: { |
||||
backgroundColor: '#ccc', |
||||
borderRadius: 3, |
||||
width: '100%', |
||||
height: 6, |
||||
marginTop: 2 |
||||
}, |
||||
|
||||
bar: { |
||||
backgroundColor: ColorPalette.blue, |
||||
borderRadius: 3, |
||||
height: 6 |
||||
}, |
||||
|
||||
voters: { |
||||
borderRadius: 3, |
||||
borderWidth: 1, |
||||
borderColor: 'gray', |
||||
padding: 2, |
||||
marginHorizontal: 8, |
||||
marginVertical: 4 |
||||
}, |
||||
|
||||
answerContainer: { |
||||
marginVertical: 2, |
||||
maxWidth: '100%' |
||||
}, |
||||
|
||||
answerHeader: { |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-between' |
||||
}, |
||||
|
||||
answer: { |
||||
flexShrink: 1 |
||||
}, |
||||
|
||||
answerVoteCount: { |
||||
paddingLeft: 10 |
||||
}, |
||||
|
||||
chatQuestion: { |
||||
fontWeight: 'bold' |
||||
} |
||||
}); |
||||
|
||||
export const chatStyles = createStyleSheet({ |
||||
messageFooter: { |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-between', |
||||
alignItems: 'center', |
||||
fontSize: 11, |
||||
marginTop: 6 |
||||
}, |
||||
|
||||
showDetails: { |
||||
fontWeight: 'bold' |
||||
}, |
||||
|
||||
noPollText: { |
||||
flex: 1, |
||||
color: schemeColor('displayName'), |
||||
textAlign: 'center', |
||||
paddingTop: '10%' |
||||
}, |
||||
|
||||
pollItemContainer: { |
||||
borderRadius: 4, |
||||
borderColor: '#2183ad', |
||||
borderWidth: 2, |
||||
padding: 16, |
||||
marginBottom: 8 |
||||
}, |
||||
|
||||
pollCreateContainer: { |
||||
flex: 1, |
||||
justifyContent: 'space-between' |
||||
}, |
||||
|
||||
pollCreateSubContainer: { |
||||
flex: 1 |
||||
}, |
||||
|
||||
pollCreateButton: { |
||||
flex: 1, |
||||
marginHorizontal: 8 |
||||
}, |
||||
|
||||
buttonRow: { |
||||
flexDirection: 'row' |
||||
}, |
||||
|
||||
answerContent: { |
||||
paddingBottom: 8 |
||||
}, |
||||
|
||||
switchRow: { |
||||
alignItems: 'center', |
||||
flexDirection: 'row', |
||||
padding: 6 |
||||
}, |
||||
|
||||
pollCreateAddButton: { |
||||
margin: 8 |
||||
}, |
||||
|
||||
toggleText: { |
||||
color: ColorPalette.blue, |
||||
paddingTop: 16 |
||||
}, |
||||
|
||||
createPollButton: { |
||||
padding: 8, |
||||
margin: 4 |
||||
}, |
||||
|
||||
PollPane: { |
||||
flex: 1, |
||||
padding: 8 |
||||
}, |
||||
|
||||
PollPaneContent: { |
||||
justifyContent: 'space-between', |
||||
flex: 1 |
||||
}, |
||||
|
||||
bottomLinks: { |
||||
flexDirection: 'row', |
||||
justifyContent: 'space-between' |
||||
} |
||||
}); |
@ -0,0 +1,69 @@ |
||||
// @flow
|
||||
|
||||
import { Checkbox } from '@atlaskit/checkbox'; |
||||
import React from 'react'; |
||||
|
||||
import AbstractPollAnswer from '../AbstractPollAnswer'; |
||||
import type { AbstractProps } from '../AbstractPollAnswer'; |
||||
|
||||
|
||||
const PollAnswer = (props: AbstractProps) => { |
||||
|
||||
const { |
||||
checkBoxStates, |
||||
poll, |
||||
setCheckbox, |
||||
skipAnswer, |
||||
submitAnswer, |
||||
t |
||||
} = props; |
||||
|
||||
return ( |
||||
<div className = 'poll-answer'> |
||||
<div className = 'poll-header'> |
||||
<div className = 'poll-question'> |
||||
<span>{ poll.question }</span> |
||||
</div> |
||||
</div> |
||||
<ol className = 'poll-answer-list'> |
||||
{ |
||||
poll.answers.map((answer, index) => ( |
||||
<li |
||||
className = 'poll-answer-container' |
||||
key = { index }> |
||||
<Checkbox |
||||
isChecked = { checkBoxStates[index] } |
||||
key = { index } |
||||
label = { <span>{ answer.name }</span> } |
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onChange = { ev => setCheckbox(index, ev.target.checked) } |
||||
size = 'large' /> |
||||
</li> |
||||
)) |
||||
} |
||||
</ol> |
||||
<div className = { 'poll-footer' }> |
||||
<button |
||||
aria-label = { t('polls.answer.skip') } |
||||
className = { 'poll-small-secondary-button' } |
||||
onClick = { skipAnswer } > |
||||
<span>{t('polls.answer.skip')}</span> |
||||
</button> |
||||
<button |
||||
aria-label = { t('polls.answer.submit') } |
||||
className = { 'poll-small-primary-button' } |
||||
onClick = { submitAnswer }> |
||||
<span>{t('polls.answer.submit')}</span> |
||||
</button> |
||||
</div> |
||||
|
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollAnswer to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollAnswer(PollAnswer); |
@ -0,0 +1,248 @@ |
||||
// @flow
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'; |
||||
|
||||
import { Icon, IconMenu } from '../../../base/icons'; |
||||
import { Tooltip } from '../../../base/tooltip'; |
||||
import AbstractPollCreate from '../AbstractPollCreate'; |
||||
import type { AbstractProps } from '../AbstractPollCreate'; |
||||
|
||||
|
||||
const PollCreate = (props: AbstractProps) => { |
||||
|
||||
|
||||
const { |
||||
addAnswer, |
||||
answers, |
||||
isSubmitDisabled, |
||||
moveAnswer, |
||||
onSubmit, |
||||
question, |
||||
removeAnswer, |
||||
setAnswer, |
||||
setCreateMode, |
||||
setQuestion, |
||||
t |
||||
} = props; |
||||
|
||||
/* |
||||
* This ref stores the Array of answer input fields, allowing us to focus on them. |
||||
* This array is maintained by registerfieldRef and the useEffect below. |
||||
*/ |
||||
const answerInputs = useRef([]); |
||||
const registerFieldRef = useCallback((i, r) => { |
||||
if (r === null) { |
||||
return; |
||||
} |
||||
answerInputs.current[i] = r; |
||||
}, [ answerInputs ]); |
||||
|
||||
useEffect(() => { |
||||
answerInputs.current = answerInputs.current.slice(0, answers.length); |
||||
}, [ answers ]); |
||||
|
||||
/* |
||||
* This state allows us to requestFocus asynchronously, without having to worry |
||||
* about whether a newly created input field has been rendered yet or not. |
||||
*/ |
||||
const [ lastFocus, requestFocus ] = useState(null); |
||||
|
||||
useEffect(() => { |
||||
if (lastFocus === null) { |
||||
return; |
||||
} |
||||
const input = answerInputs.current[lastFocus]; |
||||
|
||||
if (input === undefined) { |
||||
return; |
||||
} |
||||
input.focus(); |
||||
}, [ lastFocus ]); |
||||
|
||||
const checkModifiers = useCallback(ev => { |
||||
// Because this isn't done automatically on MacOS
|
||||
if (ev.key === 'Enter' && ev.metaKey) { |
||||
ev.preventDefault(); |
||||
onSubmit(); |
||||
|
||||
return; |
||||
} |
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) { |
||||
return; |
||||
} |
||||
}); |
||||
|
||||
const onQuestionKeyDown = useCallback(ev => { |
||||
if (checkModifiers(ev)) { |
||||
return; |
||||
} |
||||
|
||||
if (ev.key === 'Enter') { |
||||
requestFocus(0); |
||||
ev.preventDefault(); |
||||
} |
||||
}); |
||||
|
||||
// Called on keypress in answer fields
|
||||
const onAnswerKeyDown = useCallback((i, ev) => { |
||||
if (checkModifiers(ev)) { |
||||
return; |
||||
} |
||||
|
||||
if (ev.key === 'Enter') { |
||||
addAnswer(i + 1); |
||||
requestFocus(i + 1); |
||||
ev.preventDefault(); |
||||
} else if (ev.key === 'Backspace' && ev.target.value === '' && answers.length > 1) { |
||||
removeAnswer(i); |
||||
requestFocus(i > 0 ? i - 1 : 0); |
||||
ev.preventDefault(); |
||||
} else if (ev.key === 'ArrowDown') { |
||||
if (i === answers.length - 1) { |
||||
addAnswer(); |
||||
} |
||||
requestFocus(i + 1); |
||||
ev.preventDefault(); |
||||
} else if (ev.key === 'ArrowUp') { |
||||
if (i === 0) { |
||||
addAnswer(0); |
||||
requestFocus(0); |
||||
} else { |
||||
requestFocus(i - 1); |
||||
} |
||||
ev.preventDefault(); |
||||
} |
||||
}, [ answers, addAnswer, removeAnswer, requestFocus ]); |
||||
|
||||
const [ grabbing, setGrabbing ] = useState(null); |
||||
|
||||
const onGrab = useCallback((i, ev) => { |
||||
if (ev.button !== 0) { |
||||
return; |
||||
} |
||||
setGrabbing(i); |
||||
window.addEventListener('mouseup', () => { |
||||
setGrabbing(_grabbing => { |
||||
requestFocus(_grabbing); |
||||
|
||||
return null; |
||||
}); |
||||
}, { once: true }); |
||||
}); |
||||
const onMouseOver = useCallback(i => { |
||||
if (grabbing !== null && grabbing !== i) { |
||||
moveAnswer(grabbing, i); |
||||
setGrabbing(i); |
||||
} |
||||
}); |
||||
|
||||
const autogrow = ev => { |
||||
const el = ev.target; |
||||
|
||||
el.style.height = '1px'; |
||||
el.style.height = `${el.scrollHeight + 2}px`; |
||||
}; |
||||
|
||||
/* eslint-disable react/jsx-no-bind */ |
||||
return (<form |
||||
className = 'polls-pane-content' |
||||
onSubmit = { onSubmit }> |
||||
<div className = 'poll-create-container poll-container'> |
||||
<div className = 'poll-create-header'> |
||||
{ t('polls.create.create') } |
||||
</div> |
||||
<div className = 'poll-question-field'> |
||||
<span className = 'poll-create-label'> |
||||
{ t('polls.create.pollQuestion') } |
||||
</span> |
||||
<textarea |
||||
autoFocus = { true } |
||||
className = 'expandable-input' |
||||
onChange = { ev => setQuestion(ev.target.value) } |
||||
onInput = { autogrow } |
||||
onKeyDown = { onQuestionKeyDown } |
||||
placeholder = { t('polls.create.questionPlaceholder') } |
||||
required = { true } |
||||
row = '1' |
||||
value = { question } /> |
||||
</div> |
||||
<ol className = 'poll-answer-field-list'> |
||||
{answers.map((answer, i) => |
||||
(<li |
||||
className = { `poll-answer-field${grabbing === i ? ' poll-dragged' : ''}` } |
||||
key = { i } |
||||
onMouseOver = { () => onMouseOver(i) }> |
||||
<span className = 'poll-create-label'> |
||||
{ t('polls.create.pollOption', { index: i + 1 })} |
||||
</span> |
||||
<div className = 'poll-create-option-row'> |
||||
<textarea |
||||
className = 'expandable-input' |
||||
onChange = { ev => setAnswer(i, ev.target.value) } |
||||
onInput = { autogrow } |
||||
onKeyDown = { ev => onAnswerKeyDown(i, ev) } |
||||
placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) } |
||||
ref = { r => registerFieldRef(i, r) } |
||||
required = { true } |
||||
row = { 1 } |
||||
value = { answer } /> |
||||
<button |
||||
className = 'poll-drag-handle' |
||||
onMouseDown = { ev => onGrab(i, ev) } |
||||
tabIndex = '-1' |
||||
type = 'button'> |
||||
<Icon src = { IconMenu } /> |
||||
</button> |
||||
</div> |
||||
|
||||
{ answers.length > 2 |
||||
&& <Tooltip content = { t('polls.create.removeOption') }> |
||||
<button |
||||
className = 'poll-remove-option-button' |
||||
onClick = { () => removeAnswer(i) } |
||||
type = 'button'> |
||||
{ t('polls.create.removeOption') } |
||||
</button> |
||||
</Tooltip>} |
||||
</li>) |
||||
)} |
||||
</ol> |
||||
<div className = 'poll-add-button'> |
||||
<button |
||||
aria-label = { 'Add option' } |
||||
className = { 'poll-secondary-button' } |
||||
onClick = { () => { |
||||
addAnswer(); |
||||
requestFocus(answers.length); |
||||
} } |
||||
type = 'button' > |
||||
<span>{t('polls.create.addOption')}</span> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div className = 'poll-footer'> |
||||
<button |
||||
aria-label = { t('polls.create.cancel') } |
||||
className = 'poll-small-secondary-button' |
||||
onClick = { () => setCreateMode(false) } |
||||
type = 'button' > |
||||
<span>{t('polls.create.cancel')}</span> |
||||
</button> |
||||
<button |
||||
aria-label = { t('polls.create.send') } |
||||
className = 'poll-small-primary-button' |
||||
disabled = { isSubmitDisabled } |
||||
type = 'submit' > |
||||
<span>{t('polls.create.send')}</span> |
||||
</button> |
||||
</div> |
||||
</form>); |
||||
|
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollCreate to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollCreate(PollCreate); |
@ -0,0 +1,36 @@ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
import { useSelector } from 'react-redux'; |
||||
|
||||
import { PollAnswer, PollResults } from '..'; |
||||
import { shouldShowResults } from '../../functions'; |
||||
|
||||
|
||||
type Props = { |
||||
|
||||
/** |
||||
* Id of the poll |
||||
*/ |
||||
pollId: string, |
||||
|
||||
} |
||||
|
||||
const PollItem = React.forwardRef<Props, HTMLElement>(({ pollId }, ref) => { |
||||
const showResults = useSelector(state => shouldShowResults(state, pollId)); |
||||
|
||||
return ( |
||||
<div ref = { ref }> |
||||
{ showResults |
||||
? <PollResults |
||||
key = { pollId } |
||||
pollId = { pollId } /> |
||||
: <PollAnswer |
||||
pollId = { pollId } /> |
||||
} |
||||
|
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
export default PollItem; |
@ -0,0 +1,80 @@ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
|
||||
import AbstractPollResults from '../AbstractPollResults'; |
||||
import type { AbstractProps } from '../AbstractPollResults'; |
||||
|
||||
|
||||
/** |
||||
* Component that renders the poll results. |
||||
* |
||||
* @param {Props} props - The passed props. |
||||
* @returns {React.Node} |
||||
*/ |
||||
const PollResults = (props: AbstractProps) => { |
||||
const { |
||||
answers, |
||||
changeVote, |
||||
haveVoted, |
||||
showDetails, |
||||
question, |
||||
t, |
||||
toggleIsDetailed |
||||
} = props; |
||||
|
||||
return ( |
||||
<div className = 'poll-results'> |
||||
<div className = 'poll-header'> |
||||
<div className = 'poll-question'> |
||||
<strong>{ question }</strong> |
||||
</div> |
||||
</div> |
||||
<ol className = 'poll-result-list'> |
||||
{answers.map(({ name, percentage, voters, voterCount }, index) => |
||||
(<li key = { index }> |
||||
<div className = 'poll-answer-header'> |
||||
<span className = 'poll-answer-vote-name' >{name}</span> |
||||
</div> |
||||
<div className = 'poll-answer-short-results'> |
||||
<span className = 'poll-bar-container'> |
||||
<div |
||||
className = 'poll-bar' |
||||
style = {{ width: `${percentage}%` }} /> |
||||
</span> |
||||
<div className = 'poll-answer-vote-count-container'> |
||||
<span className = 'poll-answer-vote-count'>({voterCount}) {percentage}%</span> |
||||
</div> |
||||
</div> |
||||
{ showDetails && voters && voterCount > 0 |
||||
&& <ul className = 'poll-answer-voters'> |
||||
{voters.map(voter => |
||||
<li key = { voter.id }>{voter.name}</li> |
||||
)} |
||||
</ul>} |
||||
</li>) |
||||
)} |
||||
</ol> |
||||
<div className = { 'poll-result-links' }> |
||||
<a |
||||
className = { 'poll-detail-link' } |
||||
onClick = { toggleIsDetailed }> |
||||
{showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')} |
||||
</a> |
||||
<a |
||||
className = { 'poll-change-vote-link' } |
||||
onClick = { changeVote }> |
||||
{haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')} |
||||
</a> |
||||
</div> |
||||
</div> |
||||
); |
||||
|
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollResults to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollResults(PollResults); |
@ -0,0 +1,48 @@ |
||||
// @flow
|
||||
|
||||
import React, { useEffect, useRef } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
import { useSelector } from 'react-redux'; |
||||
|
||||
import { Icon, IconChatUnread } from '../../../base/icons'; |
||||
|
||||
import { PollItem } from '.'; |
||||
|
||||
const PollsList = () => { |
||||
const { t } = useTranslation(); |
||||
|
||||
const polls = useSelector(state => state['features/polls'].polls); |
||||
const pollListEndRef = useRef(null); |
||||
|
||||
const scrollToBottom = () => { |
||||
if (pollListEndRef.current) { |
||||
pollListEndRef.current.scrollIntoView({ behavior: 'smooth' }); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
scrollToBottom(); |
||||
}, [ polls ]); |
||||
|
||||
const listPolls = Object.keys(polls); |
||||
|
||||
return ( |
||||
<> |
||||
{listPolls.length === 0 |
||||
? <div className = 'pane-content'> |
||||
<Icon |
||||
className = 'empty-pane-icon' |
||||
src = { IconChatUnread } /> |
||||
<span className = 'empty-pane-message'>{t('polls.results.empty')}</span> |
||||
</div> |
||||
: listPolls.map((id, index) => ( |
||||
<PollItem |
||||
key = { id } |
||||
pollId = { id } |
||||
ref = { listPolls.length - 1 === index ? pollListEndRef : null } /> |
||||
))} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default PollsList; |
@ -0,0 +1,39 @@ |
||||
// @flow
|
||||
|
||||
import React from 'react'; |
||||
|
||||
import AbstractPollsPane from '../AbstractPollsPane'; |
||||
import type { AbstractProps } from '../AbstractPollsPane'; |
||||
|
||||
import PollsList from './PollsList'; |
||||
|
||||
import { PollCreate } from '.'; |
||||
|
||||
const PollsPane = (props: AbstractProps) => { |
||||
|
||||
const { createMode, onCreate, setCreateMode, t } = props; |
||||
|
||||
return createMode |
||||
? <PollCreate setCreateMode = { setCreateMode } /> |
||||
: <div className = 'polls-pane-content'> |
||||
<div className = { 'poll-container' } > |
||||
<PollsList /> |
||||
</div> |
||||
<div className = { 'poll-footer' }> |
||||
<button |
||||
aria-label = { t('polls.create.create') } |
||||
className = { 'poll-primary-button' } |
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { onCreate } > |
||||
<span>{t('polls.create.create')}</span> |
||||
</button> |
||||
</div> |
||||
</div>; |
||||
}; |
||||
|
||||
/* |
||||
* We apply AbstractPollsPane to fill in the AbstractProps common |
||||
* to both the web and native implementations. |
||||
*/ |
||||
// eslint-disable-next-line new-cap
|
||||
export default AbstractPollsPane(PollsPane); |
@ -0,0 +1,6 @@ |
||||
// @flow
|
||||
export { default as PollAnswer } from './PollAnswer'; |
||||
export { default as PollCreate } from './PollCreate'; |
||||
export { default as PollResults } from './PollResults'; |
||||
export { default as PollsPane } from './PollsPane'; |
||||
export { default as PollItem } from './PollItem'; |
@ -0,0 +1,5 @@ |
||||
// @flow
|
||||
|
||||
export const COMMAND_NEW_POLL = 'new-poll'; |
||||
export const COMMAND_ANSWER_POLL = 'answer-poll'; |
||||
export const COMMAND_OLD_POLLS = 'old-polls'; |
@ -0,0 +1,23 @@ |
||||
// @flow
|
||||
|
||||
/** |
||||
* Should poll results be shown. |
||||
* |
||||
* @param {Object} state - Global state. |
||||
* @param {string} id - Id of the poll. |
||||
* @returns {boolean} Should poll results be shown. |
||||
*/ |
||||
export const shouldShowResults = (state: Object, id: string) => Boolean(state['features/polls']?.polls[id].showResults); |
||||
|
||||
|
||||
/** |
||||
* Selector for calculating the number of unread poll messages. |
||||
* |
||||
* @param {Object} state - The redux state. |
||||
* @returns {number} The number of unread messages. |
||||
*/ |
||||
export function getUnreadPollCount(state: Object) { |
||||
const { nbUnreadPolls } = state['features/polls']; |
||||
|
||||
return nbUnreadPolls; |
||||
} |
@ -0,0 +1,32 @@ |
||||
// @flow
|
||||
|
||||
import { MiddlewareRegistry } from '../base/redux'; |
||||
import { playSound } from '../base/sounds'; |
||||
import { INCOMING_MSG_SOUND_ID } from '../chat/constants'; |
||||
|
||||
import { RECEIVE_POLL } from './actionTypes'; |
||||
|
||||
|
||||
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { |
||||
const result = next(action); |
||||
|
||||
switch (action.type) { |
||||
|
||||
// Middleware triggered when a poll is received
|
||||
case RECEIVE_POLL: { |
||||
|
||||
const state = getState(); |
||||
const isChatOpen: boolean = state['features/chat'].isOpen; |
||||
const isPollsTabFocused: boolean = state['features/chat'].isPollsTabFocused; |
||||
|
||||
// Finally, we notify user they received a new poll if their pane is not opened
|
||||
if (action.notify && (!isChatOpen || !isPollsTabFocused)) { |
||||
dispatch(playSound(INCOMING_MSG_SOUND_ID)); |
||||
} |
||||
break; |
||||
} |
||||
|
||||
} |
||||
|
||||
return result; |
||||
}); |
@ -0,0 +1,128 @@ |
||||
// @flow
|
||||
|
||||
import { ReducerRegistry } from '../base/redux'; |
||||
|
||||
import { |
||||
RECEIVE_POLL, |
||||
RECEIVE_ANSWER, |
||||
REGISTER_VOTE, |
||||
RETRACT_VOTE, |
||||
RESET_NB_UNREAD_POLLS |
||||
} from './actionTypes'; |
||||
import type { Answer } from './types'; |
||||
|
||||
const INITIAL_STATE = { |
||||
polls: {}, |
||||
|
||||
// Number of not read message
|
||||
nbUnreadPolls: 0 |
||||
}; |
||||
|
||||
ReducerRegistry.register('features/polls', (state = INITIAL_STATE, action) => { |
||||
switch (action.type) { |
||||
|
||||
// Reducer triggered when a poll is received
|
||||
case RECEIVE_POLL: { |
||||
const newState = { |
||||
...state, |
||||
polls: { |
||||
...state.polls, |
||||
|
||||
// The poll is added to the dictionnary of received polls
|
||||
[action.pollId]: action.poll |
||||
}, |
||||
nbUnreadPolls: state.nbUnreadPolls + 1 |
||||
}; |
||||
|
||||
return newState; |
||||
} |
||||
|
||||
// Reducer triggered when an answer is received
|
||||
// The answer is added to an existing poll
|
||||
case RECEIVE_ANSWER: { |
||||
|
||||
const { pollId, answer }: { pollId: string; answer: Answer } = action; |
||||
|
||||
// if the poll doesn't exist
|
||||
if (!(pollId in state.polls)) { |
||||
console.warn('requested poll does not exist: pollId ', pollId); |
||||
|
||||
return state; |
||||
} |
||||
|
||||
// if the poll exists, we update it with the incoming answer
|
||||
const newAnswers = state.polls[pollId].answers |
||||
.map(_answer => { |
||||
return { |
||||
name: _answer.name, |
||||
voters: new Map(_answer.voters) |
||||
}; |
||||
}); |
||||
|
||||
for (let i = 0; i < newAnswers.length; i++) { |
||||
// if the answer was chosen, we add the sender to the set of voters of this answer
|
||||
const voters = newAnswers[i].voters; |
||||
|
||||
if (answer.answers[i]) { |
||||
voters.set(answer.voterId, answer.voterName); |
||||
|
||||
} else { |
||||
voters.delete(answer.voterId); |
||||
} |
||||
} |
||||
|
||||
// finally we update the state by returning the updated poll
|
||||
return { |
||||
...state, |
||||
polls: { |
||||
...state.polls, |
||||
[pollId]: { |
||||
...state.polls[pollId], |
||||
answers: newAnswers |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
|
||||
case REGISTER_VOTE: { |
||||
const { answers, pollId }: { answers: Array<boolean> | null; pollId: string } = action; |
||||
|
||||
return { |
||||
...state, |
||||
polls: { |
||||
...state.polls, |
||||
[pollId]: { |
||||
...state.polls[pollId], |
||||
lastVote: answers, |
||||
showResults: true |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
|
||||
case RETRACT_VOTE: { |
||||
const { pollId }: { pollId: string } = action; |
||||
|
||||
return { |
||||
...state, |
||||
polls: { |
||||
...state.polls, |
||||
[pollId]: { |
||||
...state.polls[pollId], |
||||
showResults: false |
||||
} |
||||
} |
||||
}; |
||||
} |
||||
|
||||
case RESET_NB_UNREAD_POLLS: { |
||||
return { |
||||
...state, |
||||
nbUnreadPolls: 0 |
||||
}; |
||||
} |
||||
|
||||
default: |
||||
return state; |
||||
} |
||||
}); |
@ -0,0 +1,125 @@ |
||||
// @flow
|
||||
|
||||
import { getCurrentConference } from '../base/conference'; |
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; |
||||
import { StateListenerRegistry } from '../base/redux'; |
||||
import { |
||||
NOTIFICATION_TIMEOUT, |
||||
NOTIFICATION_TYPE, |
||||
showNotification |
||||
} from '../notifications'; |
||||
|
||||
import { receiveAnswer, receivePoll } from './actions'; |
||||
import { COMMAND_NEW_POLL, COMMAND_ANSWER_POLL, COMMAND_OLD_POLLS } from './constants'; |
||||
import type { Answer, Poll } from './types'; |
||||
|
||||
|
||||
const parsePollData = (pollData): Poll | null => { |
||||
if (typeof pollData !== 'object' || pollData === null) { |
||||
return null; |
||||
} |
||||
const { id, senderId, senderName, question, answers } = pollData; |
||||
|
||||
if (typeof id !== 'string' || typeof senderId !== 'string' || typeof senderName !== 'string' |
||||
|| typeof question !== 'string' || !(answers instanceof Array)) { |
||||
return null; |
||||
} |
||||
|
||||
const answersParsed = []; |
||||
|
||||
for (const answer of answers) { |
||||
const voters = new Map(); |
||||
|
||||
for (const [ voterId, voter ] of Object.entries(answer.voters)) { |
||||
if (typeof voter !== 'string') { |
||||
return null; |
||||
} |
||||
voters.set(voterId, voter); |
||||
} |
||||
|
||||
answersParsed.push({ |
||||
name: answer.name, |
||||
voters |
||||
}); |
||||
} |
||||
|
||||
return { |
||||
senderId, |
||||
senderName, |
||||
question, |
||||
showResults: true, |
||||
lastVote: null, |
||||
answers: answersParsed |
||||
}; |
||||
}; |
||||
|
||||
StateListenerRegistry.register( |
||||
state => getCurrentConference(state), |
||||
(conference, store, previousConference) => { |
||||
if (conference && conference !== previousConference) { |
||||
const receiveMessage = (_, data) => { |
||||
switch (data.type) { |
||||
case COMMAND_NEW_POLL: { |
||||
const { question, answers, pollId, senderId, senderName } = data; |
||||
|
||||
const poll = { |
||||
senderId, |
||||
senderName, |
||||
showResults: false, |
||||
lastVote: null, |
||||
question, |
||||
answers: answers.map(answer => { |
||||
return { |
||||
name: answer, |
||||
voters: new Map() |
||||
}; |
||||
}) |
||||
}; |
||||
|
||||
store.dispatch(receivePoll(pollId, poll, true)); |
||||
store.dispatch(showNotification({ |
||||
appearance: NOTIFICATION_TYPE.NORMAL, |
||||
titleKey: 'polls.notification.title', |
||||
descriptionKey: 'polls.notification.description' |
||||
}, NOTIFICATION_TIMEOUT)); |
||||
break; |
||||
|
||||
} |
||||
|
||||
case COMMAND_ANSWER_POLL: { |
||||
const { pollId, answers, voterId, voterName } = data; |
||||
|
||||
const receivedAnswer: Answer = { |
||||
voterId, |
||||
voterName, |
||||
pollId, |
||||
answers |
||||
}; |
||||
|
||||
store.dispatch(receiveAnswer(pollId, receivedAnswer)); |
||||
break; |
||||
|
||||
} |
||||
|
||||
case COMMAND_OLD_POLLS: { |
||||
const { polls } = data; |
||||
|
||||
for (const pollData of polls) { |
||||
const poll = parsePollData(pollData); |
||||
|
||||
if (poll === null) { |
||||
console.warn('[features/polls] Invalid old poll data'); |
||||
} else { |
||||
store.dispatch(receivePoll(pollData.id, poll, false)); |
||||
} |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, receiveMessage); |
||||
conference.on(JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED, receiveMessage); |
||||
} |
||||
} |
||||
); |
@ -0,0 +1,61 @@ |
||||
// @flow
|
||||
|
||||
export type Answer = { |
||||
|
||||
/** |
||||
* ID of the voter for this answer |
||||
*/ |
||||
voterId: string, |
||||
|
||||
/** |
||||
* Name of the voter |
||||
*/ |
||||
voterName: string, |
||||
|
||||
/** |
||||
* ID of the parent Poll of this answer |
||||
*/ |
||||
pollId: string, |
||||
|
||||
/** |
||||
* An array of boolean: true if the answer was chosen by the responder, else false |
||||
*/ |
||||
answers: Array<boolean> |
||||
}; |
||||
|
||||
export type Poll = { |
||||
|
||||
/** |
||||
* ID of the sender of this poll |
||||
*/ |
||||
senderId: string, |
||||
|
||||
|
||||
/** |
||||
* Name of the sender of this poll |
||||
* Store poll sender name in case they exit the call |
||||
*/ |
||||
senderName: string, |
||||
|
||||
/** |
||||
* Whether the results should be shown instead of the answer form |
||||
*/ |
||||
showResults: boolean, |
||||
|
||||
/** |
||||
* The last sent votes for this poll, or null if voting was skipped |
||||
* Note: This is reset when voting/skipping, not when clicking "Change vote" |
||||
*/ |
||||
lastVote: Array<boolean> | null, |
||||
|
||||
/** |
||||
* The question asked by this poll |
||||
*/ |
||||
question: string, |
||||
|
||||
/** |
||||
* An array of answers: |
||||
* the name of the answer name and a map of ids and names of voters voting for this option |
||||
*/ |
||||
answers: Array<{ name: string, voters: Map<string, string> }>, |
||||
}; |
@ -0,0 +1,126 @@ |
||||
-- This module provides persistence for the "polls" feature, |
||||
-- by keeping track of the state of polls in each room, and sending |
||||
-- that state to new participants when they join. |
||||
|
||||
local json = require("util.json"); |
||||
local st = require("util.stanza"); |
||||
|
||||
local util = module:require("util"); |
||||
local muc = module:depends("muc"); |
||||
|
||||
local is_healthcheck_room = util.is_healthcheck_room; |
||||
|
||||
-- Checks if the given stanza contains a JSON message, |
||||
-- and that the message type pertains to the polls feature. |
||||
-- If yes, returns the parsed message. Otherwise, returns nil. |
||||
local function get_poll_message(stanza) |
||||
if stanza.attr.type ~= "groupchat" then |
||||
return nil; |
||||
end |
||||
local json_data = stanza:get_child_text("json-message", "http://jitsi.org/jitmeet"); |
||||
if json_data == nil then |
||||
return nil; |
||||
end |
||||
local data = json.decode(json_data); |
||||
if data.type ~= "new-poll" and data.type ~= "answer-poll" then |
||||
return nil; |
||||
end |
||||
return data; |
||||
end |
||||
|
||||
-- Logs a warning and returns true if a room does not |
||||
-- have poll data associated with it. |
||||
local function check_polls(room) |
||||
if room.polls == nil then |
||||
module:log("warn", "no polls data in room"); |
||||
return true; |
||||
end |
||||
return false; |
||||
end |
||||
|
||||
-- Sets up poll data in new rooms. |
||||
module:hook("muc-room-created", function(event) |
||||
local room = event.room; |
||||
if is_healthcheck_room(room.jid) then return end |
||||
module:log("debug", "setting up polls in room "..tostring(room)); |
||||
room.polls = { |
||||
by_id = {}; |
||||
order = {}; |
||||
}; |
||||
end); |
||||
|
||||
-- Keeps track of the current state of the polls in each room, |
||||
-- by listening to "new-poll" and "answer-poll" messages, |
||||
-- and updating the room poll data accordingly. |
||||
-- This mirrors the client-side poll update logic. |
||||
module:hook("message/bare", function(event) |
||||
local data = get_poll_message(event.stanza); |
||||
if data == nil then return end |
||||
|
||||
local room = muc.get_room_from_jid(event.stanza.attr.to); |
||||
|
||||
if data.type == "new-poll" then |
||||
if check_polls(room) then return end |
||||
|
||||
local answers = {} |
||||
for _, name in ipairs(data.answers) do |
||||
table.insert(answers, { name = name, voters = {} }); |
||||
end |
||||
|
||||
local poll = { |
||||
id = data.pollId, |
||||
sender_id = data.senderId, |
||||
sender_name = data.senderName, |
||||
question = data.question, |
||||
answers = answers |
||||
}; |
||||
room.polls.by_id[data.pollId] = poll |
||||
table.insert(room.polls.order, poll) |
||||
|
||||
elseif data.type == "answer-poll" then |
||||
if check_polls(room) then return end |
||||
|
||||
local poll = room.polls.by_id[data.pollId]; |
||||
if poll == nil then |
||||
module:log("warn", "answering inexistent poll"); |
||||
return; |
||||
end |
||||
|
||||
for i, value in ipairs(data.answers) do |
||||
poll.answers[i].voters[data.voterId] = value and data.voterName or nil; |
||||
end |
||||
end |
||||
end); |
||||
|
||||
-- Sends the current poll state to new occupants after joining a room. |
||||
module:hook("muc-occupant-joined", function(event) |
||||
local room = event.room; |
||||
if is_healthcheck_room(room.jid) then return end |
||||
if room.polls == nil or #room.polls.order == 0 then |
||||
return |
||||
end |
||||
|
||||
local data = { |
||||
type = "old-polls", |
||||
polls = {}, |
||||
}; |
||||
for i, poll in ipairs(room.polls.order) do |
||||
data.polls[i] = { |
||||
id = poll.id, |
||||
senderId = poll.sender_id, |
||||
senderName = poll.sender_name, |
||||
question = poll.question, |
||||
answers = poll.answers |
||||
}; |
||||
end |
||||
|
||||
local stanza = st.message({ |
||||
from = room.jid, |
||||
to = event.occupant.jid |
||||
}) |
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" }) |
||||
:text(json.encode(data)) |
||||
:up(); |
||||
|
||||
room:route_stanza(stanza); |
||||
end); |
Loading…
Reference in new issue