From 141950d9e6a8f7f642ca640efb57b228478c51be Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 2 Dec 2021 17:12:18 +0000 Subject: [PATCH] Ensure my votes from a different device show up (#7233) Co-authored-by: Travis Ralston --- src/components/views/messages/MPollBody.tsx | 52 ++++++++++---- .../views/messages/MPollBody-test.tsx | 72 ++++++++++++++++--- 2 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 69ed8d22d5..2243b20be4 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -36,14 +36,15 @@ import ErrorDialog from '../dialogs/ErrorDialog'; const TEXT_NODE_TYPE = "org.matrix.msc1767.text"; interface IState { - selected?: string; - pollRelations: Relations; + selected?: string; // Which option was clicked by the local user + pollRelations: Relations; // Allows us to access voting events } @replaceableComponent("views.messages.MPollBody") export default class MPollBody extends React.Component { - static contextType = MatrixClientContext; + public static contextType = MatrixClientContext; public context!: React.ContextType; + private seenEventIds: string[] = []; // Events we have already seen constructor(props: IBodyProps) { super(props); @@ -98,7 +99,7 @@ export default class MPollBody extends React.Component { private onRelationsChange = () => { // We hold pollRelations in our state, and it has changed under us - this.forceUpdate(); + this.unselectIfNewEventFromMe(); }; private selectOption(answerId: string) { @@ -120,7 +121,7 @@ export default class MPollBody extends React.Component { this.props.mxEvent.getRoomId(), POLL_RESPONSE_EVENT_TYPE.name, responseContent, - ).catch(e => { + ).catch((e: any) => { console.error("Failed to submit poll response event:", e); Modal.createTrackedDialog( @@ -165,6 +166,33 @@ export default class MPollBody extends React.Component { ); } + /** + * If we've just received a new event that we hadn't seen + * before, and that event is me voting (e.g. from a different + * device) then forget when the local user selected. + * + * Either way, calls setState to update our list of events we + * have already seen. + */ + private unselectIfNewEventFromMe() { + const newEvents: MatrixEvent[] = this.state.pollRelations.getRelations() + .filter(isPollResponse) + .filter((mxEvent: MatrixEvent) => + !this.seenEventIds.includes(mxEvent.getId())); + let newSelected = this.state.selected; + + if (newEvents.length > 0) { + for (const mxEvent of newEvents) { + if (mxEvent.getSender() === this.context.getUserId()) { + newSelected = null; + } + } + } + const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()); + this.seenEventIds = this.seenEventIds.concat(newEventIds); + this.setState( { selected: newSelected } ); + } + private totalVotes(collectedVotes: Map): number { let sum = 0; for (const v of collectedVotes.values()) { @@ -254,13 +282,6 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote { } export function allVotes(pollRelations: Relations): Array { - function isPollResponse(responseEvent: MatrixEvent): boolean { - return ( - responseEvent.getType() === POLL_RESPONSE_EVENT_TYPE.name && - responseEvent.getContent().hasOwnProperty(POLL_RESPONSE_EVENT_TYPE.name) - ); - } - if (pollRelations) { return pollRelations.getRelations() .filter(isPollResponse) @@ -270,6 +291,13 @@ export function allVotes(pollRelations: Relations): Array { } } +function isPollResponse(responseEvent: MatrixEvent): boolean { + return ( + POLL_RESPONSE_EVENT_TYPE.matches(responseEvent.getType()) && + POLL_RESPONSE_EVENT_TYPE.findIn(responseEvent.getContent()) + ); +} + /** * Figure out the correct vote for each user. * @returns a Map of user ID to their vote info diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 3d2a51f4a5..11d0fc0087 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -23,9 +23,10 @@ import * as TestUtils from "../../../test-utils"; import { Callback, IContent, MatrixEvent } from "matrix-js-sdk"; import { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import { Relations } from "matrix-js-sdk/src/models/relations"; -import { IPollAnswer, IPollContent } from "../../../../src/polls/consts"; +import { IPollAnswer, IPollContent, POLL_RESPONSE_EVENT_TYPE } from "../../../../src/polls/consts"; import { UserVote, allVotes } from "../../../../src/components/views/messages/MPollBody"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps"; const CHECKED = "mx_MPollBody_option_checked"; @@ -52,12 +53,12 @@ describe("MPollBody", () => { new UserVote( ev1.getTs(), ev1.getSender(), - ev1.getContent()["org.matrix.msc3381.poll.response"].answers, + ev1.getContent()[POLL_RESPONSE_EVENT_TYPE.name].answers, ), new UserVote( ev2.getTs(), ev2.getSender(), - ev2.getContent()["org.matrix.msc3381.poll.response"].answers, + ev2.getContent()[POLL_RESPONSE_EVENT_TYPE.name].answers, ), ]); }); @@ -150,6 +151,55 @@ describe("MPollBody", () => { expect(voteButton(body, "italian").hasClass(CHECKED)).toBe(false); }); + it("cancels my local vote if another comes in", () => { + // Given I voted locally + const votes = [responseEvent("@me:example.com", "pizza", 100)]; + const body = newMPollBody(votes); + const props: IBodyProps = body.instance().props as IBodyProps; + const pollRelations: Relations = props.getRelationsForEvent( + "$mypoll", "m.reference", POLL_RESPONSE_EVENT_TYPE.name); + clickRadio(body, "pizza"); + + // When a new vote from me comes in + pollRelations.addEvent(responseEvent("@me:example.com", "wings", 101)); + + // Then the new vote is counted, not the old one + expect(votesCount(body, "pizza")).toBe("0 votes"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("1 vote"); + + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 1 vote"); + }); + + it("doesn't cancel my local vote if someone else votes", () => { + // Given I voted locally + const votes = [responseEvent("@me:example.com", "pizza")]; + const body = newMPollBody(votes); + const props: IBodyProps = body.instance().props as IBodyProps; + const pollRelations: Relations = props.getRelationsForEvent( + "$mypoll", "m.reference", POLL_RESPONSE_EVENT_TYPE.name); + clickRadio(body, "pizza"); + + // When a new vote from someone else comes in + pollRelations.addEvent(responseEvent("@xx:example.com", "wings", 101)); + + // Then my vote is still for pizza + // NOTE: the new event does not affect the counts for other people - + // that is handled through the Relations, not by listening to + // these timeline events. + expect(votesCount(body, "pizza")).toBe("1 vote"); + expect(votesCount(body, "poutine")).toBe("0 votes"); + expect(votesCount(body, "italian")).toBe("0 votes"); + expect(votesCount(body, "wings")).toBe("1 vote"); + + expect(body.find(".mx_MPollBody_totalVotes").text()).toBe("Based on 2 votes"); + + // And my vote is highlighted + expect(voteButton(body, "pizza").hasClass(CHECKED)).toBe(true); + expect(voteButton(body, "wings").hasClass(CHECKED)).toBe(false); + }); + it("highlights my vote even if I did it on another device", () => { // Given I voted italian const votes = [ @@ -363,7 +413,7 @@ describe("MPollBody", () => { function newPollRelations(relationEvents: Array): Relations { const pollRelations = new Relations( - "m.reference", "org.matrix.msc3381.poll.response", null); + "m.reference", POLL_RESPONSE_EVENT_TYPE.name, null); for (const ev of relationEvents) { pollRelations.addEvent(ev); } @@ -375,7 +425,7 @@ function newMPollBody( answers?: IPollAnswer[], ): ReactWrapper { const pollRelations = new Relations( - "m.reference", "org.matrix.msc3381.poll.response", null); + "m.reference", POLL_RESPONSE_EVENT_TYPE.name, null); for (const ev of relationEvents) { pollRelations.addEvent(ev); } @@ -390,7 +440,7 @@ function newMPollBody( (eventId: string, relationType: string, eventType: string) => { expect(eventId).toBe("$mypoll"); expect(relationType).toBe("m.reference"); - expect(eventType).toBe("org.matrix.msc3381.poll.response"); + expect(eventType).toBe(POLL_RESPONSE_EVENT_TYPE.name); return pollRelations; } } @@ -440,7 +490,7 @@ function badResponseEvent(): MatrixEvent { return new MatrixEvent( { "event_id": nextId(), - "type": "org.matrix.msc3381.poll.response", + "type": POLL_RESPONSE_EVENT_TYPE.name, "content": { "m.relates_to": { "rel_type": "m.reference", @@ -463,14 +513,14 @@ function responseEvent( "event_id": nextId(), "room_id": "#myroom:example.com", "origin_server_ts": ts, - "type": "org.matrix.msc3381.poll.response", + "type": POLL_RESPONSE_EVENT_TYPE.name, "sender": sender, "content": { "m.relates_to": { "rel_type": "m.reference", "event_id": "$mypoll", }, - "org.matrix.msc3381.poll.response": { + [POLL_RESPONSE_EVENT_TYPE.name]: { "answers": ans, }, }, @@ -481,7 +531,7 @@ function responseEvent( function expectedResponseEvent(answer: string) { return { "content": { - "org.matrix.msc3381.poll.response": { + [POLL_RESPONSE_EVENT_TYPE.name]: { "answers": [answer], }, "m.relates_to": { @@ -489,7 +539,7 @@ function expectedResponseEvent(answer: string) { "rel_type": "m.reference", }, }, - "eventType": "org.matrix.msc3381.poll.response", + "eventType": POLL_RESPONSE_EVENT_TYPE.name, "roomId": "#myroom:example.com", "txnId": undefined, "callback": undefined,