Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d705fdd

Browse files
Display and send votes in polls (#7158)
Co-authored-by: Travis Ralston <travpc@gmail.com>
1 parent a156ba8 commit d705fdd

File tree

9 files changed

+1740
-15
lines changed

9 files changed

+1740
-15
lines changed

res/css/views/messages/_MPollBody.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ limitations under the License.
8686

8787
.mx_MPollBody_option_checked {
8888
border-color: $accent;
89+
90+
.mx_MPollBody_popularityBackground {
91+
.mx_MPollBody_popularityAmount {
92+
background-color: $accent;
93+
}
94+
}
8995
}
9096

9197
.mx_StyledRadioButton_checked input[type="radio"] + div {

src/components/views/messages/IBodyProps.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { MatrixEvent } from "matrix-js-sdk/src";
17+
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
1818
import { TileShape } from "../rooms/EventTile";
1919
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
2020
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
2121
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
22+
import { Relations } from "matrix-js-sdk/src/models/relations";
2223

2324
export interface IBodyProps {
2425
mxEvent: MatrixEvent;
@@ -41,4 +42,7 @@ export interface IBodyProps {
4142
onMessageAllowed: () => void; // TODO: Docs
4243
permalinkCreator: RoomPermalinkCreator;
4344
mediaEventHelper: MediaEventHelper;
45+
46+
// helper function to access relations for this event
47+
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
4448
}

src/components/views/messages/MPollBody.tsx

Lines changed: 231 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,48 +18,174 @@ import React from 'react';
1818
import { _t } from '../../../languageHandler';
1919
import { replaceableComponent } from "../../../utils/replaceableComponent";
2020
import { 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';
2228
import 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
2534
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
2635

2736
interface IState {
2837
selected?: string;
38+
pollRelations: Relations;
2939
}
3040

3141
@replaceableComponent("views.messages.MPollBody")
3242
export 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+
}

src/components/views/messages/MessageEvent.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,16 @@ import { ReactAnyComponent } from "../../../@types/common";
2828
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
2929
import { IBodyProps } from "./IBodyProps";
3030
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
31+
import { Relations } from 'matrix-js-sdk/src/models/relations';
3132

3233
// onMessageAllowed is handled internally
3334
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
3435
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
3536
overrideBodyTypes?: Record<string, React.Component>;
3637
overrideEventTypes?: Record<string, React.Component>;
38+
39+
// helper function to access relations for this event
40+
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
3741
}
3842

3943
@replaceableComponent("views.messages.MessageEvent")
@@ -154,6 +158,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
154158
onMessageAllowed={this.onTileUpdate}
155159
permalinkCreator={this.props.permalinkCreator}
156160
mediaEventHelper={this.mediaHelper}
161+
getRelationsForEvent={this.props.getRelationsForEvent}
157162
/> : null;
158163
}
159164
}

0 commit comments

Comments
 (0)