@@ -18,48 +18,174 @@ import React from 'react';
1818import { _t } from '../../../languageHandler' ;
1919import { replaceableComponent } from "../../../utils/replaceableComponent" ;
2020import { IBodyProps } from "./IBodyProps" ;
21- import { IPollAnswer , IPollContent , POLL_START_EVENT_TYPE } from '../../../polls/consts' ;
21+ import {
22+ IPollAnswer ,
23+ IPollContent ,
24+ IPollResponse ,
25+ POLL_RESPONSE_EVENT_TYPE ,
26+ POLL_START_EVENT_TYPE ,
27+ } from '../../../polls/consts' ;
2228import StyledRadioButton from '../elements/StyledRadioButton' ;
29+ import { MatrixEvent } from "matrix-js-sdk/src/models/event" ;
30+ import { Relations } from 'matrix-js-sdk/src/models/relations' ;
31+ import { MatrixClientPeg } from '../../../MatrixClientPeg' ;
2332
2433// TODO: [andyb] Use extensible events library when ready
2534const TEXT_NODE_TYPE = "org.matrix.msc1767.text" ;
2635
2736interface IState {
2837 selected ?: string ;
38+ pollRelations : Relations ;
2939}
3040
3141@replaceableComponent ( "views.messages.MPollBody" )
3242export default class MPollBody extends React . Component < IBodyProps , IState > {
3343 constructor ( props : IBodyProps ) {
3444 super ( props ) ;
3545
36- this . state = {
37- selected : null ,
38- } ;
46+ const pollRelations = this . fetchPollRelations ( ) ;
47+ let selected = null ;
48+
49+ const userVotes = collectUserVotes ( allVotes ( pollRelations ) , null ) ;
50+ const userId = MatrixClientPeg . get ( ) . getUserId ( ) ;
51+ const currentVote = userVotes . get ( userId ) ;
52+ if ( currentVote ) {
53+ selected = currentVote . answers [ 0 ] ;
54+ }
55+
56+ this . state = { selected, pollRelations } ;
57+
58+ this . addListeners ( this . state . pollRelations ) ;
59+ this . props . mxEvent . on ( "Event.relationsCreated" , this . onPollRelationsCreated ) ;
60+ }
61+
62+ componentWillUnmount ( ) {
63+ this . props . mxEvent . off ( "Event.relationsCreated" , this . onPollRelationsCreated ) ;
64+ this . removeListeners ( this . state . pollRelations ) ;
65+ }
66+
67+ private addListeners ( pollRelations ?: Relations ) {
68+ if ( pollRelations ) {
69+ pollRelations . on ( "Relations.add" , this . onRelationsChange ) ;
70+ pollRelations . on ( "Relations.remove" , this . onRelationsChange ) ;
71+ pollRelations . on ( "Relations.redaction" , this . onRelationsChange ) ;
72+ }
73+ }
74+
75+ private removeListeners ( pollRelations ?: Relations ) {
76+ if ( pollRelations ) {
77+ pollRelations . off ( "Relations.add" , this . onRelationsChange ) ;
78+ pollRelations . off ( "Relations.remove" , this . onRelationsChange ) ;
79+ pollRelations . off ( "Relations.redaction" , this . onRelationsChange ) ;
80+ }
3981 }
4082
83+ private onPollRelationsCreated = ( relationType : string , eventType : string ) => {
84+ if (
85+ relationType === "m.reference" &&
86+ POLL_RESPONSE_EVENT_TYPE . matches ( eventType )
87+ ) {
88+ this . props . mxEvent . removeListener (
89+ "Event.relationsCreated" , this . onPollRelationsCreated ) ;
90+
91+ const newPollRelations = this . fetchPollRelations ( ) ;
92+ this . addListeners ( newPollRelations ) ;
93+ this . removeListeners ( this . state . pollRelations ) ;
94+
95+ this . setState ( {
96+ pollRelations : newPollRelations ,
97+ } ) ;
98+ }
99+ } ;
100+
101+ private onRelationsChange = ( ) => {
102+ // We hold pollRelations in our state, and it has changed under us
103+ this . forceUpdate ( ) ;
104+ } ;
105+
41106 private selectOption ( answerId : string ) {
107+ if ( answerId === this . state . selected ) {
108+ return ;
109+ }
110+
111+ const responseContent : IPollResponse = {
112+ [ POLL_RESPONSE_EVENT_TYPE . name ] : {
113+ "answers" : [ answerId ] ,
114+ } ,
115+ "m.relates_to" : {
116+ "event_id" : this . props . mxEvent . getId ( ) ,
117+ "rel_type" : "m.reference" ,
118+ } ,
119+ } ;
120+ MatrixClientPeg . get ( ) . sendEvent (
121+ this . props . mxEvent . getRoomId ( ) ,
122+ POLL_RESPONSE_EVENT_TYPE . name ,
123+ responseContent ,
124+ ) . catch ( e => {
125+ console . error ( "Failed to submit poll response event:" , e ) ;
126+ } ) ;
127+
42128 this . setState ( { selected : answerId } ) ;
43129 }
44130
45131 private onOptionSelected = ( e : React . FormEvent < HTMLInputElement > ) : void => {
46132 this . selectOption ( e . currentTarget . value ) ;
47133 } ;
48134
135+ private fetchPollRelations ( ) : Relations | null {
136+ if ( this . props . getRelationsForEvent ) {
137+ return this . props . getRelationsForEvent (
138+ this . props . mxEvent . getId ( ) ,
139+ "m.reference" ,
140+ POLL_RESPONSE_EVENT_TYPE . name ,
141+ ) ;
142+ } else {
143+ return null ;
144+ }
145+ }
146+
147+ /**
148+ * @returns answer-id -> number-of-votes
149+ */
150+ private collectVotes ( ) : Map < string , number > {
151+ return countVotes (
152+ collectUserVotes ( allVotes ( this . state . pollRelations ) , this . state . selected ) ,
153+ this . props . mxEvent . getContent ( ) ,
154+ ) ;
155+ }
156+
157+ private totalVotes ( collectedVotes : Map < string , number > ) : number {
158+ let sum = 0 ;
159+ for ( const v of collectedVotes . values ( ) ) {
160+ sum += v ;
161+ }
162+ return sum ;
163+ }
164+
49165 render ( ) {
50- const pollStart : IPollContent =
51- this . props . mxEvent . getContent ( ) [ POLL_START_EVENT_TYPE . name ] ;
166+ const pollStart : IPollContent = this . props . mxEvent . getContent ( ) ;
167+ const pollInfo = pollStart [ POLL_START_EVENT_TYPE . name ] ;
168+
169+ if ( pollInfo . answers . length < 1 || pollInfo . answers . length > 20 ) {
170+ return null ;
171+ }
172+
52173 const pollId = this . props . mxEvent . getId ( ) ;
174+ const votes = this . collectVotes ( ) ;
175+ const totalVotes = this . totalVotes ( votes ) ;
53176
54177 return < div className = "mx_MPollBody" >
55- < h2 > { pollStart . question [ TEXT_NODE_TYPE ] } </ h2 >
178+ < h2 > { pollInfo . question [ TEXT_NODE_TYPE ] } </ h2 >
56179 < div className = "mx_MPollBody_allOptions" >
57180 {
58- pollStart . answers . map ( ( answer : IPollAnswer ) => {
181+ pollInfo . answers . map ( ( answer : IPollAnswer ) => {
59182 const checked = this . state . selected === answer . id ;
60183 const classNames = `mx_MPollBody_option${
61184 checked ? " mx_MPollBody_option_checked" : ""
62185 } `;
186+ const answerVotes = votes . get ( answer . id ) ?? 0 ;
187+ const answerPercent = Math . round (
188+ 100.0 * answerVotes / totalVotes ) ;
63189 return < div
64190 key = { answer . id }
65191 className = { classNames }
@@ -72,22 +198,116 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
72198 onChange = { this . onOptionSelected }
73199 >
74200 < div className = "mx_MPollBody_optionVoteCount" >
75- { _t ( "%(number )s votes" , { number : 0 } ) }
201+ { _t ( "%(count )s votes" , { count : answerVotes } ) }
76202 </ div >
77203 < div className = "mx_MPollBody_optionText" >
78204 { answer [ TEXT_NODE_TYPE ] }
79205 </ div >
80206 </ StyledRadioButton >
81207 < div className = "mx_MPollBody_popularityBackground" >
82- < div className = "mx_MPollBody_popularityAmount" />
208+ < div
209+ className = "mx_MPollBody_popularityAmount"
210+ style = { { "width" : `${ answerPercent } %` } }
211+ />
83212 </ div >
84213 </ div > ;
85214 } )
86215 }
87216 </ div >
88217 < div className = "mx_MPollBody_totalVotes" >
89- { _t ( "Based on %(total )s votes" , { total : 0 } ) }
218+ { _t ( "Based on %(count )s votes" , { count : totalVotes } ) }
90219 </ div >
91220 </ div > ;
92221 }
93222}
223+
224+ export class UserVote {
225+ constructor ( public readonly ts : number , public readonly sender : string , public readonly answers : string [ ] ) {
226+ }
227+ }
228+
229+ function userResponseFromPollResponseEvent ( event : MatrixEvent ) : UserVote {
230+ const pr = event . getContent ( ) as IPollResponse ;
231+ const answers = pr [ POLL_RESPONSE_EVENT_TYPE . name ] . answers ;
232+
233+ return new UserVote (
234+ event . getTs ( ) ,
235+ event . getSender ( ) ,
236+ answers ,
237+ ) ;
238+ }
239+
240+ export function allVotes ( pollRelations : Relations ) : Array < UserVote > {
241+ function isPollResponse ( responseEvent : MatrixEvent ) : boolean {
242+ return (
243+ responseEvent . getType ( ) === POLL_RESPONSE_EVENT_TYPE . name &&
244+ responseEvent . getContent ( ) . hasOwnProperty ( POLL_RESPONSE_EVENT_TYPE . name )
245+ ) ;
246+ }
247+
248+ if ( pollRelations ) {
249+ return pollRelations . getRelations ( )
250+ . filter ( isPollResponse )
251+ . map ( userResponseFromPollResponseEvent ) ;
252+ } else {
253+ return [ ] ;
254+ }
255+ }
256+
257+ /**
258+ * Figure out the correct vote for each user.
259+ * @returns a Map of user ID to their vote info
260+ */
261+ function collectUserVotes (
262+ userResponses : Array < UserVote > ,
263+ selected ?: string ,
264+ ) : Map < string , UserVote > {
265+ const userVotes : Map < string , UserVote > = new Map ( ) ;
266+
267+ for ( const response of userResponses ) {
268+ const otherResponse = userVotes . get ( response . sender ) ;
269+ if ( ! otherResponse || otherResponse . ts < response . ts ) {
270+ userVotes . set ( response . sender , response ) ;
271+ }
272+ }
273+
274+ if ( selected ) {
275+ const client = MatrixClientPeg . get ( ) ;
276+ const userId = client . getUserId ( ) ;
277+ userVotes . set ( userId , new UserVote ( 0 , userId , [ selected ] ) ) ;
278+ }
279+
280+ return userVotes ;
281+ }
282+
283+ function countVotes (
284+ userVotes : Map < string , UserVote > ,
285+ pollStart : IPollContent ,
286+ ) : Map < string , number > {
287+ const collected = new Map < string , number > ( ) ;
288+
289+ const pollInfo = pollStart [ POLL_START_EVENT_TYPE . name ] ;
290+ const maxSelections = 1 ; // See MSC3381 - later this will be in pollInfo
291+
292+ const allowedAnswerIds = pollInfo . answers . map ( ( ans : IPollAnswer ) => ans . id ) ;
293+ function isValidAnswer ( answerId : string ) {
294+ return allowedAnswerIds . includes ( answerId ) ;
295+ }
296+
297+ for ( const response of userVotes . values ( ) ) {
298+ if ( response . answers . every ( isValidAnswer ) ) {
299+ for ( const [ index , answerId ] of response . answers . entries ( ) ) {
300+ if ( index >= maxSelections ) {
301+ break ;
302+ }
303+ if ( collected . has ( answerId ) ) {
304+ collected . set ( answerId , collected . get ( answerId ) + 1 ) ;
305+ } else {
306+ collected . set ( answerId , 1 ) ;
307+ }
308+ }
309+ }
310+ }
311+
312+ return collected ;
313+ }
0 commit comments