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

Ensure my votes from a different device show up #7233

Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions src/components/views/messages/MPollBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IBodyProps, IState> {
static contextType = MatrixClientContext;
andybalaam marked this conversation as resolved.
Show resolved Hide resolved
public context!: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen

constructor(props: IBodyProps) {
super(props);
Expand Down Expand Up @@ -98,7 +99,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {

private onRelationsChange = () => {
// We hold pollRelations in our state, and it has changed under us
this.forceUpdate();
this.unselectIfNewEventFromMe();
};

private selectOption(answerId: string) {
Expand All @@ -120,7 +121,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
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(
Expand Down Expand Up @@ -165,6 +166,33 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
);
}

/**
* 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<string, number>): number {
let sum = 0;
for (const v of collectedVotes.values()) {
Expand Down Expand Up @@ -254,13 +282,6 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
}

export function allVotes(pollRelations: Relations): Array<UserVote> {
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)
Expand All @@ -270,6 +291,13 @@ export function allVotes(pollRelations: Relations): Array<UserVote> {
}
}

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
Expand Down
72 changes: 61 additions & 11 deletions test/components/views/messages/MPollBody-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
),
]);
});
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -363,7 +413,7 @@ describe("MPollBody", () => {

function newPollRelations(relationEvents: Array<MatrixEvent>): 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);
}
Expand All @@ -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);
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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",
Expand All @@ -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,
},
},
Expand All @@ -481,15 +531,15 @@ function responseEvent(
function expectedResponseEvent(answer: string) {
return {
"content": {
"org.matrix.msc3381.poll.response": {
[POLL_RESPONSE_EVENT_TYPE.name]: {
"answers": [answer],
},
"m.relates_to": {
"event_id": "$mypoll",
"rel_type": "m.reference",
},
},
"eventType": "org.matrix.msc3381.poll.response",
"eventType": POLL_RESPONSE_EVENT_TYPE.name,
"roomId": "#myroom:example.com",
"txnId": undefined,
"callback": undefined,
Expand Down