Procházet zdrojové kódy

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>
master
Jade Guiton před 3 roky
rodič
revize
8c82c0f56e
Žádný účet není propojen s e-mailovou adresou tvůrce revize
56 změnil soubory, kde provedl 3192 přidání a 36 odebrání
  1. 3
    0
      config.js
  2. 38
    0
      css/_chat.scss
  3. 448
    0
      css/_polls.scss
  4. 1
    0
      css/main.scss
  5. 1
    0
      doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example
  6. 37
    5
      lang/main-fr.json
  7. 37
    5
      lang/main.json
  8. 2
    0
      react/features/app/middlewares.any.js
  9. 1
    0
      react/features/app/reducers.any.js
  10. 1
    0
      react/features/base/config/configWhitelist.js
  11. 13
    8
      react/features/base/dialog/components/native/BaseDialog.js
  12. 8
    2
      react/features/base/dialog/components/native/ConfirmDialog.js
  13. 11
    1
      react/features/base/dialog/components/native/styles.js
  14. 9
    2
      react/features/base/dialog/components/web/StatelessDialog.js
  15. 10
    0
      react/features/chat/actionTypes.js
  16. 15
    1
      react/features/chat/actions.any.js
  17. 69
    3
      react/features/chat/components/AbstractChat.js
  18. 51
    5
      react/features/chat/components/native/Chat.js
  19. 19
    0
      react/features/chat/components/native/styles.js
  20. 59
    0
      react/features/chat/components/web/Chat.js
  21. 4
    1
      react/features/chat/components/web/ChatCounter.js
  22. 8
    0
      react/features/chat/constants.js
  23. 12
    0
      react/features/chat/functions.js
  24. 15
    2
      react/features/chat/middleware.js
  25. 13
    1
      react/features/chat/reducer.js
  26. 55
    0
      react/features/polls/actionTypes.js
  27. 99
    0
      react/features/polls/actions.js
  28. 101
    0
      react/features/polls/components/AbstractPollAnswer.js
  29. 135
    0
      react/features/polls/components/AbstractPollCreate.js
  30. 124
    0
      react/features/polls/components/AbstractPollResults.js
  31. 44
    0
      react/features/polls/components/AbstractPollsPane.js
  32. 3
    0
      react/features/polls/components/_.native.js
  33. 3
    0
      react/features/polls/components/_.web.js
  34. 3
    0
      react/features/polls/components/index.js
  35. 69
    0
      react/features/polls/components/native/PollAnswer.js
  36. 185
    0
      react/features/polls/components/native/PollCreate.js
  37. 40
    0
      react/features/polls/components/native/PollItem.js
  38. 120
    0
      react/features/polls/components/native/PollResults.js
  39. 53
    0
      react/features/polls/components/native/PollsList.js
  40. 45
    0
      react/features/polls/components/native/PollsPane.js
  41. 7
    0
      react/features/polls/components/native/index.js
  42. 195
    0
      react/features/polls/components/native/styles.js
  43. 69
    0
      react/features/polls/components/web/PollAnswer.js
  44. 248
    0
      react/features/polls/components/web/PollCreate.js
  45. 36
    0
      react/features/polls/components/web/PollItem.js
  46. 80
    0
      react/features/polls/components/web/PollResults.js
  47. 48
    0
      react/features/polls/components/web/PollsList.js
  48. 39
    0
      react/features/polls/components/web/PollsPane.js
  49. 6
    0
      react/features/polls/components/web/index.js
  50. 5
    0
      react/features/polls/constants.js
  51. 23
    0
      react/features/polls/functions.js
  52. 32
    0
      react/features/polls/middleware.js
  53. 128
    0
      react/features/polls/reducer.js
  54. 125
    0
      react/features/polls/subscriber.js
  55. 61
    0
      react/features/polls/types.js
  56. 126
    0
      resources/prosody-plugins/mod_polls.lua

+ 3
- 0
config.js Zobrazit soubor

@@ -73,6 +73,9 @@ var config = {
73 73
     // Enables reactions feature.
74 74
     // enableReactions: false,
75 75
 
76
+    // Disables polls feature.
77
+    // disablePolls: false,
78
+
76 79
     // Disables ICE/UDP by filtering out local and remote UDP candidates in
77 80
     // signalling.
78 81
     // webrtcIceUdpDisable: false,

+ 38
- 0
css/_chat.scss Zobrazit soubor

@@ -574,3 +574,41 @@
574 574
     background: #36383C;
575 575
     border-radius: 3px;
576 576
 }
577
+
578
+.chat-tabs-container {
579
+    width: 100%;
580
+    border-bottom: thin solid #292929;
581
+    display: flex;
582
+    justify-content: space-around;
583
+}
584
+
585
+.chat-tab {
586
+    font-size: 1.2em;
587
+    padding-bottom: 0.5em;
588
+    width: 50%;
589
+    text-align: center;
590
+    color: #8B8B8B;
591
+    cursor: pointer;
592
+}
593
+
594
+.chat-tab-focus {
595
+    border-bottom-style: solid;
596
+    color: #FFF;
597
+}
598
+
599
+.chat-tab-title {
600
+    margin-right: 8px;
601
+}
602
+
603
+.chat-tab-badge {
604
+    background-color: #165ecc;
605
+    border-radius: 50%;
606
+    box-sizing: border-box;
607
+    font-weight: 700;
608
+    overflow: hidden;
609
+    text-align: center;
610
+    text-overflow: ellipsis;
611
+    vertical-align: middle;
612
+    padding: 0 4px;
613
+    color: #FFF;
614
+}

+ 448
- 0
css/_polls.scss Zobrazit soubor

@@ -0,0 +1,448 @@
1
+.poll-dialog {
2
+    font-size: 1rem;
3
+
4
+    h1, span, li, strong {
5
+        color: #bce;
6
+    }
7
+    ol {
8
+        margin: 0;
9
+    }
10
+}
11
+
12
+.poll-question-field {
13
+    padding: 8px 16px;
14
+    padding-bottom: 24px;
15
+    border-bottom: 1px solid #525252;
16
+}
17
+
18
+.poll-header {
19
+    padding: 8px 16px;
20
+}
21
+
22
+.poll-answer-container{
23
+    padding: 8px;
24
+    background: #3D3D3D;
25
+    border-radius: 3px;
26
+    margin-bottom: 8px;
27
+}
28
+
29
+.poll-answer-field-list, .poll-answer-list, .poll-result-list {
30
+    list-style-type: none;
31
+    padding: 0 16px;
32
+    margin: 0;
33
+}
34
+
35
+ol.poll-result-list {
36
+    margin-bottom: 1.5em;
37
+}
38
+
39
+.poll-result-list > li {
40
+    margin-bottom: 8px;
41
+}
42
+
43
+.poll-answer-field {
44
+    flex-direction: column;
45
+    align-items: stretch;
46
+    margin-bottom: 16;
47
+
48
+}
49
+
50
+.poll-answer-field:last-child {
51
+    margin-bottom: 0;
52
+}
53
+
54
+.poll-create-option-row {
55
+    display: 'flex';
56
+    margin-bottom: 4;
57
+}
58
+
59
+// Needeed to override atlaskit default blue color
60
+.poll-create-container .jsYMHu {
61
+    background: #292929;
62
+    border-color: #808090;
63
+    color: white // #808090
64
+}
65
+
66
+.poll-add-button {
67
+    display: flex;
68
+    justify-content: center;
69
+    padding: 8px 16px;
70
+}
71
+
72
+.poll-remove-option-button {
73
+    background: 0 0;
74
+    border: none;
75
+    color: #8B8B8B;
76
+    padding-left: 0;
77
+}
78
+
79
+.poll-create-add-option {
80
+    border: none;
81
+    background-color: #292929;
82
+    padding: 3px;
83
+    width: 100%;
84
+}
85
+
86
+.poll-icon-button, .poll-drag-handle {
87
+    .jitsi-icon svg {
88
+        fill: #bce;
89
+    }
90
+}
91
+
92
+.poll-drag-handle {
93
+    background-color: transparent;
94
+    border: none;
95
+    cursor: grab;
96
+    padding-left: 8;
97
+    display: flex;
98
+}
99
+
100
+.poll-dragged {
101
+    opacity: 0.5;
102
+    * {
103
+        cursor: grabbing !important;
104
+    }
105
+}
106
+
107
+.poll-question {
108
+    font-size: 1.2em;
109
+    font-weight: 600;
110
+    margin-bottom: 0.5em;
111
+}
112
+
113
+.poll-answer-voters {
114
+    font-size: 1em;
115
+    font-weight: lighter;
116
+    list-style-type: none;
117
+    border: #616161 solid 1px;
118
+    border-radius: 3px;
119
+    padding: 2px 6px;
120
+    margin: 4px 0px 12px;
121
+    background-color: #616161;
122
+}
123
+
124
+.poll-answer-header {
125
+    display: flex;
126
+    justify-content: space-between;
127
+}
128
+
129
+.poll-answer-vote-name {
130
+    flex-shrink: 1;
131
+    overflow-wrap: anywhere
132
+}
133
+
134
+.poll-answer-vote-count-container{
135
+    display: flex;
136
+}
137
+
138
+.poll-answer-vote-count {
139
+    margin-left: 10px;
140
+    white-space: nowrap;
141
+    flex: 1;
142
+    text-align: right;
143
+}
144
+
145
+.poll-answer-short-results{
146
+    display: flex;
147
+    min-width: 10em;
148
+    justify-content: space-between;
149
+    align-items: center;
150
+}
151
+
152
+.poll-bar-container, .poll-bar {
153
+    border-radius: 3px;
154
+    height: 6px;
155
+}
156
+
157
+.poll-bar-container {
158
+    background-color: #616161;
159
+    max-width: 160px;
160
+    margin-top: 3px;
161
+    flex: 1;
162
+}
163
+
164
+.poll-bar {
165
+    background-color: #246FE5;
166
+}
167
+
168
+.poll-message-footer {
169
+    display: flex;
170
+    justify-content: space-between;
171
+    align-items: center;
172
+    font-size: 12px;
173
+    margin-top: 5px;
174
+}
175
+
176
+.poll-notice {
177
+    font-weight: 100;
178
+    margin-right: 10px;
179
+}
180
+
181
+.poll-show-details {
182
+    background-color: transparent;
183
+    border: none;
184
+
185
+    &:hover {
186
+        text-decoration: underline;
187
+    }
188
+}
189
+
190
+.poll-result-links {
191
+    display: flex;
192
+    flex-direction: row;
193
+    justify-content: space-between;
194
+}
195
+
196
+a.poll-detail-link, a.poll-change-vote-link {
197
+    color: #246FE5;
198
+    cursor: pointer;
199
+    text-decoration: none;
200
+}
201
+
202
+.polls-pane-content {
203
+    display: flex;
204
+    flex-direction: column;
205
+    font-weight: 600;
206
+    height: 85%;
207
+    align-items: stretch;
208
+}
209
+
210
+.pane-content{
211
+    display: flex;
212
+    flex-direction: column;
213
+    justify-content: center;
214
+    align-items: center;
215
+    width: 100%;
216
+    height: 100%;
217
+}
218
+
219
+.empty-pane-icon {
220
+    width: 50%;
221
+    padding: 24px;
222
+}
223
+
224
+.empty-pane-icon svg {
225
+    fill: #3D3D3D;
226
+    width: 100%;
227
+    height: auto;
228
+}
229
+
230
+.empty-pane-message {
231
+    text-align: center;
232
+}
233
+
234
+.poll-results {
235
+    color: white;
236
+}
237
+
238
+.poll-answer {
239
+    h1, strong ,span {
240
+        color: white;
241
+    }
242
+}
243
+
244
+.poll-results, .poll-answer {
245
+    margin-bottom: 16px;
246
+    background: #292929;
247
+    border-radius: 8px;
248
+    padding: 12px 8px;
249
+    border-width: thin;
250
+    border-style: solid;
251
+    border-color: #616161;
252
+}
253
+
254
+.poll-create-label {
255
+    color: white;
256
+    margin-bottom: 4;
257
+    display: flex;
258
+}
259
+
260
+.expandable-input{
261
+    resize: none;
262
+    width: 100%;
263
+    height: 40px;
264
+    box-sizing: border-box;
265
+    overflow: hidden;
266
+    border: 1px solid #666666;
267
+    background-color: #141414;
268
+    color: #FFF;
269
+    border-radius: 6px;
270
+    padding: 10px 16px;
271
+}
272
+
273
+.poll-container {
274
+    box-sizing: border-box;
275
+    flex: 1;
276
+    overflow-y: auto;
277
+    position: relative;
278
+    padding: 16px;
279
+
280
+    & > * + *:not(.ignore-child) {
281
+        margin-top: 16px;
282
+    }
283
+
284
+    &::-webkit-scrollbar {
285
+        display: none;
286
+    }
287
+}
288
+
289
+.poll-create-header {
290
+    font-size: 20px;
291
+    margin: 20px 16px;
292
+    font-weight: 600;
293
+}
294
+
295
+.poll-create-container {
296
+    padding: 8px 0;
297
+}
298
+
299
+.poll-footer {
300
+    display: flex;
301
+    justify-content: flex-end;
302
+    padding: 8px 16px;
303
+    height: 40px;
304
+    align-items: stretch;
305
+
306
+    & > *:not(:last-child) {
307
+        margin-right: 16px;
308
+    }
309
+}
310
+
311
+.poll-primary-button {
312
+    align-items: center;
313
+    background-color: #0056E0;
314
+    border: 0;
315
+    border-radius: 6px;
316
+    display: flex;
317
+    font-weight: unset;
318
+    justify-content: center;
319
+    font-size: 15px;
320
+    flex: 1;
321
+
322
+    &:hover {
323
+        background-color: #246FE5;
324
+    }
325
+
326
+    &:active {
327
+        background-color: #0045B3;
328
+    }
329
+
330
+    &:focus {
331
+        background-color: #0045B3;
332
+        border: 3px solid #99BBF3;
333
+    }
334
+
335
+    &:disabled {
336
+        background-color: #00225A;
337
+        color: #858585;
338
+    }
339
+
340
+    & > *:not(:last-child) {
341
+        margin-right: 8px;
342
+    }
343
+}
344
+
345
+.poll-secondary-button {
346
+    align-items: center;
347
+    background-color: #3D3D3D;
348
+    border: 0;
349
+    border-radius: 6px;
350
+    display: flex;
351
+    font-weight: unset;
352
+    justify-content: center;
353
+    font-size: 15px;
354
+    height: 40px;
355
+    width: 100%;
356
+
357
+    &:hover {
358
+        background-color: #525252;
359
+    }
360
+
361
+    &:active {
362
+        background-color: #292929;
363
+    }
364
+
365
+    &:focus {
366
+        background-color: #292929;
367
+        border: 3px solid #858585;
368
+    }
369
+
370
+    &:disabled {
371
+        background-color: #141414;
372
+        color: #858585;
373
+    }
374
+
375
+    & > *:not(:last-child) {
376
+        margin-right: 8px;
377
+    }
378
+}
379
+
380
+.poll-small-primary-button {
381
+    align-items: center;
382
+    background-color: #0056E0;
383
+    border: 0;
384
+    border-radius: 6px;
385
+    display: flex;
386
+    font-weight: unset;
387
+    justify-content: center;
388
+    font-size: 15px;
389
+    height: 40px;
390
+    width: 50%;
391
+
392
+    &:hover {
393
+        background-color: #246FE5;
394
+    }
395
+
396
+    &:active {
397
+        background-color: #0045B3;
398
+    }
399
+
400
+    &:focus {
401
+        background-color: #0045B3;
402
+        border: 3px solid #99BBF3;
403
+    }
404
+
405
+    &:disabled {
406
+        background-color: #00225A;
407
+        color: #858585;
408
+    }
409
+
410
+    & > *:not(:last-child) {
411
+        margin-right: 8px;
412
+    }
413
+}
414
+
415
+.poll-small-secondary-button {
416
+    align-items: center;
417
+    background-color: #3D3D3D;
418
+    border: 0;
419
+    border-radius: 6px;
420
+    display: flex;
421
+    font-weight: unset;
422
+    justify-content: center;
423
+    font-size: 15px;
424
+    height: 40px;
425
+    width: 50%;
426
+
427
+    &:hover {
428
+        background-color: #525252;
429
+    }
430
+
431
+    &:active {
432
+        background-color: #292929;
433
+    }
434
+
435
+    &:focus {
436
+        background-color: #292929;
437
+        border: 3px solid #858585;
438
+    }
439
+
440
+    &:disabled {
441
+        background-color: #141414;
442
+        color: #858585;
443
+    }
444
+
445
+    & > *:not(:last-child) {
446
+        margin-right: 8px;
447
+    }
448
+}

+ 1
- 0
css/main.scss Zobrazit soubor

@@ -108,5 +108,6 @@ $flagsImagePath: "../images/";
108 108
 @import 'participants-pane';
109 109
 @import 'reactions-menu';
110 110
 @import 'plan-limit';
111
+@import 'polls';
111 112
 
112 113
 /* Modules END */

+ 1
- 0
doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example Zobrazit soubor

@@ -65,6 +65,7 @@ Component "conference.jitmeet.example.com" "muc"
65 65
     modules_enabled = {
66 66
         "muc_meeting_id";
67 67
         "muc_domain_mapper";
68
+        "polls";
68 69
         --"token_verification";
69 70
     }
70 71
     admins = { "focusUser@auth.jitmeet.example.com" }

+ 37
- 5
lang/main-fr.json Zobrazit soubor

@@ -58,7 +58,7 @@
58 58
     "today": "Aujourd'hui"
59 59
   },
60 60
   "chat": {
61
-    "enter": "Entrez dans le salon de chat",
61
+    "enter": "Entrez dans le salon",
62 62
     "error": "Erreur : votre message n'a pas été envoyé. Raison : {{error}}",
63 63
     "fieldPlaceHolder": "Tapez votre message ici",
64 64
     "messagebox": "Saisissez un message",
@@ -66,15 +66,19 @@
66 66
     "noMessagesMessage": "Il n'y a pas encore de messages dans cette réunion. Démarrez une conversation ici !",
67 67
     "nickname": {
68 68
       "popover": "Choisissez un pseudonyme",
69
-      "title": "Entrez un pseudonyme pour utiliser le chat"
69
+      "title": "Entrez un pseudonyme pour utiliser le chat et les sondages"
70 70
     },
71 71
     "privateNotice": "Message privé à {{recipient}}",
72
-    "title": "Chat",
73
-    "you": "vous",
74 72
     "message": "Message",
75 73
     "messageAccessibleTitle": "{{user}} dit: ",
76 74
     "messageAccessibleTitleMe": "Je dis: ",
77
-    "smileysPanel": "Panneaux des Émojis"
75
+    "smileysPanel": "Panneaux des Émojis",
76
+    "tabs": {
77
+            "chat": "Chat",
78
+            "polls": "Sondages"
79
+        },
80
+        "title": "Chat et Sondages",
81
+        "you": "vous"
78 82
   },
79 83
   "chromeExtensionBanner": {
80 84
     "installExtensionText": "Installer l'extension pour l'intégration de Google Calendar et Office 365",
@@ -568,6 +572,34 @@
568 572
   },
569 573
   "passwordSetRemotely": "défini par un autre participant",
570 574
   "passwordDigitsOnly": "Jusqu'à {{number}} chiffres",
575
+  "polls": {
576
+    "create": {
577
+      "addOption": "Ajouter une option",
578
+      "answerPlaceholder": "Option {{index}}",
579
+      "create": "Créer un sondage",
580
+      "cancel": "Annuler",
581
+      "pollOption" : "Option {{index}}",
582
+      "pollQuestion" : "Question du sondage",
583
+      "questionPlaceholder": "Poser une question",
584
+      "removeOption": "Supprimer l'option",
585
+      "send": "Envoyer"
586
+    },
587
+    "answer": {
588
+      "skip": "Passer",
589
+      "submit": "Envoyer"
590
+    },
591
+    "results": {
592
+      "vote": "Voter",
593
+      "changeVote": "Changer le vote",
594
+      "empty": "Il n'y a pas encore de sondages dans cette réunion. Démarrez un sondage ici !",
595
+      "hideDetailedResults": "Cacher les détails",
596
+      "showDetailedResults": "Montrer les détails"
597
+    },
598
+    "notification": {
599
+      "title": "Un nouveau sondage a été ajouté à la réunion",
600
+      "description": "Ouvrez l'onget des sondages pour voter"
601
+    }
602
+  },
571 603
   "poweredby": "produit par",
572 604
   "prejoin": {
573 605
     "audioAndVideoError": "Erreur audio et video:",

+ 37
- 5
lang/main.json Zobrazit soubor

@@ -58,7 +58,7 @@
58 58
         "today": "Today"
59 59
     },
60 60
     "chat": {
61
-        "enter": "Enter chat room",
61
+        "enter": "Enter room",
62 62
         "error": "Error: your message was not sent. Reason: {{error}}",
63 63
         "fieldPlaceHolder": "Type your message here",
64 64
         "messagebox": "Type a message",
@@ -66,15 +66,19 @@
66 66
         "noMessagesMessage": "There are no messages in the meeting yet. Start a conversation here!",
67 67
         "nickname": {
68 68
             "popover": "Choose a nickname",
69
-            "title": "Enter a nickname to use chat"
69
+            "title": "Enter a nickname to use chat and polls"
70 70
         },
71 71
         "privateNotice": "Private message to {{recipient}}",
72
-        "title": "Chat",
73
-        "you": "you",
74 72
         "message": "Message",
75 73
         "messageAccessibleTitle": "{{user}} says:",
76 74
         "messageAccessibleTitleMe": "me says:",
77
-        "smileysPanel": "Emoji panel"
75
+        "smileysPanel": "Emoji panel",
76
+        "tabs": {
77
+            "chat": "Chat",
78
+            "polls": "Polls"
79
+        },
80
+        "title": "Chat and Polls",
81
+        "you": "you"
78 82
     },
79 83
     "chromeExtensionBanner": {
80 84
         "installExtensionText": "Install the extension for Google Calendar and Office 365 integration",
@@ -614,6 +618,34 @@
614 618
     },
615 619
     "passwordSetRemotely": "Set by another participant",
616 620
     "passwordDigitsOnly": "Up to {{number}} digits",
621
+    "polls": {
622
+        "create": {
623
+            "addOption": "Add option",
624
+            "answerPlaceholder": "Option {{index}}",
625
+            "create": "Create a poll",
626
+            "cancel": "Cancel",
627
+            "pollOption" : "Poll option {{index}}",
628
+            "pollQuestion" : "Poll Question",
629
+            "questionPlaceholder": "Ask a question",
630
+            "removeOption": "Remove option",
631
+            "send": "Send"
632
+        },
633
+        "answer": {
634
+            "skip": "Skip",
635
+            "submit": "Submit"
636
+        },
637
+        "results": {
638
+            "vote": "Vote",
639
+            "changeVote": "Change vote",
640
+            "empty": "There are no polls in the meeting yet. Start a poll here!",
641
+            "hideDetailedResults": "Hide details",
642
+            "showDetailedResults": "Show details"
643
+        },
644
+        "notification": {
645
+            "title": "A new poll was added to this meeting",
646
+            "description": "Open polls tab to vote"
647
+        }
648
+    },
617 649
     "poweredby": "powered by",
618 650
     "prejoin": {
619 651
         "audioAndVideoError": "Audio and video error:",

+ 2
- 0
react/features/app/middlewares.any.js Zobrazit soubor

@@ -34,6 +34,8 @@ import '../large-video/middleware';
34 34
 import '../lobby/middleware';
35 35
 import '../notifications/middleware';
36 36
 import '../overlay/middleware';
37
+import '../polls/middleware';
38
+import '../polls/subscriber';
37 39
 import '../reactions/middleware';
38 40
 import '../recent-list/middleware';
39 41
 import '../recording/middleware';

+ 1
- 0
react/features/app/reducers.any.js Zobrazit soubor

@@ -41,6 +41,7 @@ import '../lobby/reducer';
41 41
 import '../notifications/reducer';
42 42
 import '../overlay/reducer';
43 43
 import '../participants-pane/reducer';
44
+import '../polls/reducer';
44 45
 import '../reactions/reducer';
45 46
 import '../recent-list/reducer';
46 47
 import '../recording/reducer';

+ 1
- 0
react/features/base/config/configWhitelist.js Zobrazit soubor

@@ -91,6 +91,7 @@ export default [
91 91
     'disableJoinLeaveSounds',
92 92
     'disableLocalVideoFlip',
93 93
     'disableNS',
94
+    'disablePolls',
94 95
     'disableProfile',
95 96
     'disableRemoteControl',
96 97
     'disableRemoteMute',

+ 13
- 8
react/features/base/dialog/components/native/BaseDialog.js Zobrazit soubor

@@ -50,7 +50,7 @@ class BaseDialog<P: Props, S: State> extends AbstractDialog<P, S> {
50 50
      * @returns {ReactElement}
51 51
      */
52 52
     render() {
53
-        const { _dialogStyles, style } = this.props;
53
+        const { _dialogStyles, style, t, titleKey } = this.props;
54 54
 
55 55
         return (
56 56
             <TouchableWithoutFeedback>
@@ -65,13 +65,18 @@ class BaseDialog<P: Props, S: State> extends AbstractDialog<P, S> {
65 65
                             _dialogStyles.dialog,
66 66
                             style
67 67
                         ] }>
68
-                        <TouchableOpacity
69
-                            onPress = { this._onCancel }
70
-                            style = { styles.closeWrapper }>
71
-                            <Icon
72
-                                src = { IconClose }
73
-                                style = { _dialogStyles.closeStyle } />
74
-                        </TouchableOpacity>
68
+                        <View style = { styles.headerWrapper }>
69
+                            <Text style = { styles.dialogTitle }>
70
+                                { titleKey ? t(titleKey) : ' ' }
71
+                            </Text>
72
+                            <TouchableOpacity
73
+                                onPress = { this._onCancel }
74
+                                style = { styles.closeWrapper }>
75
+                                <Icon
76
+                                    src = { IconClose }
77
+                                    style = { _dialogStyles.closeStyle } />
78
+                            </TouchableOpacity>
79
+                        </View>
75 80
                         { this._renderContent() }
76 81
                     </View>
77 82
                 </KeyboardAvoidingView>

+ 8
- 2
react/features/base/dialog/components/native/ConfirmDialog.js Zobrazit soubor

@@ -28,6 +28,12 @@ type Props = BaseProps & {
28 28
      */
29 29
     contentKey: string | { key: string, params: Object},
30 30
 
31
+    /**
32
+     * The handler for the event when clicking the 'confirmNo' button.
33
+     * Defaults to onCancel if absent.
34
+     */
35
+    onDecline?: Function,
36
+
31 37
     t: Function
32 38
 }
33 39
 
@@ -55,11 +61,11 @@ class ConfirmDialog extends BaseSubmitDialog<Props, *> {
55 61
      * @inheritdoc
56 62
      */
57 63
     _renderAdditionalButtons() {
58
-        const { _dialogStyles, cancelKey, t } = this.props;
64
+        const { _dialogStyles, cancelKey, onDecline, t } = this.props;
59 65
 
60 66
         return (
61 67
             <TouchableOpacity
62
-                onPress = { this._onCancel }
68
+                onPress = { onDecline || this._onCancel }
63 69
                 style = { [
64 70
                     _dialogStyles.button,
65 71
                     brandedDialog.buttonFarLeft,

+ 11
- 1
react/features/base/dialog/components/native/styles.js Zobrazit soubor

@@ -81,10 +81,20 @@ export const brandedDialog = {
81 81
     },
82 82
 
83 83
     closeWrapper: {
84
-        alignSelf: 'flex-end',
85 84
         padding: BoxModel.padding
86 85
     },
87 86
 
87
+    dialogTitle: {
88
+        fontWeight: 'bold',
89
+        paddingLeft: BoxModel.padding * 2
90
+    },
91
+
92
+    headerWrapper: {
93
+        alignItems: 'center',
94
+        flexDirection: 'row',
95
+        justifyContent: 'space-between'
96
+    },
97
+
88 98
     mainWrapper: {
89 99
         alignSelf: 'stretch',
90 100
         padding: BoxModel.padding * 2,

+ 9
- 2
react/features/base/dialog/components/web/StatelessDialog.js Zobrazit soubor

@@ -71,6 +71,12 @@ type Props = {
71 71
      */
72 72
     isModal: boolean,
73 73
 
74
+    /**
75
+     * The handler for the event when clicking the 'confirmNo' button.
76
+     * Defaults to onCancel if absent.
77
+     */
78
+    onDecline?: Function,
79
+
74 80
     /**
75 81
      * Disables rendering of the submit button.
76 82
      */
@@ -268,7 +274,8 @@ class StatelessDialog extends Component<Props> {
268 274
         }
269 275
 
270 276
         const {
271
-            t /* The following fixes a flow error: */ = _.identity
277
+            t /* The following fixes a flow error: */ = _.identity,
278
+            onDecline
272 279
         } = this.props;
273 280
 
274 281
         return (
@@ -276,7 +283,7 @@ class StatelessDialog extends Component<Props> {
276 283
                 appearance = 'subtle'
277 284
                 id = { CANCEL_BUTTON_ID }
278 285
                 key = 'cancel'
279
-                onClick = { this._onCancel }
286
+                onClick = { onDecline || this._onCancel }
280 287
                 type = 'button'>
281 288
                 { t(this.props.cancelKey || 'dialog.Cancel') }
282 289
             </Button>

+ 10
- 0
react/features/chat/actionTypes.js Zobrazit soubor

@@ -64,3 +64,13 @@ export const SEND_MESSAGE = 'SEND_MESSAGE';
64 64
  * }
65 65
  */
66 66
 export const SET_PRIVATE_MESSAGE_RECIPIENT = 'SET_PRIVATE_MESSAGE_RECIPIENT';
67
+
68
+/**
69
+ * The type of action which signals the update a _isPollsTabFocused.
70
+ *
71
+ * {
72
+ *     isPollsTabFocused: boolean,
73
+ *     type: SET_PRIVATE_MESSAGE_RECIPIENT
74
+ * }
75
+ */
76
+export const SET_IS_POLL_TAB_FOCUSED = 'SET_IS_POLL_TAB_FOCUSED';

+ 15
- 1
react/features/chat/actions.any.js Zobrazit soubor

@@ -5,7 +5,8 @@ import {
5 5
     CLEAR_MESSAGES,
6 6
     CLOSE_CHAT,
7 7
     SEND_MESSAGE,
8
-    SET_PRIVATE_MESSAGE_RECIPIENT
8
+    SET_PRIVATE_MESSAGE_RECIPIENT,
9
+    SET_IS_POLL_TAB_FOCUSED
9 10
 } from './actionTypes';
10 11
 
11 12
 /**
@@ -97,3 +98,16 @@ export function setPrivateMessageRecipient(participant: Object) {
97 98
         type: SET_PRIVATE_MESSAGE_RECIPIENT
98 99
     };
99 100
 }
101
+
102
+/**
103
+ * Set the value of _isPollsTabFocused.
104
+ *
105
+ * @param {boolean} isPollsTabFocused - The new value for _isPollsTabFocused.
106
+ * @returns {Function}
107
+ */
108
+export function setIsPollsTabFocused(isPollsTabFocused: boolean) {
109
+    return {
110
+        isPollsTabFocused,
111
+        type: SET_IS_POLL_TAB_FOCUSED
112
+    };
113
+}

+ 69
- 3
react/features/chat/components/AbstractChat.js Zobrazit soubor

@@ -4,7 +4,7 @@ import { Component } from 'react';
4 4
 import type { Dispatch } from 'redux';
5 5
 
6 6
 import { getLocalParticipant } from '../../base/participants';
7
-import { sendMessage } from '../actions';
7
+import { sendMessage, setIsPollsTabFocused } from '../actions';
8 8
 import { SMALL_WIDTH_THRESHOLD } from '../constants';
9 9
 
10 10
 /**
@@ -22,11 +22,31 @@ export type Props = {
22 22
      */
23 23
     _isOpen: boolean,
24 24
 
25
+    /**
26
+     * True if the polls feature is enabled.
27
+     */
28
+    _isPollsEnabled: boolean,
29
+
30
+    /**
31
+     * Whether the poll tab is focused or not.
32
+     */
33
+    _isPollsTabFocused: boolean,
34
+
25 35
     /**
26 36
      * All the chat messages in the conference.
27 37
      */
28 38
     _messages: Array<Object>,
29 39
 
40
+    /**
41
+     * Number of unread chat messages.
42
+     */
43
+    _nbUnreadMessages: number,
44
+
45
+    /**
46
+     * Number of unread poll messages.
47
+     */
48
+    _nbUnreadPolls: number,
49
+
30 50
     /**
31 51
      * Function to send a text message.
32 52
      *
@@ -34,6 +54,20 @@ export type Props = {
34 54
      */
35 55
     _onSendMessage: Function,
36 56
 
57
+    /**
58
+     * Function to display the chat tab.
59
+     *
60
+     * @protected
61
+     */
62
+    _onToggleChatTab: Function,
63
+
64
+    /**
65
+     * Function to display the polls tab.
66
+     *
67
+     * @protected
68
+     */
69
+    _onTogglePollsTab: Function,
70
+
37 71
     /**
38 72
      * Function to toggle the chat window.
39 73
      */
@@ -52,7 +86,7 @@ export type Props = {
52 86
     /**
53 87
      * Function to be used to translate i18n labels.
54 88
      */
55
-    t: Function
89
+    t: Function,
56 90
 };
57 91
 
58 92
 /**
@@ -71,6 +105,8 @@ export default class AbstractChat<P: Props> extends Component<P> {
71 105
 
72 106
         // Bind event handlers so they are only bound once per instance.
73 107
         this._onSendMessage = this._onSendMessage.bind(this);
108
+        this._onToggleChatTab = this._onToggleChatTab.bind(this);
109
+        this._onTogglePollsTab = this._onTogglePollsTab.bind(this);
74 110
     }
75 111
 
76 112
     _onSendMessage: (string) => void;
@@ -86,6 +122,30 @@ export default class AbstractChat<P: Props> extends Component<P> {
86 122
     _onSendMessage(text: string) {
87 123
         this.props.dispatch(sendMessage(text));
88 124
     }
125
+
126
+    _onToggleChatTab: () => void;
127
+
128
+    /**
129
+     * Display the Chat tab.
130
+     *
131
+     * @private
132
+     * @returns {void}
133
+     */
134
+    _onToggleChatTab() {
135
+        this.props.dispatch(setIsPollsTabFocused(false));
136
+    }
137
+
138
+    _onTogglePollsTab: () => void;
139
+
140
+    /**
141
+     * Display the Polls tab.
142
+     *
143
+     * @private
144
+     * @returns {void}
145
+     */
146
+    _onTogglePollsTab() {
147
+        this.props.dispatch(setIsPollsTabFocused(true));
148
+    }
89 149
 }
90 150
 
91 151
 /**
@@ -101,13 +161,19 @@ export default class AbstractChat<P: Props> extends Component<P> {
101 161
  * }}
102 162
  */
103 163
 export function _mapStateToProps(state: Object) {
104
-    const { isOpen, messages } = state['features/chat'];
164
+    const { isOpen, isPollsTabFocused, messages, nbUnreadMessages } = state['features/chat'];
165
+    const { nbUnreadPolls } = state['features/polls'];
105 166
     const _localParticipant = getLocalParticipant(state);
167
+    const { disablePolls } = state['features/base/config'];
106 168
 
107 169
     return {
108 170
         _isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
109 171
         _isOpen: isOpen,
172
+        _isPollsEnabled: !disablePolls,
173
+        _isPollsTabFocused: isPollsTabFocused,
110 174
         _messages: messages,
175
+        _nbUnreadMessages: nbUnreadMessages,
176
+        _nbUnreadPolls: nbUnreadPolls,
111 177
         _showNamePrompt: !_localParticipant?.name
112 178
     };
113 179
 }

+ 51
- 5
react/features/chat/components/native/Chat.js Zobrazit soubor

@@ -1,12 +1,15 @@
1 1
 // @flow
2 2
 
3 3
 import React from 'react';
4
+import { View } from 'react-native';
5
+import { Button } from 'react-native-paper';
4 6
 
5 7
 import { translate } from '../../../base/i18n';
6 8
 import { JitsiModal } from '../../../base/modal';
7 9
 import { connect } from '../../../base/redux';
10
+import { PollsPane } from '../../../polls/components';
8 11
 import { closeChat } from '../../actions.any';
9
-import { CHAT_VIEW_MODAL_ID } from '../../constants';
12
+import { BUTTON_MODES, CHAT_VIEW_MODAL_ID } from '../../constants';
10 13
 import AbstractChat, {
11 14
     _mapStateToProps,
12 15
     type Props
@@ -15,6 +18,7 @@ import AbstractChat, {
15 18
 import ChatInputBar from './ChatInputBar';
16 19
 import MessageContainer from './MessageContainer';
17 20
 import MessageRecipient from './MessageRecipient';
21
+import styles from './styles';
18 22
 
19 23
 /**
20 24
  * Implements a React native component that renders the chat window (modal) of
@@ -45,10 +49,49 @@ class Chat extends AbstractChat<Props> {
45 49
                 }}
46 50
                 modalId = { CHAT_VIEW_MODAL_ID }
47 51
                 onClose = { this._onClose }>
48
-
49
-                <MessageContainer messages = { this.props._messages } />
50
-                <MessageRecipient />
51
-                <ChatInputBar onSend = { this._onSendMessage } />
52
+                {this.props._isPollsEnabled && <View style = { styles.tabContainer }>
53
+                    <Button
54
+                        color = '#17a0db'
55
+                        mode = {
56
+                            this.props._isPollsTabFocused
57
+                                ? BUTTON_MODES.CONTAINED
58
+                                : BUTTON_MODES.TEXT
59
+                        }
60
+                        onPress = { this._onToggleChatTab }
61
+                        style = { styles.tabLeftButton }
62
+                        uppercase = { false }>
63
+                        {`${this.props.t('chat.tabs.chat')}${this.props._isPollsTabFocused
64
+                                && this.props._nbUnreadMessages > 0
65
+                            ? `(${this.props._nbUnreadMessages})`
66
+                            : ''
67
+                        }`}
68
+                    </Button>
69
+                    <Button
70
+                        color = '#17a0db'
71
+                        mode = {
72
+                            this.props._isPollsTabFocused
73
+                                ? BUTTON_MODES.TEXT
74
+                                : BUTTON_MODES.CONTAINED
75
+                        }
76
+                        onPress = { this._onTogglePollsTab }
77
+                        style = { styles.tabRightButton }
78
+                        uppercase = { false }>
79
+                        {`${this.props.t('chat.tabs.polls')}${!this.props._isPollsTabFocused
80
+                                && this.props._nbUnreadPolls > 0
81
+                            ? `(${this.props._nbUnreadPolls})`
82
+                            : ''
83
+                        }`}
84
+                    </Button>
85
+                </View>}
86
+                {this.props._isPollsTabFocused
87
+                    ? <PollsPane />
88
+                    : (
89
+                    <>
90
+                        <MessageContainer messages = { this.props._messages } />
91
+                        <MessageRecipient />
92
+                        <ChatInputBar onSend = { this._onSendMessage } />
93
+                    </>
94
+                    )}
52 95
             </JitsiModal>
53 96
         );
54 97
     }
@@ -57,6 +100,9 @@ class Chat extends AbstractChat<Props> {
57 100
 
58 101
     _onClose: () => boolean
59 102
 
103
+    _onTogglePollsTab: () => void;
104
+    _onToggleChatTab: () => void;
105
+
60 106
     /**
61 107
      * Closes the modal.
62 108
      *

+ 19
- 0
react/features/chat/components/native/styles.js Zobrazit soubor

@@ -124,6 +124,25 @@ export default {
124 124
     timeText: {
125 125
         color: 'rgb(164, 184, 209)',
126 126
         fontSize: 13
127
+    },
128
+
129
+    tabContainer: {
130
+        flexDirection: 'row',
131
+        justifyContent: 'center'
132
+    },
133
+
134
+    tabLeftButton: {
135
+        flex: 1,
136
+        borderTopLeftRadius: 0,
137
+        borderTopRightRadius: 0,
138
+        borderBottomLeftRadius: 0
139
+    },
140
+
141
+    tabRightButton: {
142
+        flex: 1,
143
+        borderTopLeftRadius: 0,
144
+        borderTopRightRadius: 0,
145
+        borderBottomRightRadius: 0
127 146
     }
128 147
 };
129 148
 

+ 59
- 0
react/features/chat/components/web/Chat.js Zobrazit soubor

@@ -4,6 +4,7 @@ import React from 'react';
4 4
 
5 5
 import { translate } from '../../../base/i18n';
6 6
 import { connect } from '../../../base/redux';
7
+import { PollsPane } from '../../../polls/components';
7 8
 import { toggleChat } from '../../actions.web';
8 9
 import AbstractChat, {
9 10
     _mapStateToProps,
@@ -128,8 +129,20 @@ class Chat extends AbstractChat<Props> {
128 129
      * @returns {ReactElement}
129 130
      */
130 131
     _renderChat() {
132
+
133
+        if (this.props._isPollsTabFocused) {
134
+            return (
135
+                <>
136
+                    { this.props._isPollsEnabled && this._renderTabs()}
137
+                    <PollsPane />
138
+                    <KeyboardAvoider />
139
+                </>
140
+            );
141
+        }
142
+
131 143
         return (
132 144
             <>
145
+                {this.props._isPollsEnabled && this._renderTabs()}
133 146
                 <TouchmoveHack isModal = { this.props._isModal }>
134 147
                     <MessageContainer
135 148
                         messages = { this.props._messages }
@@ -144,6 +157,50 @@ class Chat extends AbstractChat<Props> {
144 157
         );
145 158
     }
146 159
 
160
+    /**
161
+     * Returns a React Element showing the Chat and Polls tab.
162
+     *
163
+     * @private
164
+     * @returns {ReactElement}
165
+     */
166
+    _renderTabs() {
167
+
168
+        return (
169
+            <div className = { 'chat-tabs-container' }>
170
+                <div
171
+                    className = { `chat-tab ${
172
+                        this.props._isPollsTabFocused ? '' : 'chat-tab-focus'
173
+                    }` }
174
+                    onClick = { this._onToggleChatTab }>
175
+                    <span className = { 'chat-tab-title' }>
176
+                        {this.props.t('chat.tabs.chat')}
177
+                    </span>
178
+                    {this.props._isPollsTabFocused
179
+                        && this.props._nbUnreadMessages > 0 && (
180
+                        <span className = { 'chat-tab-badge' }>
181
+                            {this.props._nbUnreadMessages}
182
+                        </span>
183
+                    )}
184
+                </div>
185
+                <div
186
+                    className = { `chat-tab ${
187
+                        this.props._isPollsTabFocused ? 'chat-tab-focus' : ''
188
+                    }` }
189
+                    onClick = { this._onTogglePollsTab }>
190
+                    <span className = { 'chat-tab-title' }>
191
+                        {this.props.t('chat.tabs.polls')}
192
+                    </span>
193
+                    {!this.props._isPollsTabFocused
194
+                        && this.props._nbUnreadPolls > 0 && (
195
+                        <span className = { 'chat-tab-badge' }>
196
+                            {this.props._nbUnreadPolls}
197
+                        </span>
198
+                    )}
199
+                </div>
200
+            </div>
201
+        );
202
+    }
203
+
147 204
     /**
148 205
      * Instantiates a React Element to display at the top of {@code Chat} to
149 206
      * close {@code Chat}.
@@ -233,6 +290,8 @@ class Chat extends AbstractChat<Props> {
233 290
     _onToggleChat() {
234 291
         this.props.dispatch(toggleChat());
235 292
     }
293
+    _onTogglePollsTab: () => void;
294
+    _onToggleChatTab: () => void;
236 295
 
237 296
 }
238 297
 

+ 4
- 1
react/features/chat/components/web/ChatCounter.js Zobrazit soubor

@@ -3,6 +3,7 @@
3 3
 import React, { Component } from 'react';
4 4
 
5 5
 import { connect } from '../../../base/redux';
6
+import { getUnreadPollCount } from '../../../polls/functions';
6 7
 import { getUnreadCount } from '../../functions';
7 8
 
8 9
 /**
@@ -64,8 +65,10 @@ function _mapStateToProps(state) {
64 65
     const { isOpen } = state['features/chat'];
65 66
 
66 67
     return {
67
-        _count: getUnreadCount(state),
68
+
69
+        _count: getUnreadCount(state) + getUnreadPollCount(state),
68 70
         _isOpen: isOpen
71
+
69 72
     };
70 73
 }
71 74
 

+ 8
- 0
react/features/chat/constants.js Zobrazit soubor

@@ -31,3 +31,11 @@ export const MESSAGE_TYPE_LOCAL = 'local';
31 31
 export const MESSAGE_TYPE_REMOTE = 'remote';
32 32
 
33 33
 export const SMALL_WIDTH_THRESHOLD = 580;
34
+
35
+/**
36
+ * The modes of the buttons of the chat and polls tabs.
37
+ */
38
+export const BUTTON_MODES = {
39
+    CONTAINED: 'contained',
40
+    TEXT: 'text'
41
+};

+ 12
- 0
react/features/chat/functions.js Zobrazit soubor

@@ -78,3 +78,15 @@ export function getUnreadCount(state: Object) {
78 78
 
79 79
     return messagesCount - (lastReadIndex + 1);
80 80
 }
81
+
82
+/**
83
+ * Selector for calculating the number of unread chat messages.
84
+ *
85
+ * @param {Object} state - The redux state.
86
+ * @returns {number} The number of unread messages.
87
+ */
88
+export function getUnreadMessagesCount(state: Object) {
89
+    const { nbUnreadMessages } = state['features/chat'];
90
+
91
+    return nbUnreadMessages;
92
+}

+ 15
- 2
react/features/chat/middleware.js Zobrazit soubor

@@ -22,6 +22,7 @@ import {
22 22
 import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
23 23
 import { playSound, registerSound, unregisterSound } from '../base/sounds';
24 24
 import { openDisplayNamePrompt } from '../display-name';
25
+import { resetNbUnreadPollsMessages } from '../polls/actions';
25 26
 import { ADD_REACTION_MESSAGE } from '../reactions/actionTypes';
26 27
 import { pushReactions } from '../reactions/actions.any';
27 28
 import { getReactionMessageFromBuffer } from '../reactions/functions.any';
@@ -33,7 +34,7 @@ import {
33 34
     setToolboxVisible
34 35
 } from '../toolbox/actions.web';
35 36
 
36
-import { ADD_MESSAGE, SEND_MESSAGE, OPEN_CHAT, CLOSE_CHAT } from './actionTypes';
37
+import { ADD_MESSAGE, SEND_MESSAGE, OPEN_CHAT, CLOSE_CHAT, SET_IS_POLL_TAB_FOCUSED } from './actionTypes';
37 38
 import { addMessage, clearMessages } from './actions';
38 39
 import { closeChat } from './actions.any';
39 40
 import { ChatPrivacyDialog } from './components';
@@ -112,15 +113,27 @@ MiddlewareRegistry.register(store => next => action => {
112 113
         }
113 114
         break;
114 115
 
115
-    case CLOSE_CHAT:
116
+    case CLOSE_CHAT: {
117
+        const isPollTabOpen = getState()['features/chat'].isPollsTabFocused;
118
+
116 119
         unreadCount = 0;
117 120
 
118 121
         if (typeof APP !== 'undefined') {
119 122
             APP.API.notifyChatUpdated(unreadCount, false);
120 123
         }
121 124
 
125
+        if (isPollTabOpen) {
126
+            dispatch(resetNbUnreadPollsMessages());
127
+        }
128
+
122 129
         dispatch(setActiveModalId());
123 130
         break;
131
+    }
132
+
133
+    case SET_IS_POLL_TAB_FOCUSED: {
134
+        dispatch(resetNbUnreadPollsMessages());
135
+        break;
136
+    }
124 137
 
125 138
     case SEND_MESSAGE: {
126 139
         const state = store.getState();

+ 13
- 1
react/features/chat/reducer.js Zobrazit soubor

@@ -7,13 +7,17 @@ import {
7 7
     CLEAR_MESSAGES,
8 8
     CLOSE_CHAT,
9 9
     OPEN_CHAT,
10
-    SET_PRIVATE_MESSAGE_RECIPIENT
10
+    SET_PRIVATE_MESSAGE_RECIPIENT,
11
+    SET_IS_POLL_TAB_FOCUSED
11 12
 } from './actionTypes';
12 13
 
13 14
 const DEFAULT_STATE = {
14 15
     isOpen: false,
16
+    isPollsTabFocused: false,
15 17
     lastReadMessage: undefined,
18
+    lastReadPoll: undefined,
16 19
     messages: [],
20
+    nbUnreadMessages: 0,
17 21
     privateMessageRecipient: undefined
18 22
 };
19 23
 
@@ -46,6 +50,7 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
46 50
             ...state,
47 51
             lastReadMessage:
48 52
                 action.hasRead ? newMessage : state.lastReadMessage,
53
+            nbUnreadMessages: state.isPollsTabFocused ? state.nbUnreadMessages + 1 : state.nbUnreadMessages,
49 54
             messages
50 55
         };
51 56
     }
@@ -78,6 +83,13 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, action) => {
78 83
                 navigator.product === 'ReactNative' ? 0 : state.messages.length - 1],
79 84
             privateMessageRecipient: action.participant
80 85
         };
86
+
87
+    case SET_IS_POLL_TAB_FOCUSED: {
88
+        return {
89
+            ...state,
90
+            isPollsTabFocused: action.isPollsTabFocused,
91
+            nbUnreadMessages: 0
92
+        }; }
81 93
     }
82 94
 
83 95
     return state;

+ 55
- 0
react/features/polls/actionTypes.js Zobrazit soubor

@@ -0,0 +1,55 @@
1
+// @flow
2
+
3
+/**
4
+ * The type of the action which signals that a new Poll was received.
5
+ *
6
+ * {
7
+ *     type: RECEIVE_POLL,
8
+ *     poll: Poll,
9
+ *     pollId: string,
10
+ *     notify: boolean
11
+ * }
12
+ *
13
+ */
14
+export const RECEIVE_POLL = 'RECEIVE_POLL';
15
+
16
+/**
17
+ * The type of the action which signals that a new Answer was received.
18
+ *
19
+ * {
20
+ *     type: RECEIVE_ANSWER,
21
+ *     answer: Answer,
22
+ *     pollId: string,
23
+ * }
24
+ */
25
+export const RECEIVE_ANSWER = 'RECEIVE_ANSWER';
26
+
27
+/**
28
+ * The type of the action which registers a vote.
29
+ *
30
+ * {
31
+ *     type: REGISTER_VOTE,
32
+ *     answers: Array<boolean> | null,
33
+ *     pollId: string
34
+ * }
35
+ */
36
+export const REGISTER_VOTE = 'REGISTER_VOTE';
37
+
38
+/**
39
+ * The type of the action which retracts a vote.
40
+ *
41
+ * {
42
+ *     type: RETRACT_VOTE,
43
+ *     pollId: string,
44
+ * }
45
+ */
46
+export const RETRACT_VOTE = 'RETRACT_VOTE';
47
+
48
+/**
49
+ * The type of the action triggered when the poll tab in chat pane is closed
50
+ *
51
+ * {
52
+ *     type: RESET_NB_UNREAD_POLLS,
53
+ * }
54
+ */
55
+export const RESET_NB_UNREAD_POLLS = 'RESET_NB_UNREAD_POLLS';

+ 99
- 0
react/features/polls/actions.js Zobrazit soubor

@@ -0,0 +1,99 @@
1
+// @flow
2
+
3
+import {
4
+    RESET_NB_UNREAD_POLLS,
5
+    RECEIVE_ANSWER,
6
+    RECEIVE_POLL,
7
+    REGISTER_VOTE,
8
+    RETRACT_VOTE
9
+} from './actionTypes';
10
+import type { Answer, Poll } from './types';
11
+
12
+/**
13
+ * Action to signal that a new poll was received.
14
+ *
15
+ * @param {string} pollId - The id of the incoming poll.
16
+ * @param {Poll} poll - The incoming Poll object.
17
+ * @param {boolean} notify - Whether to send or not a notification.
18
+ * @returns {{
19
+ *     type: RECEIVE_POLL,
20
+ *     poll: Poll,
21
+ *     pollId: string,
22
+ *     notify: boolean
23
+ * }}
24
+ */
25
+export const receivePoll = (pollId: string, poll: Poll, notify: boolean) => {
26
+    return {
27
+        type: RECEIVE_POLL,
28
+        poll,
29
+        pollId,
30
+        notify
31
+    };
32
+};
33
+
34
+/**
35
+ * Action to signal that a new answer was received.
36
+ *
37
+ * @param {string} pollId - The id of the incoming poll.
38
+ * @param {Answer} answer - The incoming Answer object.
39
+ * @returns {{
40
+ *     type: RECEIVE_ANSWER,
41
+ *     answer: Answer,
42
+ *     pollId: string
43
+ * }}
44
+ */
45
+export const receiveAnswer = (pollId: string, answer: Answer) => {
46
+    return {
47
+        type: RECEIVE_ANSWER,
48
+        answer,
49
+        pollId
50
+    };
51
+};
52
+
53
+/**
54
+ * Action to register a vote on a poll.
55
+ *
56
+ * @param {string} pollId - The id of the poll.
57
+ * @param {?Array<boolean>} answers - The new answers.
58
+ * @returns {{
59
+ *     type: REGISTER_VOTE,
60
+ *     answers: ?Array<boolean>,
61
+ *     pollId: string
62
+ * }}
63
+ */
64
+export const registerVote = (pollId: string, answers: Array<boolean> | null) => {
65
+    return {
66
+        type: REGISTER_VOTE,
67
+        answers,
68
+        pollId
69
+    };
70
+};
71
+
72
+/**
73
+ * Action to retract a vote on a poll.
74
+ *
75
+ * @param {string} pollId - The id of the poll.
76
+ * @returns {{
77
+ *     type: RETRACT_VOTE,
78
+ *     pollId: string
79
+ * }}
80
+ */
81
+export const retractVote = (pollId: string) => {
82
+    return {
83
+        type: RETRACT_VOTE,
84
+        pollId
85
+    };
86
+};
87
+
88
+/**
89
+ * Action to signal the closing of the polls tab.
90
+ *
91
+ * @returns {{
92
+ *     type: POLL_TAB_CLOSED
93
+ * }}
94
+ */
95
+export function resetNbUnreadPollsMessages() {
96
+    return {
97
+        type: RESET_NB_UNREAD_POLLS
98
+    };
99
+}

+ 101
- 0
react/features/polls/components/AbstractPollAnswer.js Zobrazit soubor

@@ -0,0 +1,101 @@
1
+// @flow
2
+
3
+import React, { useCallback, useState } from 'react';
4
+import type { AbstractComponent } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch, useSelector } from 'react-redux';
7
+
8
+import { getLocalParticipant, getParticipantById } from '../../base/participants';
9
+import { registerVote } from '../actions';
10
+import { COMMAND_ANSWER_POLL } from '../constants';
11
+import type { Poll } from '../types';
12
+
13
+/**
14
+ * The type of the React {@code Component} props of inheriting component.
15
+ */
16
+type InputProps = {
17
+    pollId: string,
18
+};
19
+
20
+/*
21
+ * Props that will be passed by the AbstractPollAnswer to its
22
+ * concrete implementations (web/native).
23
+ **/
24
+export type AbstractProps = {
25
+    checkBoxStates: Function,
26
+    poll: Poll,
27
+    setCheckbox: Function,
28
+    skipAnswer: Function,
29
+    submitAnswer: Function,
30
+    t: Function,
31
+};
32
+
33
+/**
34
+ * Higher Order Component taking in a concrete PollAnswer component and
35
+ * augmenting it with state/behavior common to both web and native implementations.
36
+ *
37
+ * @param {React.AbstractComponent} Component - The concrete component.
38
+ * @returns {React.AbstractComponent}
39
+ */
40
+const AbstractPollAnswer = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => {
41
+
42
+    const { pollId } = props;
43
+
44
+    const conference: Object = useSelector(state => state['features/base/conference'].conference);
45
+
46
+    const poll: Poll = useSelector(state => state['features/polls'].polls[pollId]);
47
+
48
+    const { id: localId } = useSelector(getLocalParticipant);
49
+
50
+    const [ checkBoxStates, setCheckBoxState ] = useState(() => {
51
+        if (poll.lastVote !== null) {
52
+            return [ ...poll.lastVote ];
53
+        }
54
+
55
+        return new Array(poll.answers.length).fill(false);
56
+    });
57
+
58
+    const setCheckbox = useCallback((index, state) => {
59
+        const newCheckBoxStates = [ ...checkBoxStates ];
60
+
61
+        newCheckBoxStates[index] = state;
62
+        setCheckBoxState(newCheckBoxStates);
63
+    }, [ checkBoxStates ]);
64
+
65
+    const dispatch = useDispatch();
66
+
67
+    const localParticipant = useSelector(state => getParticipantById(state, localId));
68
+    const localName: string = localParticipant.name ? localParticipant.name : 'Fellow Jitster';
69
+
70
+    const submitAnswer = useCallback(() => {
71
+        conference.sendMessage({
72
+            type: COMMAND_ANSWER_POLL,
73
+            pollId,
74
+            voterId: localId,
75
+            voterName: localName,
76
+            answers: checkBoxStates
77
+        });
78
+
79
+        dispatch(registerVote(pollId, checkBoxStates));
80
+
81
+        return false;
82
+    }, [ pollId, localId, localName, checkBoxStates, conference ]);
83
+
84
+    const skipAnswer = useCallback(() => {
85
+        dispatch(registerVote(pollId, null));
86
+
87
+    }, [ pollId ]);
88
+
89
+    const { t } = useTranslation();
90
+
91
+    return (<Component
92
+        checkBoxStates = { checkBoxStates }
93
+        poll = { poll }
94
+        setCheckbox = { setCheckbox }
95
+        skipAnswer = { skipAnswer }
96
+        submitAnswer = { submitAnswer }
97
+        t = { t } />);
98
+
99
+};
100
+
101
+export default AbstractPollAnswer;

+ 135
- 0
react/features/polls/components/AbstractPollCreate.js Zobrazit soubor

@@ -0,0 +1,135 @@
1
+// @flow
2
+
3
+import React, { useCallback, useState } from 'react';
4
+import type { AbstractComponent } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useSelector } from 'react-redux';
7
+
8
+import { getParticipantDisplayName } from '../../base/participants';
9
+import { COMMAND_NEW_POLL } from '../constants';
10
+
11
+/**
12
+ * The type of the React {@code Component} props of inheriting component.
13
+ */
14
+type InputProps = {
15
+    setCreateMode: boolean => void,
16
+};
17
+
18
+/*
19
+ * Props that will be passed by the AbstractPollCreate to its
20
+ * concrete implementations (web/native).
21
+ **/
22
+export type AbstractProps = InputProps & {
23
+    answers: Array<string>,
24
+    question: string,
25
+    setQuestion: string => void,
26
+    setAnswer: (number, string) => void,
27
+    addAnswer: ?number => void,
28
+    moveAnswer: (number, number) => void,
29
+    removeAnswer: number => void,
30
+    onSubmit: Function,
31
+    isSubmitDisabled: boolean,
32
+    t: Function,
33
+};
34
+
35
+/**
36
+ * Higher Order Component taking in a concrete PollCreate component and
37
+ * augmenting it with state/behavior common to both web and native implementations.
38
+ *
39
+ * @param {React.AbstractComponent} Component - The concrete component.
40
+ * @returns {React.AbstractComponent}
41
+ */
42
+const AbstractPollCreate = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => {
43
+
44
+    const { setCreateMode } = props;
45
+
46
+    const [ question, setQuestion ] = useState('');
47
+
48
+    const [ answers, setAnswers ] = useState([ '', '' ]);
49
+
50
+    const setAnswer = useCallback((i, answer) => {
51
+        const newAnswers = [ ...answers ];
52
+
53
+        newAnswers[i] = answer;
54
+        setAnswers(newAnswers);
55
+    });
56
+
57
+    const addAnswer = useCallback((i: ?number) => {
58
+
59
+        const newAnswers = [ ...answers ];
60
+
61
+        newAnswers.splice(typeof i === 'number' ? i : answers.length, 0, '');
62
+        setAnswers(newAnswers);
63
+    });
64
+
65
+    const moveAnswer = useCallback((i, j) => {
66
+        const newAnswers = [ ...answers ];
67
+
68
+        const answer = answers[i];
69
+
70
+        newAnswers.splice(i, 1);
71
+        newAnswers.splice(j, 0, answer);
72
+        setAnswers(newAnswers);
73
+    });
74
+
75
+    const removeAnswer = useCallback(i => {
76
+        if (answers.length <= 2) {
77
+            return;
78
+        }
79
+        const newAnswers = [ ...answers ];
80
+
81
+        newAnswers.splice(i, 1);
82
+        setAnswers(newAnswers);
83
+    });
84
+
85
+    const conference = useSelector(state => state['features/base/conference'].conference);
86
+    const myId = conference.myUserId();
87
+    const myName = useSelector(state => getParticipantDisplayName(state, myId));
88
+
89
+    const onSubmit = useCallback(ev => {
90
+        if (ev) {
91
+            ev.preventDefault();
92
+        }
93
+
94
+        const filteredAnswers = answers.filter(answer => answer.trim().length > 0);
95
+
96
+        if (filteredAnswers.length < 2) {
97
+            return;
98
+        }
99
+
100
+        conference.sendMessage({
101
+            type: COMMAND_NEW_POLL,
102
+            pollId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36),
103
+            senderId: myId,
104
+            senderName: myName,
105
+            question,
106
+            answers: filteredAnswers
107
+        });
108
+
109
+        setCreateMode(false);
110
+
111
+    }, [ conference, question, answers ]);
112
+
113
+    // Check if the poll create form can be submitted i.e. if the send button should be disabled.
114
+    const isSubmitDisabled
115
+        = question.trim().length <= 0 // If no question is provided
116
+        || answers.filter(answer => answer.trim().length > 0).length < 2; // If not enough options are provided
117
+
118
+    const { t } = useTranslation();
119
+
120
+    return (<Component
121
+        addAnswer = { addAnswer }
122
+        answers = { answers }
123
+        isSubmitDisabled = { isSubmitDisabled }
124
+        moveAnswer = { moveAnswer }
125
+        onSubmit = { onSubmit }
126
+        question = { question }
127
+        removeAnswer = { removeAnswer }
128
+        setAnswer = { setAnswer }
129
+        setCreateMode = { setCreateMode }
130
+        setQuestion = { setQuestion }
131
+        t = { t } />);
132
+
133
+};
134
+
135
+export default AbstractPollCreate;

+ 124
- 0
react/features/polls/components/AbstractPollResults.js Zobrazit soubor

@@ -0,0 +1,124 @@
1
+// @flow
2
+
3
+import React, { useCallback, useMemo, useState } from 'react';
4
+import type { AbstractComponent } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch, useSelector } from 'react-redux';
7
+
8
+import { getLocalParticipant, getParticipantById } from '../../base/participants/functions';
9
+import { retractVote } from '../actions';
10
+import { COMMAND_ANSWER_POLL } from '../constants';
11
+
12
+/**
13
+ * The type of the React {@code Component} props of inheriting component.
14
+ */
15
+type InputProps = {
16
+
17
+    /**
18
+     * ID of the poll to display
19
+     */
20
+    pollId: string,
21
+};
22
+
23
+export type AnswerInfo = {
24
+    name: string,
25
+    percentage: number,
26
+    voters?: Array<{ id: number, name: string }>,
27
+    voterCount: number
28
+};
29
+
30
+/**
31
+ * The type of the React {@code Component} props of {@link AbstractPollResults}.
32
+ */
33
+export type AbstractProps = {
34
+    answers: Array<AnswerInfo>,
35
+    changeVote: Function,
36
+    showDetails: boolean,
37
+    question: string,
38
+    t: Function,
39
+    toggleIsDetailed: Function,
40
+    haveVoted: boolean,
41
+};
42
+
43
+/**
44
+ * Higher Order Component taking in a concrete PollResult component and
45
+ * augmenting it with state/behavior common to both web and native implementations.
46
+ *
47
+ * @param {React.AbstractComponent} Component - The concrete component.
48
+ * @returns {React.AbstractComponent}
49
+ */
50
+const AbstractPollResults = (Component: AbstractComponent<AbstractProps>) => (props: InputProps) => {
51
+    const { pollId } = props;
52
+
53
+    const pollDetails = useSelector(state => state['features/polls'].polls[pollId]);
54
+
55
+    const [ showDetails, setShowDetails ] = useState(false);
56
+    const toggleIsDetailed = useCallback(() => {
57
+        setShowDetails(!showDetails);
58
+    });
59
+
60
+    const answers: Array<AnswerInfo> = useMemo(() => {
61
+        const voterSet = new Set();
62
+
63
+        // Getting every voters ID that participates to the poll
64
+        for (const answer of pollDetails.answers) {
65
+            for (const [ voterId ] of answer.voters) {
66
+                voterSet.add(voterId);
67
+            }
68
+        }
69
+
70
+        const totalVoters = voterSet.size;
71
+
72
+        return pollDetails.answers.map(answer => {
73
+            const percentage = totalVoters === 0 ? 0 : Math.round(answer.voters.size / totalVoters * 100);
74
+
75
+            let voters = null;
76
+
77
+            if (showDetails) {
78
+                voters = [ ...answer.voters ].map(([ id, name ]) => {
79
+                    return {
80
+                        id,
81
+                        name
82
+                    };
83
+                });
84
+            }
85
+
86
+            return {
87
+                name: answer.name,
88
+                percentage,
89
+                voters,
90
+                voterCount: answer.voters.size
91
+            };
92
+        });
93
+    }, [ pollDetails.answers, showDetails ]);
94
+
95
+    const dispatch = useDispatch();
96
+
97
+    const conference: Object = useSelector(state => state['features/base/conference'].conference);
98
+    const localId = useSelector(state => getLocalParticipant(state).id);
99
+    const localParticipant = useSelector(state => getParticipantById(state, localId));
100
+    const localName: string = localParticipant ? localParticipant.name : 'Fellow Jitster';
101
+    const changeVote = useCallback(() => {
102
+        conference.sendMessage({
103
+            type: COMMAND_ANSWER_POLL,
104
+            pollId,
105
+            voterId: localId,
106
+            voterName: localName,
107
+            answers: new Array(pollDetails.answers.length).fill(false)
108
+        });
109
+        dispatch(retractVote(pollId));
110
+    }, [ pollId, localId, localName, pollDetails ]);
111
+
112
+    const { t } = useTranslation();
113
+
114
+    return (<Component
115
+        answers = { answers }
116
+        changeVote = { changeVote }
117
+        haveVoted = { pollDetails.lastVote !== null }
118
+        question = { pollDetails.question }
119
+        showDetails = { showDetails }
120
+        t = { t }
121
+        toggleIsDetailed = { toggleIsDetailed } />);
122
+};
123
+
124
+export default AbstractPollResults;

+ 44
- 0
react/features/polls/components/AbstractPollsPane.js Zobrazit soubor

@@ -0,0 +1,44 @@
1
+// @flow
2
+
3
+import React, { useState } from 'react';
4
+import type { AbstractComponent } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+
7
+/*
8
+ * Props that will be passed by the AbstractPollsPane to its
9
+ * concrete implementations (web/native).
10
+ **/
11
+export type AbstractProps = {
12
+    createMode: boolean,
13
+    onCreate: void => void,
14
+    setCreateMode: boolean => void,
15
+    t: Function,
16
+};
17
+
18
+/**
19
+ * Higher Order Component taking in a concrete PollsPane component and
20
+ * augmenting it with state/behavior common to both web and native implementations.
21
+ *
22
+ * @param {React.AbstractComponent} Component - The concrete component.
23
+ * @returns {React.AbstractComponent}
24
+ */
25
+const AbstractPollsPane = (Component: AbstractComponent<AbstractProps>) => () => {
26
+
27
+    const [ createMode, setCreateMode ] = useState(false);
28
+
29
+    const onCreate = () => {
30
+        setCreateMode(true);
31
+    };
32
+
33
+    const { t } = useTranslation();
34
+
35
+    return (<Component
36
+        createMode = { createMode }
37
+        /* eslint-disable react/jsx-no-bind */
38
+        onCreate = { onCreate }
39
+        setCreateMode = { setCreateMode }
40
+        t = { t } />);
41
+
42
+};
43
+
44
+export default AbstractPollsPane;

+ 3
- 0
react/features/polls/components/_.native.js Zobrazit soubor

@@ -0,0 +1,3 @@
1
+// @flow
2
+
3
+export * from './native';

+ 3
- 0
react/features/polls/components/_.web.js Zobrazit soubor

@@ -0,0 +1,3 @@
1
+// @flow
2
+
3
+export * from './web';

+ 3
- 0
react/features/polls/components/index.js Zobrazit soubor

@@ -0,0 +1,3 @@
1
+// @flow
2
+
3
+export * from './_';

+ 69
- 0
react/features/polls/components/native/PollAnswer.js Zobrazit soubor

@@ -0,0 +1,69 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+import { Switch, Text, View } from 'react-native';
5
+import { Button } from 'react-native-paper';
6
+
7
+import { BUTTON_MODES } from '../../../chat/constants';
8
+import AbstractPollAnswer from '../AbstractPollAnswer';
9
+import type { AbstractProps } from '../AbstractPollAnswer';
10
+
11
+import { chatStyles, dialogStyles } from './styles';
12
+
13
+
14
+const PollAnswer = (props: AbstractProps) => {
15
+
16
+    const {
17
+        checkBoxStates,
18
+        poll,
19
+        setCheckbox,
20
+        skipAnswer,
21
+        submitAnswer,
22
+        t
23
+    } = props;
24
+
25
+    return (
26
+        <View>
27
+            <View>
28
+                <Text style = { dialogStyles.question } >{ poll.question }</Text>
29
+            </View>
30
+            <View style = { chatStyles.answerContent }>
31
+                {poll.answers.map((answer, index) => (
32
+                    <View
33
+                        key = { index }
34
+                        style = { chatStyles.switchRow } >
35
+                        <Switch
36
+                            /* eslint-disable react/jsx-no-bind */
37
+                            onValueChange = { state => setCheckbox(index, state) }
38
+                            value = { checkBoxStates[index] } />
39
+                        <Text>{answer.name}</Text>
40
+                    </View>
41
+                ))}
42
+            </View>
43
+            <View style = { chatStyles.buttonRow }>
44
+                <Button
45
+                    color = '#3D3D3D'
46
+                    mode = { BUTTON_MODES.CONTAINED }
47
+                    onPress = { skipAnswer }
48
+                    style = { chatStyles.pollCreateButton } >
49
+                    {t('polls.answer.skip')}
50
+                </Button>
51
+                <Button
52
+                    color = '#17a0db'
53
+                    mode = { BUTTON_MODES.CONTAINED }
54
+                    onPress = { submitAnswer }
55
+                    style = { chatStyles.pollCreateButton } >
56
+                    {t('polls.answer.submit')}
57
+                </Button>
58
+            </View>
59
+        </View>
60
+
61
+    );
62
+};
63
+
64
+/*
65
+ * We apply AbstractPollAnswer to fill in the AbstractProps common
66
+ * to both the web and native implementations.
67
+ */
68
+// eslint-disable-next-line new-cap
69
+export default AbstractPollAnswer(PollAnswer);

+ 185
- 0
react/features/polls/components/native/PollCreate.js Zobrazit soubor

@@ -0,0 +1,185 @@
1
+// @flow
2
+
3
+import React, { useCallback, useEffect, useRef, useState } from 'react';
4
+import { View, TextInput, FlatList, TouchableOpacity } from 'react-native';
5
+import { Button } from 'react-native-paper';
6
+
7
+import { Icon, IconClose } from '../../../base/icons';
8
+import { BUTTON_MODES } from '../../../chat/constants';
9
+import AbstractPollCreate from '../AbstractPollCreate';
10
+import type { AbstractProps } from '../AbstractPollCreate';
11
+
12
+import { chatStyles, dialogStyles } from './styles';
13
+
14
+const PollCreate = (props: AbstractProps) => {
15
+
16
+
17
+    const {
18
+        addAnswer,
19
+        answers,
20
+        isSubmitDisabled,
21
+        onSubmit,
22
+        question,
23
+        removeAnswer,
24
+        setAnswer,
25
+        setCreateMode,
26
+        setQuestion,
27
+        t
28
+    } = props;
29
+
30
+    const answerListRef = useRef(null);
31
+
32
+    /*
33
+     * This ref stores the Array of answer input fields, allowing us to focus on them.
34
+     * This array is maintained by registerfieldRef and the useEffect below.
35
+     */
36
+    const answerInputs = useRef([]);
37
+    const registerFieldRef = useCallback((i, input) => {
38
+        if (input === null) {
39
+            return;
40
+        }
41
+        answerInputs.current[i] = input;
42
+    },
43
+        [ answerInputs ]
44
+    );
45
+
46
+    useEffect(() => {
47
+        answerInputs.current = answerInputs.current.slice(0, answers.length);
48
+    }, [ answers ]);
49
+
50
+    /*
51
+     * This state allows us to requestFocus asynchronously, without having to worry
52
+     * about whether a newly created input field has been rendered yet or not.
53
+     */
54
+    const [ lastFocus, requestFocus ] = useState(null);
55
+
56
+    useEffect(() => {
57
+        if (lastFocus === null) {
58
+            return;
59
+        }
60
+        const input = answerInputs.current[lastFocus];
61
+
62
+        if (input === undefined) {
63
+            return;
64
+        }
65
+        input.focus();
66
+
67
+    }, [ answerInputs, lastFocus ]);
68
+
69
+
70
+    const onQuestionKeyDown = useCallback(() => {
71
+        answerInputs.current[0].focus();
72
+    });
73
+
74
+    // Called on keypress in answer fields
75
+    const onAnswerKeyDown = useCallback((index: number, ev) => {
76
+        const { key } = ev.nativeEvent;
77
+        const currentText = answers[index];
78
+
79
+        if (key === 'Backspace' && currentText === '' && answers.length > 1) {
80
+            removeAnswer(index);
81
+            requestFocus(index > 0 ? index - 1 : 0);
82
+        }
83
+    }, [ answers, addAnswer, removeAnswer, requestFocus ]);
84
+
85
+    /* eslint-disable react/no-multi-comp */
86
+    const createIconButton = (icon, onPress, style) => (
87
+        <TouchableOpacity
88
+            activeOpacity = { 0.8 }
89
+            onPress = { onPress }
90
+            style = { [ dialogStyles.buttonContainer, style ] }>
91
+            <Icon
92
+                size = { 24 }
93
+                src = { icon }
94
+                style = { dialogStyles.icon } />
95
+        </TouchableOpacity>
96
+    );
97
+
98
+
99
+    /* eslint-disable react/jsx-no-bind */
100
+    const renderListItem = ({ index }: { index: number }) =>
101
+
102
+        // padding to take into account the two default options
103
+        (
104
+            <View
105
+                style = { dialogStyles.optionContainer }>
106
+                <TextInput
107
+                    blurOnSubmit = { false }
108
+                    multiline = { true }
109
+                    onChangeText = { text => setAnswer(index, text) }
110
+                    onKeyPress = { ev => onAnswerKeyDown(index, ev) }
111
+                    placeholder = { t('polls.create.answerPlaceholder', { index: index + 1 }) }
112
+                    ref = { input => registerFieldRef(index, input) }
113
+                    style = { dialogStyles.field }
114
+                    value = { answers[index] } />
115
+
116
+                {answers.length > 2
117
+                    && createIconButton(IconClose, () => removeAnswer(index))
118
+                }
119
+            </View>
120
+        );
121
+
122
+    return (
123
+        <View style = { chatStyles.pollCreateContainer }>
124
+            <View style = { chatStyles.pollCreateSubContainer }>
125
+                <TextInput
126
+                    autoFocus = { true }
127
+                    blurOnSubmit = { false }
128
+                    multiline = { true }
129
+                    onChangeText = { setQuestion }
130
+                    onSubmitEditing = { onQuestionKeyDown }
131
+                    placeholder = { t('polls.create.questionPlaceholder') }
132
+                    style = { dialogStyles.question }
133
+                    value = { question } />
134
+                <FlatList
135
+                    blurOnSubmit = { true }
136
+                    data = { answers }
137
+                    extraData = { answers }
138
+                    keyExtractor = { (item, index) => index.toString() }
139
+                    ref = { answerListRef }
140
+                    renderItem = { renderListItem } />
141
+
142
+                <Button
143
+                    color = '#3D3D3D'
144
+                    mode = { BUTTON_MODES.CONTAINED }
145
+                    onPress = { () => {
146
+                        // adding and answer
147
+                        addAnswer();
148
+                        requestFocus(answers.length);
149
+                    } }
150
+                    style = { chatStyles.pollCreateAddButton }>
151
+                    {t('polls.create.addOption')}
152
+                </Button>
153
+            </View>
154
+
155
+            <View
156
+                style = { chatStyles.buttonRow }>
157
+
158
+                <Button
159
+                    color = '#3D3D3D'
160
+                    mode = { BUTTON_MODES.CONTAINED }
161
+                    onPress = { () => setCreateMode(false) }
162
+                    style = { chatStyles.pollCreateButton } >
163
+                    {t('polls.create.cancel')}
164
+                </Button>
165
+
166
+                <Button
167
+                    color = '#17a0db'
168
+                    disabled = { isSubmitDisabled }
169
+                    mode = { BUTTON_MODES.CONTAINED }
170
+                    onPress = { onSubmit }
171
+                    style = { chatStyles.pollCreateButton } >
172
+                    {t('polls.create.send')}
173
+                </Button>
174
+            </View>
175
+        </View>
176
+    );
177
+
178
+};
179
+
180
+/*
181
+ * We apply AbstractPollCreate to fill in the AbstractProps common
182
+ * to both the web and native implementations.
183
+ */
184
+// eslint-disable-next-line new-cap
185
+export default AbstractPollCreate(PollCreate);

+ 40
- 0
react/features/polls/components/native/PollItem.js Zobrazit soubor

@@ -0,0 +1,40 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+import { View } from 'react-native';
5
+import { useSelector } from 'react-redux';
6
+
7
+import { shouldShowResults } from '../../functions';
8
+
9
+import { chatStyles } from './styles';
10
+
11
+import { PollAnswer, PollResults } from '.';
12
+
13
+type Props = {
14
+
15
+    /**
16
+     * Id of the poll
17
+     */
18
+    pollId: string,
19
+
20
+}
21
+
22
+const PollItem = ({ pollId }: Props) => {
23
+    const showResults = useSelector(state => shouldShowResults(state, pollId));
24
+
25
+    return (
26
+        <View
27
+            style = { chatStyles.pollItemContainer }>
28
+            { showResults
29
+                ? <PollResults
30
+                    key = { pollId }
31
+                    pollId = { pollId } />
32
+                : <PollAnswer
33
+                    pollId = { pollId } />
34
+            }
35
+
36
+        </View>
37
+    );
38
+};
39
+
40
+export default PollItem;

+ 120
- 0
react/features/polls/components/native/PollResults.js Zobrazit soubor

@@ -0,0 +1,120 @@
1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { View, Text, FlatList, TouchableOpacity } from 'react-native';
5
+
6
+import AbstractPollResults from '../AbstractPollResults';
7
+import type { AbstractProps, AnswerInfo } from '../AbstractPollResults';
8
+
9
+import { chatStyles, dialogStyles, resultsStyles } from './styles';
10
+
11
+
12
+/**
13
+ * Component that renders the poll results.
14
+ *
15
+ * @param {Props} props - The passed props.
16
+ * @returns {React.Node}
17
+ */
18
+const PollResults = (props: AbstractProps) => {
19
+    const {
20
+        answers,
21
+        changeVote,
22
+        haveVoted,
23
+        showDetails,
24
+        question,
25
+        t,
26
+        toggleIsDetailed
27
+    } = props;
28
+
29
+    /* eslint-disable react/no-multi-comp */
30
+    /**
31
+     * Render a header summing up answer information.
32
+     *
33
+     * @param {string} answer - The name of the answer.
34
+     * @param {number} percentage - The percentage of voters.
35
+     * @param {number} nbVotes - The number of collected votes.
36
+     * @returns {React.Node}
37
+     */
38
+    const renderHeader = (answer: string, percentage: number, nbVotes: number) => (
39
+        <View style = { resultsStyles.answerHeader }>
40
+            <Text style = { resultsStyles.answer }>{ answer }</Text>
41
+            <Text style = { resultsStyles.answer }>({nbVotes}) {percentage}%</Text>
42
+
43
+            {/* <Text style = { resultsStyles.answer }>{ answer } - { percentage }%</Text>
44
+            <Text style = { resultsStyles.answerVoteCount }>
45
+                { t('polls.answer.vote', { count: nbVotes }) }
46
+            </Text> */}
47
+        </View>
48
+    );
49
+
50
+    /**
51
+     * Render voters of and answer
52
+     * @param {AnswerInfo} answer - the answer info
53
+     * @returns {React.Node}
54
+     */
55
+    const renderRow = useCallback((answer: AnswerInfo) => {
56
+        const { name, percentage, voters, voterCount } = answer;
57
+
58
+        if (showDetails) {
59
+            return (
60
+                <View style = { resultsStyles.answerContainer }>
61
+                    { renderHeader(name, percentage, voterCount) }
62
+                    { voters && voterCount > 0
63
+                    && <View style = { resultsStyles.voters }>
64
+                        {voters.map(({ id, name: voterName }) =>
65
+                            <Text key = { id }>{ voterName }</Text>
66
+                        )}
67
+                    </View>}
68
+                </View>
69
+            );
70
+        }
71
+
72
+
73
+        // else, we display a simple list
74
+        // We add a progress bar by creating an empty view of width equal to percentage.
75
+        return (
76
+            <View style = { resultsStyles.answerContainer }>
77
+                { renderHeader(answer.name, percentage, voterCount) }
78
+                <View style = { resultsStyles.barContainer }>
79
+                    <View style = { [ resultsStyles.bar, { width: `${percentage}%` } ] } />
80
+                </View>
81
+            </View>
82
+        );
83
+
84
+    }, [ showDetails ]);
85
+
86
+    /* eslint-disable react/jsx-no-bind */
87
+    return (
88
+        <View>
89
+            <View>
90
+                <Text style = { dialogStyles.question } >{ question }</Text>
91
+            </View>
92
+            <FlatList
93
+                data = { answers }
94
+                keyExtractor = { (item, index) => index.toString() }
95
+                renderItem = { answer => renderRow(answer.item) } />
96
+            <View style = { chatStyles.bottomLinks }>
97
+                <TouchableOpacity onPress = { toggleIsDetailed }>
98
+                    <Text
99
+                        style = { chatStyles.toggleText }>
100
+                        {showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')}
101
+                    </Text>
102
+                </TouchableOpacity>
103
+                <TouchableOpacity onPress = { changeVote }>
104
+                    <Text
105
+                        style = { chatStyles.toggleText }>
106
+                        {haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')}
107
+                    </Text>
108
+                </TouchableOpacity>
109
+            </View>
110
+
111
+        </View>
112
+    );
113
+};
114
+
115
+/*
116
+ * We apply AbstractPollResults to fill in the AbstractProps common
117
+ * to both the web and native implementations.
118
+ */
119
+// eslint-disable-next-line new-cap
120
+export default AbstractPollResults(PollResults);

+ 53
- 0
react/features/polls/components/native/PollsList.js Zobrazit soubor

@@ -0,0 +1,53 @@
1
+// @flow
2
+
3
+import React, { useCallback, useEffect, useRef } from '@atlaskit/checkbox';
4
+import { useTranslation } from 'react-i18next';
5
+import { FlatList } from 'react-native';
6
+import { Text } from 'react-native-paper';
7
+import { useSelector } from 'react-redux';
8
+
9
+import { chatStyles } from './styles';
10
+
11
+import { PollItem } from '.';
12
+
13
+
14
+const PollsList = () => {
15
+
16
+    const polls = useSelector(state => state['features/polls'].polls);
17
+    const { t } = useTranslation();
18
+    const listPolls = Object.keys(polls);
19
+
20
+    const renderItem = useCallback(({ item }) => (
21
+        <PollItem
22
+            key = { item }
23
+            pollId = { item } />)
24
+    , []);
25
+
26
+    const flatlistRef = useRef();
27
+
28
+    const scrollToBottom = () => {
29
+        flatlistRef.current.scrollToEnd({ animating: true });
30
+    };
31
+
32
+    useEffect(() => {
33
+        scrollToBottom();
34
+    }, [ polls ]);
35
+
36
+    return (
37
+    <>
38
+        {listPolls.length === 0
39
+            && <Text style = { chatStyles.noPollText } >
40
+                {t('polls.results.empty')}
41
+            </Text>}
42
+        <FlatList
43
+            data = { listPolls }
44
+            extraData = { listPolls }
45
+            // eslint-disable-next-line react/jsx-no-bind
46
+            keyExtractor = { (item, index) => index.toString() }
47
+            ref = { flatlistRef }
48
+            renderItem = { renderItem } />
49
+    </>
50
+    );
51
+};
52
+
53
+export default PollsList;

+ 45
- 0
react/features/polls/components/native/PollsPane.js Zobrazit soubor

@@ -0,0 +1,45 @@
1
+/* eslint-disable react-native/no-color-literals */
2
+// @flow
3
+
4
+import React from 'react';
5
+import { View } from 'react-native';
6
+import { Button } from 'react-native-paper';
7
+
8
+import { BUTTON_MODES } from '../../../chat/constants';
9
+import AbstractPollsPane from '../AbstractPollsPane';
10
+import type { AbstractProps } from '../AbstractPollsPane';
11
+
12
+import PollCreate from './PollCreate';
13
+import PollsList from './PollsList';
14
+import { chatStyles } from './styles';
15
+
16
+const PollsPane = (props: AbstractProps) => {
17
+
18
+    const { createMode, onCreate, setCreateMode, t } = props;
19
+
20
+    return (
21
+        <View style = { chatStyles.PollPane }>
22
+            { createMode
23
+                ? <PollCreate setCreateMode = { setCreateMode } />
24
+                : <View style = { chatStyles.PollPaneContent }>
25
+                    {/* <View /> */}
26
+                    <PollsList />
27
+                    <Button
28
+                        color = '#17a0db'
29
+                        mode = { BUTTON_MODES.CONTAINED }
30
+                        onPress = { onCreate }
31
+                        style = { chatStyles.createPollButton } >
32
+                        {t('polls.create.create')}
33
+                    </Button>
34
+                </View>}
35
+        </View>
36
+    );
37
+};
38
+
39
+
40
+/*
41
+ * We apply AbstractPollsPane to fill in the AbstractProps common
42
+ * to both the web and native implementations.
43
+ */
44
+// eslint-disable-next-line new-cap
45
+export default AbstractPollsPane(PollsPane);

+ 7
- 0
react/features/polls/components/native/index.js Zobrazit soubor

@@ -0,0 +1,7 @@
1
+// @flow
2
+
3
+export { default as PollResults } from './PollResults';
4
+export { default as PollsPane } from './PollsPane';
5
+export { default as PollItem } from './PollItem';
6
+export { default as PollAnswer } from './PollAnswer';
7
+export { default as PollCreate } from './PollCreate';

+ 195
- 0
react/features/polls/components/native/styles.js Zobrazit soubor

@@ -0,0 +1,195 @@
1
+// @flow
2
+
3
+import { schemeColor } from '../../../base/color-scheme';
4
+import { ColorPalette, createStyleSheet } from '../../../base/styles';
5
+
6
+export const answerStyles = createStyleSheet({
7
+    question: {
8
+        fontSize: 24,
9
+        fontWeight: 'bold',
10
+        marginBottom: 6
11
+    },
12
+    answer: {
13
+        flexDirection: 'row',
14
+        alignItems: 'center',
15
+        marginBottom: 3
16
+    },
17
+    option: {
18
+        flexShrink: 1
19
+    }
20
+});
21
+
22
+export const dialogStyles = createStyleSheet({
23
+    question: {
24
+        fontSize: 16,
25
+        fontWeight: 'bold',
26
+        marginVertical: 4
27
+    },
28
+
29
+    optionContainer: {
30
+        flexDirection: 'row'
31
+    },
32
+
33
+    field: {
34
+        borderBottomWidth: 1,
35
+        borderColor: ColorPalette.blue,
36
+        fontSize: 14,
37
+        flexGrow: 1,
38
+        paddingBottom: 0,
39
+        flexShrink: 1
40
+    },
41
+
42
+    buttonContainer: {
43
+        justifyContent: 'flex-end',
44
+        alignItems: 'center'
45
+    },
46
+
47
+    icon: {
48
+        color: ColorPalette.white,
49
+        backgroundColor: ColorPalette.blue,
50
+        borderRadius: 5,
51
+        margin: 0
52
+    },
53
+
54
+    plusButton: {
55
+        marginTop: 8
56
+    }
57
+});
58
+
59
+export const resultsStyles = createStyleSheet({
60
+    title: {
61
+        fontSize: 24,
62
+        fontWeight: 'bold'
63
+    },
64
+
65
+    barContainer: {
66
+        backgroundColor: '#ccc',
67
+        borderRadius: 3,
68
+        width: '100%',
69
+        height: 6,
70
+        marginTop: 2
71
+    },
72
+
73
+    bar: {
74
+        backgroundColor: ColorPalette.blue,
75
+        borderRadius: 3,
76
+        height: 6
77
+    },
78
+
79
+    voters: {
80
+        borderRadius: 3,
81
+        borderWidth: 1,
82
+        borderColor: 'gray',
83
+        padding: 2,
84
+        marginHorizontal: 8,
85
+        marginVertical: 4
86
+    },
87
+
88
+    answerContainer: {
89
+        marginVertical: 2,
90
+        maxWidth: '100%'
91
+    },
92
+
93
+    answerHeader: {
94
+        flexDirection: 'row',
95
+        justifyContent: 'space-between'
96
+    },
97
+
98
+    answer: {
99
+        flexShrink: 1
100
+    },
101
+
102
+    answerVoteCount: {
103
+        paddingLeft: 10
104
+    },
105
+
106
+    chatQuestion: {
107
+        fontWeight: 'bold'
108
+    }
109
+});
110
+
111
+export const chatStyles = createStyleSheet({
112
+    messageFooter: {
113
+        flexDirection: 'row',
114
+        justifyContent: 'space-between',
115
+        alignItems: 'center',
116
+        fontSize: 11,
117
+        marginTop: 6
118
+    },
119
+
120
+    showDetails: {
121
+        fontWeight: 'bold'
122
+    },
123
+
124
+    noPollText: {
125
+        flex: 1,
126
+        color: schemeColor('displayName'),
127
+        textAlign: 'center',
128
+        paddingTop: '10%'
129
+    },
130
+
131
+    pollItemContainer: {
132
+        borderRadius: 4,
133
+        borderColor: '#2183ad',
134
+        borderWidth: 2,
135
+        padding: 16,
136
+        marginBottom: 8
137
+    },
138
+
139
+    pollCreateContainer: {
140
+        flex: 1,
141
+        justifyContent: 'space-between'
142
+    },
143
+
144
+    pollCreateSubContainer: {
145
+        flex: 1
146
+    },
147
+
148
+    pollCreateButton: {
149
+        flex: 1,
150
+        marginHorizontal: 8
151
+    },
152
+
153
+    buttonRow: {
154
+        flexDirection: 'row'
155
+    },
156
+
157
+    answerContent: {
158
+        paddingBottom: 8
159
+    },
160
+
161
+    switchRow: {
162
+        alignItems: 'center',
163
+        flexDirection: 'row',
164
+        padding: 6
165
+    },
166
+
167
+    pollCreateAddButton: {
168
+        margin: 8
169
+    },
170
+
171
+    toggleText: {
172
+        color: ColorPalette.blue,
173
+        paddingTop: 16
174
+    },
175
+
176
+    createPollButton: {
177
+        padding: 8,
178
+        margin: 4
179
+    },
180
+
181
+    PollPane: {
182
+        flex: 1,
183
+        padding: 8
184
+    },
185
+
186
+    PollPaneContent: {
187
+        justifyContent: 'space-between',
188
+        flex: 1
189
+    },
190
+
191
+    bottomLinks: {
192
+        flexDirection: 'row',
193
+        justifyContent: 'space-between'
194
+    }
195
+});

+ 69
- 0
react/features/polls/components/web/PollAnswer.js Zobrazit soubor

@@ -0,0 +1,69 @@
1
+// @flow
2
+
3
+import { Checkbox } from '@atlaskit/checkbox';
4
+import React from 'react';
5
+
6
+import AbstractPollAnswer from '../AbstractPollAnswer';
7
+import type { AbstractProps } from '../AbstractPollAnswer';
8
+
9
+
10
+const PollAnswer = (props: AbstractProps) => {
11
+
12
+    const {
13
+        checkBoxStates,
14
+        poll,
15
+        setCheckbox,
16
+        skipAnswer,
17
+        submitAnswer,
18
+        t
19
+    } = props;
20
+
21
+    return (
22
+        <div className = 'poll-answer'>
23
+            <div className = 'poll-header'>
24
+                <div className = 'poll-question'>
25
+                    <span>{ poll.question }</span>
26
+                </div>
27
+            </div>
28
+            <ol className = 'poll-answer-list'>
29
+                {
30
+                    poll.answers.map((answer, index) => (
31
+                        <li
32
+                            className = 'poll-answer-container'
33
+                            key = { index }>
34
+                            <Checkbox
35
+                                isChecked = { checkBoxStates[index] }
36
+                                key = { index }
37
+                                label = { <span>{ answer.name }</span> }
38
+                                // eslint-disable-next-line react/jsx-no-bind
39
+                                onChange = { ev => setCheckbox(index, ev.target.checked) }
40
+                                size = 'large' />
41
+                        </li>
42
+                    ))
43
+                }
44
+            </ol>
45
+            <div className = { 'poll-footer' }>
46
+                <button
47
+                    aria-label = { t('polls.answer.skip') }
48
+                    className = { 'poll-small-secondary-button' }
49
+                    onClick = { skipAnswer } >
50
+                    <span>{t('polls.answer.skip')}</span>
51
+                </button>
52
+                <button
53
+                    aria-label = { t('polls.answer.submit') }
54
+                    className = { 'poll-small-primary-button' }
55
+                    onClick = { submitAnswer }>
56
+                    <span>{t('polls.answer.submit')}</span>
57
+                </button>
58
+            </div>
59
+
60
+        </div>
61
+    );
62
+};
63
+
64
+/*
65
+ * We apply AbstractPollAnswer to fill in the AbstractProps common
66
+ * to both the web and native implementations.
67
+ */
68
+// eslint-disable-next-line new-cap
69
+export default AbstractPollAnswer(PollAnswer);

+ 248
- 0
react/features/polls/components/web/PollCreate.js Zobrazit soubor

@@ -0,0 +1,248 @@
1
+// @flow
2
+
3
+import React, { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+import { Icon, IconMenu } from '../../../base/icons';
6
+import { Tooltip } from '../../../base/tooltip';
7
+import AbstractPollCreate from '../AbstractPollCreate';
8
+import type { AbstractProps } from '../AbstractPollCreate';
9
+
10
+
11
+const PollCreate = (props: AbstractProps) => {
12
+
13
+
14
+    const {
15
+        addAnswer,
16
+        answers,
17
+        isSubmitDisabled,
18
+        moveAnswer,
19
+        onSubmit,
20
+        question,
21
+        removeAnswer,
22
+        setAnswer,
23
+        setCreateMode,
24
+        setQuestion,
25
+        t
26
+    } = props;
27
+
28
+    /*
29
+     * This ref stores the Array of answer input fields, allowing us to focus on them.
30
+     * This array is maintained by registerfieldRef and the useEffect below.
31
+     */
32
+    const answerInputs = useRef([]);
33
+    const registerFieldRef = useCallback((i, r) => {
34
+        if (r === null) {
35
+            return;
36
+        }
37
+        answerInputs.current[i] = r;
38
+    }, [ answerInputs ]);
39
+
40
+    useEffect(() => {
41
+        answerInputs.current = answerInputs.current.slice(0, answers.length);
42
+    }, [ answers ]);
43
+
44
+    /*
45
+     * This state allows us to requestFocus asynchronously, without having to worry
46
+     * about whether a newly created input field has been rendered yet or not.
47
+     */
48
+    const [ lastFocus, requestFocus ] = useState(null);
49
+
50
+    useEffect(() => {
51
+        if (lastFocus === null) {
52
+            return;
53
+        }
54
+        const input = answerInputs.current[lastFocus];
55
+
56
+        if (input === undefined) {
57
+            return;
58
+        }
59
+        input.focus();
60
+    }, [ lastFocus ]);
61
+
62
+    const checkModifiers = useCallback(ev => {
63
+        // Because this isn't done automatically on MacOS
64
+        if (ev.key === 'Enter' && ev.metaKey) {
65
+            ev.preventDefault();
66
+            onSubmit();
67
+
68
+            return;
69
+        }
70
+        if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
71
+            return;
72
+        }
73
+    });
74
+
75
+    const onQuestionKeyDown = useCallback(ev => {
76
+        if (checkModifiers(ev)) {
77
+            return;
78
+        }
79
+
80
+        if (ev.key === 'Enter') {
81
+            requestFocus(0);
82
+            ev.preventDefault();
83
+        }
84
+    });
85
+
86
+    // Called on keypress in answer fields
87
+    const onAnswerKeyDown = useCallback((i, ev) => {
88
+        if (checkModifiers(ev)) {
89
+            return;
90
+        }
91
+
92
+        if (ev.key === 'Enter') {
93
+            addAnswer(i + 1);
94
+            requestFocus(i + 1);
95
+            ev.preventDefault();
96
+        } else if (ev.key === 'Backspace' && ev.target.value === '' && answers.length > 1) {
97
+            removeAnswer(i);
98
+            requestFocus(i > 0 ? i - 1 : 0);
99
+            ev.preventDefault();
100
+        } else if (ev.key === 'ArrowDown') {
101
+            if (i === answers.length - 1) {
102
+                addAnswer();
103
+            }
104
+            requestFocus(i + 1);
105
+            ev.preventDefault();
106
+        } else if (ev.key === 'ArrowUp') {
107
+            if (i === 0) {
108
+                addAnswer(0);
109
+                requestFocus(0);
110
+            } else {
111
+                requestFocus(i - 1);
112
+            }
113
+            ev.preventDefault();
114
+        }
115
+    }, [ answers, addAnswer, removeAnswer, requestFocus ]);
116
+
117
+    const [ grabbing, setGrabbing ] = useState(null);
118
+
119
+    const onGrab = useCallback((i, ev) => {
120
+        if (ev.button !== 0) {
121
+            return;
122
+        }
123
+        setGrabbing(i);
124
+        window.addEventListener('mouseup', () => {
125
+            setGrabbing(_grabbing => {
126
+                requestFocus(_grabbing);
127
+
128
+                return null;
129
+            });
130
+        }, { once: true });
131
+    });
132
+    const onMouseOver = useCallback(i => {
133
+        if (grabbing !== null && grabbing !== i) {
134
+            moveAnswer(grabbing, i);
135
+            setGrabbing(i);
136
+        }
137
+    });
138
+
139
+    const autogrow = ev => {
140
+        const el = ev.target;
141
+
142
+        el.style.height = '1px';
143
+        el.style.height = `${el.scrollHeight + 2}px`;
144
+    };
145
+
146
+    /* eslint-disable react/jsx-no-bind */
147
+    return (<form
148
+        className = 'polls-pane-content'
149
+        onSubmit = { onSubmit }>
150
+        <div className = 'poll-create-container poll-container'>
151
+            <div className = 'poll-create-header'>
152
+                { t('polls.create.create') }
153
+            </div>
154
+            <div className = 'poll-question-field'>
155
+                <span className = 'poll-create-label'>
156
+                    { t('polls.create.pollQuestion') }
157
+                </span>
158
+                <textarea
159
+                    autoFocus = { true }
160
+                    className = 'expandable-input'
161
+                    onChange = { ev => setQuestion(ev.target.value) }
162
+                    onInput = { autogrow }
163
+                    onKeyDown = { onQuestionKeyDown }
164
+                    placeholder = { t('polls.create.questionPlaceholder') }
165
+                    required = { true }
166
+                    row = '1'
167
+                    value = { question } />
168
+            </div>
169
+            <ol className = 'poll-answer-field-list'>
170
+                {answers.map((answer, i) =>
171
+                    (<li
172
+                        className = { `poll-answer-field${grabbing === i ? ' poll-dragged' : ''}` }
173
+                        key = { i }
174
+                        onMouseOver = { () => onMouseOver(i) }>
175
+                        <span className = 'poll-create-label'>
176
+                            { t('polls.create.pollOption', { index: i + 1 })}
177
+                        </span>
178
+                        <div className = 'poll-create-option-row'>
179
+                            <textarea
180
+                                className = 'expandable-input'
181
+                                onChange = { ev => setAnswer(i, ev.target.value) }
182
+                                onInput = { autogrow }
183
+                                onKeyDown = { ev => onAnswerKeyDown(i, ev) }
184
+                                placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
185
+                                ref = { r => registerFieldRef(i, r) }
186
+                                required = { true }
187
+                                row = { 1 }
188
+                                value = { answer } />
189
+                            <button
190
+                                className = 'poll-drag-handle'
191
+                                onMouseDown = { ev => onGrab(i, ev) }
192
+                                tabIndex = '-1'
193
+                                type = 'button'>
194
+                                <Icon src = { IconMenu } />
195
+                            </button>
196
+                        </div>
197
+
198
+                        { answers.length > 2
199
+                        && <Tooltip content = { t('polls.create.removeOption') }>
200
+                            <button
201
+                                className = 'poll-remove-option-button'
202
+                                onClick = { () => removeAnswer(i) }
203
+                                type = 'button'>
204
+                                { t('polls.create.removeOption') }
205
+                            </button>
206
+                        </Tooltip>}
207
+                    </li>)
208
+                )}
209
+            </ol>
210
+            <div className = 'poll-add-button'>
211
+                <button
212
+                    aria-label = { 'Add option' }
213
+                    className = { 'poll-secondary-button' }
214
+                    onClick = { () => {
215
+                        addAnswer();
216
+                        requestFocus(answers.length);
217
+                    } }
218
+                    type = 'button' >
219
+                    <span>{t('polls.create.addOption')}</span>
220
+                </button>
221
+            </div>
222
+        </div>
223
+        <div className = 'poll-footer'>
224
+            <button
225
+                aria-label = { t('polls.create.cancel') }
226
+                className = 'poll-small-secondary-button'
227
+                onClick = { () => setCreateMode(false) }
228
+                type = 'button' >
229
+                <span>{t('polls.create.cancel')}</span>
230
+            </button>
231
+            <button
232
+                aria-label = { t('polls.create.send') }
233
+                className = 'poll-small-primary-button'
234
+                disabled = { isSubmitDisabled }
235
+                type = 'submit' >
236
+                <span>{t('polls.create.send')}</span>
237
+            </button>
238
+        </div>
239
+    </form>);
240
+
241
+};
242
+
243
+/*
244
+ * We apply AbstractPollCreate to fill in the AbstractProps common
245
+ * to both the web and native implementations.
246
+ */
247
+// eslint-disable-next-line new-cap
248
+export default AbstractPollCreate(PollCreate);

+ 36
- 0
react/features/polls/components/web/PollItem.js Zobrazit soubor

@@ -0,0 +1,36 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+import { useSelector } from 'react-redux';
5
+
6
+import { PollAnswer, PollResults } from '..';
7
+import { shouldShowResults } from '../../functions';
8
+
9
+
10
+type Props = {
11
+
12
+    /**
13
+     * Id of the poll
14
+     */
15
+    pollId: string,
16
+
17
+}
18
+
19
+const PollItem = React.forwardRef<Props, HTMLElement>(({ pollId }, ref) => {
20
+    const showResults = useSelector(state => shouldShowResults(state, pollId));
21
+
22
+    return (
23
+        <div ref = { ref }>
24
+            { showResults
25
+                ? <PollResults
26
+                    key = { pollId }
27
+                    pollId = { pollId } />
28
+                : <PollAnswer
29
+                    pollId = { pollId } />
30
+            }
31
+
32
+        </div>
33
+    );
34
+});
35
+
36
+export default PollItem;

+ 80
- 0
react/features/polls/components/web/PollResults.js Zobrazit soubor

@@ -0,0 +1,80 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+
5
+import AbstractPollResults from '../AbstractPollResults';
6
+import type { AbstractProps } from '../AbstractPollResults';
7
+
8
+
9
+/**
10
+ * Component that renders the poll results.
11
+ *
12
+ * @param {Props} props - The passed props.
13
+ * @returns {React.Node}
14
+ */
15
+const PollResults = (props: AbstractProps) => {
16
+    const {
17
+        answers,
18
+        changeVote,
19
+        haveVoted,
20
+        showDetails,
21
+        question,
22
+        t,
23
+        toggleIsDetailed
24
+    } = props;
25
+
26
+    return (
27
+        <div className = 'poll-results'>
28
+            <div className = 'poll-header'>
29
+                <div className = 'poll-question'>
30
+                    <strong>{ question }</strong>
31
+                </div>
32
+            </div>
33
+            <ol className = 'poll-result-list'>
34
+                {answers.map(({ name, percentage, voters, voterCount }, index) =>
35
+                    (<li key = { index }>
36
+                        <div className = 'poll-answer-header'>
37
+                            <span className = 'poll-answer-vote-name' >{name}</span>
38
+                        </div>
39
+                        <div className = 'poll-answer-short-results'>
40
+                            <span className = 'poll-bar-container'>
41
+                                <div
42
+                                    className = 'poll-bar'
43
+                                    style = {{ width: `${percentage}%` }} />
44
+                            </span>
45
+                            <div className = 'poll-answer-vote-count-container'>
46
+                                <span className = 'poll-answer-vote-count'>({voterCount}) {percentage}%</span>
47
+                            </div>
48
+                        </div>
49
+                        { showDetails && voters && voterCount > 0
50
+                            && <ul className = 'poll-answer-voters'>
51
+                                {voters.map(voter =>
52
+                                    <li key = { voter.id }>{voter.name}</li>
53
+                                )}
54
+                            </ul>}
55
+                    </li>)
56
+                )}
57
+            </ol>
58
+            <div className = { 'poll-result-links' }>
59
+                <a
60
+                    className = { 'poll-detail-link' }
61
+                    onClick = { toggleIsDetailed }>
62
+                    {showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')}
63
+                </a>
64
+                <a
65
+                    className = { 'poll-change-vote-link' }
66
+                    onClick = { changeVote }>
67
+                    {haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')}
68
+                </a>
69
+            </div>
70
+        </div>
71
+    );
72
+
73
+};
74
+
75
+/*
76
+ * We apply AbstractPollResults to fill in the AbstractProps common
77
+ * to both the web and native implementations.
78
+ */
79
+// eslint-disable-next-line new-cap
80
+export default AbstractPollResults(PollResults);

+ 48
- 0
react/features/polls/components/web/PollsList.js Zobrazit soubor

@@ -0,0 +1,48 @@
1
+// @flow
2
+
3
+import React, { useEffect, useRef } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { useSelector } from 'react-redux';
6
+
7
+import { Icon, IconChatUnread } from '../../../base/icons';
8
+
9
+import { PollItem } from '.';
10
+
11
+const PollsList = () => {
12
+    const { t } = useTranslation();
13
+
14
+    const polls = useSelector(state => state['features/polls'].polls);
15
+    const pollListEndRef = useRef(null);
16
+
17
+    const scrollToBottom = () => {
18
+        if (pollListEndRef.current) {
19
+            pollListEndRef.current.scrollIntoView({ behavior: 'smooth' });
20
+        }
21
+    };
22
+
23
+    useEffect(() => {
24
+        scrollToBottom();
25
+    }, [ polls ]);
26
+
27
+    const listPolls = Object.keys(polls);
28
+
29
+    return (
30
+    <>
31
+        {listPolls.length === 0
32
+            ? <div className = 'pane-content'>
33
+                <Icon
34
+                    className = 'empty-pane-icon'
35
+                    src = { IconChatUnread } />
36
+                <span className = 'empty-pane-message'>{t('polls.results.empty')}</span>
37
+            </div>
38
+            : listPolls.map((id, index) => (
39
+                <PollItem
40
+                    key = { id }
41
+                    pollId = { id }
42
+                    ref = { listPolls.length - 1 === index ? pollListEndRef : null } />
43
+            ))}
44
+    </>
45
+    );
46
+};
47
+
48
+export default PollsList;

+ 39
- 0
react/features/polls/components/web/PollsPane.js Zobrazit soubor

@@ -0,0 +1,39 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+
5
+import AbstractPollsPane from '../AbstractPollsPane';
6
+import type { AbstractProps } from '../AbstractPollsPane';
7
+
8
+import PollsList from './PollsList';
9
+
10
+import { PollCreate } from '.';
11
+
12
+const PollsPane = (props: AbstractProps) => {
13
+
14
+    const { createMode, onCreate, setCreateMode, t } = props;
15
+
16
+    return createMode
17
+        ? <PollCreate setCreateMode = { setCreateMode } />
18
+        : <div className = 'polls-pane-content'>
19
+            <div className = { 'poll-container' } >
20
+                <PollsList />
21
+            </div>
22
+            <div className = { 'poll-footer' }>
23
+                <button
24
+                    aria-label = { t('polls.create.create') }
25
+                    className = { 'poll-primary-button' }
26
+                    // eslint-disable-next-line react/jsx-no-bind
27
+                    onClick = { onCreate } >
28
+                    <span>{t('polls.create.create')}</span>
29
+                </button>
30
+            </div>
31
+        </div>;
32
+};
33
+
34
+/*
35
+ * We apply AbstractPollsPane to fill in the AbstractProps common
36
+ * to both the web and native implementations.
37
+ */
38
+// eslint-disable-next-line new-cap
39
+export default AbstractPollsPane(PollsPane);

+ 6
- 0
react/features/polls/components/web/index.js Zobrazit soubor

@@ -0,0 +1,6 @@
1
+// @flow
2
+export { default as PollAnswer } from './PollAnswer';
3
+export { default as PollCreate } from './PollCreate';
4
+export { default as PollResults } from './PollResults';
5
+export { default as PollsPane } from './PollsPane';
6
+export { default as PollItem } from './PollItem';

+ 5
- 0
react/features/polls/constants.js Zobrazit soubor

@@ -0,0 +1,5 @@
1
+// @flow
2
+
3
+export const COMMAND_NEW_POLL = 'new-poll';
4
+export const COMMAND_ANSWER_POLL = 'answer-poll';
5
+export const COMMAND_OLD_POLLS = 'old-polls';

+ 23
- 0
react/features/polls/functions.js Zobrazit soubor

@@ -0,0 +1,23 @@
1
+// @flow
2
+
3
+/**
4
+ * Should poll results be shown.
5
+ *
6
+ * @param {Object} state - Global state.
7
+ * @param {string} id - Id of the poll.
8
+ * @returns {boolean} Should poll results be shown.
9
+ */
10
+export const shouldShowResults = (state: Object, id: string) => Boolean(state['features/polls']?.polls[id].showResults);
11
+
12
+
13
+/**
14
+ * Selector for calculating the number of unread poll messages.
15
+ *
16
+ * @param {Object} state - The redux state.
17
+ * @returns {number} The number of unread messages.
18
+ */
19
+export function getUnreadPollCount(state: Object) {
20
+    const { nbUnreadPolls } = state['features/polls'];
21
+
22
+    return nbUnreadPolls;
23
+}

+ 32
- 0
react/features/polls/middleware.js Zobrazit soubor

@@ -0,0 +1,32 @@
1
+// @flow
2
+
3
+import { MiddlewareRegistry } from '../base/redux';
4
+import { playSound } from '../base/sounds';
5
+import { INCOMING_MSG_SOUND_ID } from '../chat/constants';
6
+
7
+import { RECEIVE_POLL } from './actionTypes';
8
+
9
+
10
+MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
11
+    const result = next(action);
12
+
13
+    switch (action.type) {
14
+
15
+    // Middleware triggered when a poll is received
16
+    case RECEIVE_POLL: {
17
+
18
+        const state = getState();
19
+        const isChatOpen: boolean = state['features/chat'].isOpen;
20
+        const isPollsTabFocused: boolean = state['features/chat'].isPollsTabFocused;
21
+
22
+        // Finally, we notify user they received a new poll if their pane is not opened
23
+        if (action.notify && (!isChatOpen || !isPollsTabFocused)) {
24
+            dispatch(playSound(INCOMING_MSG_SOUND_ID));
25
+        }
26
+        break;
27
+    }
28
+
29
+    }
30
+
31
+    return result;
32
+});

+ 128
- 0
react/features/polls/reducer.js Zobrazit soubor

@@ -0,0 +1,128 @@
1
+// @flow
2
+
3
+import { ReducerRegistry } from '../base/redux';
4
+
5
+import {
6
+    RECEIVE_POLL,
7
+    RECEIVE_ANSWER,
8
+    REGISTER_VOTE,
9
+    RETRACT_VOTE,
10
+    RESET_NB_UNREAD_POLLS
11
+} from './actionTypes';
12
+import type { Answer } from './types';
13
+
14
+const INITIAL_STATE = {
15
+    polls: {},
16
+
17
+    // Number of not read message
18
+    nbUnreadPolls: 0
19
+};
20
+
21
+ReducerRegistry.register('features/polls', (state = INITIAL_STATE, action) => {
22
+    switch (action.type) {
23
+
24
+    // Reducer triggered when a poll is received
25
+    case RECEIVE_POLL: {
26
+        const newState = {
27
+            ...state,
28
+            polls: {
29
+                ...state.polls,
30
+
31
+                // The poll is added to the dictionnary of received polls
32
+                [action.pollId]: action.poll
33
+            },
34
+            nbUnreadPolls: state.nbUnreadPolls + 1
35
+        };
36
+
37
+        return newState;
38
+    }
39
+
40
+    // Reducer triggered when an answer is received
41
+    // The answer is added  to an existing poll
42
+    case RECEIVE_ANSWER: {
43
+
44
+        const { pollId, answer }: { pollId: string; answer: Answer } = action;
45
+
46
+        // if the poll doesn't exist
47
+        if (!(pollId in state.polls)) {
48
+            console.warn('requested poll does not exist: pollId ', pollId);
49
+
50
+            return state;
51
+        }
52
+
53
+        // if the poll exists, we update it with the incoming answer
54
+        const newAnswers = state.polls[pollId].answers
55
+            .map(_answer => {
56
+                return {
57
+                    name: _answer.name,
58
+                    voters: new Map(_answer.voters)
59
+                };
60
+            });
61
+
62
+        for (let i = 0; i < newAnswers.length; i++) {
63
+            // if the answer was chosen, we add the sender to the set of voters of this answer
64
+            const voters = newAnswers[i].voters;
65
+
66
+            if (answer.answers[i]) {
67
+                voters.set(answer.voterId, answer.voterName);
68
+
69
+            } else {
70
+                voters.delete(answer.voterId);
71
+            }
72
+        }
73
+
74
+        // finally we update the state by returning the updated poll
75
+        return {
76
+            ...state,
77
+            polls: {
78
+                ...state.polls,
79
+                [pollId]: {
80
+                    ...state.polls[pollId],
81
+                    answers: newAnswers
82
+                }
83
+            }
84
+        };
85
+    }
86
+
87
+    case REGISTER_VOTE: {
88
+        const { answers, pollId }: { answers: Array<boolean> | null; pollId: string } = action;
89
+
90
+        return {
91
+            ...state,
92
+            polls: {
93
+                ...state.polls,
94
+                [pollId]: {
95
+                    ...state.polls[pollId],
96
+                    lastVote: answers,
97
+                    showResults: true
98
+                }
99
+            }
100
+        };
101
+    }
102
+
103
+    case RETRACT_VOTE: {
104
+        const { pollId }: { pollId: string } = action;
105
+
106
+        return {
107
+            ...state,
108
+            polls: {
109
+                ...state.polls,
110
+                [pollId]: {
111
+                    ...state.polls[pollId],
112
+                    showResults: false
113
+                }
114
+            }
115
+        };
116
+    }
117
+
118
+    case RESET_NB_UNREAD_POLLS: {
119
+        return {
120
+            ...state,
121
+            nbUnreadPolls: 0
122
+        };
123
+    }
124
+
125
+    default:
126
+        return state;
127
+    }
128
+});

+ 125
- 0
react/features/polls/subscriber.js Zobrazit soubor

@@ -0,0 +1,125 @@
1
+// @flow
2
+
3
+import { getCurrentConference } from '../base/conference';
4
+import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
5
+import { StateListenerRegistry } from '../base/redux';
6
+import {
7
+    NOTIFICATION_TIMEOUT,
8
+    NOTIFICATION_TYPE,
9
+    showNotification
10
+} from '../notifications';
11
+
12
+import { receiveAnswer, receivePoll } from './actions';
13
+import { COMMAND_NEW_POLL, COMMAND_ANSWER_POLL, COMMAND_OLD_POLLS } from './constants';
14
+import type { Answer, Poll } from './types';
15
+
16
+
17
+const parsePollData = (pollData): Poll | null => {
18
+    if (typeof pollData !== 'object' || pollData === null) {
19
+        return null;
20
+    }
21
+    const { id, senderId, senderName, question, answers } = pollData;
22
+
23
+    if (typeof id !== 'string' || typeof senderId !== 'string' || typeof senderName !== 'string'
24
+        || typeof question !== 'string' || !(answers instanceof Array)) {
25
+        return null;
26
+    }
27
+
28
+    const answersParsed = [];
29
+
30
+    for (const answer of answers) {
31
+        const voters = new Map();
32
+
33
+        for (const [ voterId, voter ] of Object.entries(answer.voters)) {
34
+            if (typeof voter !== 'string') {
35
+                return null;
36
+            }
37
+            voters.set(voterId, voter);
38
+        }
39
+
40
+        answersParsed.push({
41
+            name: answer.name,
42
+            voters
43
+        });
44
+    }
45
+
46
+    return {
47
+        senderId,
48
+        senderName,
49
+        question,
50
+        showResults: true,
51
+        lastVote: null,
52
+        answers: answersParsed
53
+    };
54
+};
55
+
56
+StateListenerRegistry.register(
57
+    state => getCurrentConference(state),
58
+    (conference, store, previousConference) => {
59
+        if (conference && conference !== previousConference) {
60
+            const receiveMessage = (_, data) => {
61
+                switch (data.type) {
62
+                case COMMAND_NEW_POLL: {
63
+                    const { question, answers, pollId, senderId, senderName } = data;
64
+
65
+                    const poll = {
66
+                        senderId,
67
+                        senderName,
68
+                        showResults: false,
69
+                        lastVote: null,
70
+                        question,
71
+                        answers: answers.map(answer => {
72
+                            return {
73
+                                name: answer,
74
+                                voters: new Map()
75
+                            };
76
+                        })
77
+                    };
78
+
79
+                    store.dispatch(receivePoll(pollId, poll, true));
80
+                    store.dispatch(showNotification({
81
+                        appearance: NOTIFICATION_TYPE.NORMAL,
82
+                        titleKey: 'polls.notification.title',
83
+                        descriptionKey: 'polls.notification.description'
84
+                    }, NOTIFICATION_TIMEOUT));
85
+                    break;
86
+
87
+                }
88
+
89
+                case COMMAND_ANSWER_POLL: {
90
+                    const { pollId, answers, voterId, voterName } = data;
91
+
92
+                    const receivedAnswer: Answer = {
93
+                        voterId,
94
+                        voterName,
95
+                        pollId,
96
+                        answers
97
+                    };
98
+
99
+                    store.dispatch(receiveAnswer(pollId, receivedAnswer));
100
+                    break;
101
+
102
+                }
103
+
104
+                case COMMAND_OLD_POLLS: {
105
+                    const { polls } = data;
106
+
107
+                    for (const pollData of polls) {
108
+                        const poll = parsePollData(pollData);
109
+
110
+                        if (poll === null) {
111
+                            console.warn('[features/polls] Invalid old poll data');
112
+                        } else {
113
+                            store.dispatch(receivePoll(pollData.id, poll, false));
114
+                        }
115
+                    }
116
+                    break;
117
+                }
118
+                }
119
+            };
120
+
121
+            conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, receiveMessage);
122
+            conference.on(JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED, receiveMessage);
123
+        }
124
+    }
125
+);

+ 61
- 0
react/features/polls/types.js Zobrazit soubor

@@ -0,0 +1,61 @@
1
+// @flow
2
+
3
+export type Answer = {
4
+
5
+    /**
6
+     * ID of the voter for this answer
7
+     */
8
+    voterId: string,
9
+
10
+    /**
11
+     * Name of the voter
12
+     */
13
+    voterName: string,
14
+
15
+    /**
16
+     * ID of the parent Poll of this answer
17
+     */
18
+    pollId: string,
19
+
20
+    /**
21
+     * An array of boolean: true if the answer was chosen by the responder, else false
22
+     */
23
+    answers: Array<boolean>
24
+};
25
+
26
+export type Poll = {
27
+
28
+    /**
29
+     * ID of the sender of this poll
30
+     */
31
+    senderId: string,
32
+
33
+
34
+    /**
35
+     * Name of the sender of this poll
36
+     * Store poll sender name in case they exit the call
37
+     */
38
+    senderName: string,
39
+
40
+    /**
41
+     * Whether the results should be shown instead of the answer form
42
+     */
43
+    showResults: boolean,
44
+
45
+    /**
46
+     * The last sent votes for this poll, or null if voting was skipped
47
+     * Note: This is reset when voting/skipping, not when clicking "Change vote"
48
+     */
49
+    lastVote: Array<boolean> | null,
50
+
51
+    /**
52
+     * The question asked by this poll
53
+     */
54
+    question: string,
55
+
56
+    /**
57
+     * An array of answers:
58
+     * the name of the answer name and a map of ids and names of voters voting for this option
59
+     */
60
+    answers: Array<{ name: string, voters: Map<string, string> }>,
61
+};

+ 126
- 0
resources/prosody-plugins/mod_polls.lua Zobrazit soubor

@@ -0,0 +1,126 @@
1
+-- This module provides persistence for the "polls" feature,
2
+-- by keeping track of the state of polls in each room, and sending
3
+-- that state to new participants when they join.
4
+
5
+local json = require("util.json");
6
+local st = require("util.stanza");
7
+
8
+local util = module:require("util");
9
+local muc = module:depends("muc");
10
+
11
+local is_healthcheck_room = util.is_healthcheck_room;
12
+
13
+-- Checks if the given stanza contains a JSON message,
14
+-- and that the message type pertains to the polls feature.
15
+-- If yes, returns the parsed message. Otherwise, returns nil.
16
+local function get_poll_message(stanza)
17
+	if stanza.attr.type ~= "groupchat" then
18
+		return nil;
19
+	end
20
+	local json_data = stanza:get_child_text("json-message", "http://jitsi.org/jitmeet");
21
+	if json_data == nil then
22
+		return nil;
23
+	end
24
+	local data = json.decode(json_data);
25
+	if data.type ~= "new-poll" and data.type ~= "answer-poll" then
26
+		return nil;
27
+	end
28
+	return data;
29
+end
30
+
31
+-- Logs a warning and returns true if a room does not
32
+-- have poll data associated with it.
33
+local function check_polls(room)
34
+	if room.polls == nil then
35
+		module:log("warn", "no polls data in room");
36
+		return true;
37
+	end
38
+	return false;
39
+end
40
+
41
+-- Sets up poll data in new rooms.
42
+module:hook("muc-room-created", function(event)
43
+	local room = event.room;
44
+	if is_healthcheck_room(room.jid) then return end
45
+	module:log("debug", "setting up polls in room "..tostring(room));
46
+	room.polls = {
47
+		by_id = {};
48
+		order = {};
49
+	};
50
+end);
51
+
52
+-- Keeps track of the current state of the polls in each room,
53
+-- by listening to "new-poll" and "answer-poll" messages,
54
+-- and updating the room poll data accordingly.
55
+-- This mirrors the client-side poll update logic.
56
+module:hook("message/bare", function(event)
57
+	local data = get_poll_message(event.stanza);
58
+	if data == nil then return end
59
+
60
+	local room = muc.get_room_from_jid(event.stanza.attr.to);
61
+
62
+	if data.type == "new-poll" then
63
+		if check_polls(room) then return end
64
+
65
+		local answers = {}
66
+		for _, name in ipairs(data.answers) do
67
+			table.insert(answers, { name = name, voters = {} });
68
+		end
69
+
70
+		local poll = {
71
+			id = data.pollId,
72
+			sender_id = data.senderId,
73
+			sender_name = data.senderName,
74
+			question = data.question,
75
+			answers = answers
76
+		};
77
+		room.polls.by_id[data.pollId] = poll
78
+		table.insert(room.polls.order, poll)
79
+
80
+	elseif data.type == "answer-poll" then
81
+		if check_polls(room) then return end
82
+
83
+		local poll = room.polls.by_id[data.pollId];
84
+		if poll == nil then
85
+			module:log("warn", "answering inexistent poll");
86
+			return;
87
+		end
88
+
89
+		for i, value in ipairs(data.answers) do
90
+			poll.answers[i].voters[data.voterId] = value and data.voterName or nil;
91
+		end
92
+	end
93
+end);
94
+
95
+-- Sends the current poll state to new occupants after joining a room.
96
+module:hook("muc-occupant-joined", function(event)
97
+	local room = event.room;
98
+	if is_healthcheck_room(room.jid) then return end
99
+	if room.polls == nil or #room.polls.order == 0 then
100
+		return
101
+	end
102
+
103
+	local data = {
104
+		type = "old-polls",
105
+		polls = {},
106
+	};
107
+	for i, poll in ipairs(room.polls.order) do
108
+		data.polls[i] = {
109
+			id = poll.id,
110
+			senderId = poll.sender_id,
111
+			senderName = poll.sender_name,
112
+			question = poll.question,
113
+			answers = poll.answers
114
+		};
115
+	end
116
+
117
+	local stanza = st.message({
118
+		from = room.jid,
119
+		to = event.occupant.jid
120
+	})
121
+	:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
122
+	:text(json.encode(data))
123
+	:up();
124
+
125
+	room:route_stanza(stanza);
126
+end);

Načítá se…
Zrušit
Uložit