use Poll model with relations API in poll rendering (#9877)

* wip

* remove dupe

* use poll model relations in all cases

* update mpollbody tests to use poll instance

* update poll fetching login in pinned messages card

* add pinned polls to room polls state

* add spinner while relations are still loading

* handle no poll in end poll dialog

* strict errors

* strict fix

* more strict fix
This commit is contained in:
Kerry 2023-02-03 09:22:26 +13:00 committed by GitHub
parent b45b933a65
commit 544baa30ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 350 additions and 670 deletions

View file

@ -150,8 +150,16 @@ limitations under the License.
} }
.mx_MPollBody_totalVotes { .mx_MPollBody_totalVotes {
display: flex;
flex-direction: inline;
justify-content: start;
color: $secondary-content; color: $secondary-content;
font-size: $font-12px; font-size: $font-12px;
.mx_Spinner {
flex: 0;
margin-left: $spacing-8;
}
} }
} }

View file

@ -195,7 +195,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
return ( return (
M_POLL_START.matches(mxEvent.getType()) && M_POLL_START.matches(mxEvent.getType()) &&
this.state.canRedact && this.state.canRedact &&
!isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent) !isPollEnded(mxEvent, MatrixClientPeg.get())
); );
} }

View file

@ -35,26 +35,34 @@ interface IProps extends IDialogProps {
} }
export default class EndPollDialog extends React.Component<IProps> { export default class EndPollDialog extends React.Component<IProps> {
private onFinished = (endPoll: boolean): void => { private onFinished = async (endPoll: boolean): Promise<void> => {
const topAnswer = findTopAnswer(this.props.event, this.props.matrixClient, this.props.getRelationsForEvent);
const message =
topAnswer === ""
? _t("The poll has ended. No votes were cast.")
: _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer });
if (endPoll) { if (endPoll) {
const endEvent = PollEndEvent.from(this.props.event.getId(), message).serialize(); const room = this.props.matrixClient.getRoom(this.props.event.getRoomId());
const poll = room?.polls.get(this.props.event.getId()!);
this.props.matrixClient if (!poll) {
.sendEvent(this.props.event.getRoomId(), endEvent.type, endEvent.content) throw new Error("No poll instance found in room.");
.catch((e: any) => { }
console.error("Failed to submit poll response event:", e);
Modal.createDialog(ErrorDialog, { try {
title: _t("Failed to end poll"), const responses = await poll.getResponses();
description: _t("Sorry, the poll did not end. Please try again."), const topAnswer = findTopAnswer(this.props.event, responses);
});
const message =
topAnswer === ""
? _t("The poll has ended. No votes were cast.")
: _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer });
const endEvent = PollEndEvent.from(this.props.event.getId()!, message).serialize();
await this.props.matrixClient.sendEvent(this.props.event.getRoomId()!, endEvent.type, endEvent.content);
} catch (e) {
console.error("Failed to submit poll response event:", e);
Modal.createDialog(ErrorDialog, {
title: _t("Failed to end poll"),
description: _t("Sorry, the poll did not end. Please try again."),
}); });
}
} }
this.props.onFinished(endPoll); this.props.onFinished(endPoll);
}; };

View file

@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls";
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
import { NamespacedValue } from "matrix-events-sdk";
import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { Poll, PollEvent } from "matrix-js-sdk/src/models/poll";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
@ -36,11 +36,14 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import { GetRelationsForEvent } from "../rooms/EventTile"; import { GetRelationsForEvent } from "../rooms/EventTile";
import PollCreateDialog from "../elements/PollCreateDialog"; import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Spinner from "../elements/Spinner";
interface IState { interface IState {
poll?: Poll;
// poll instance has fetched at least one page of responses
pollInitialised: boolean;
selected?: string | null | undefined; // Which option was clicked by the local user selected?: string | null | undefined; // Which option was clicked by the local user
voteRelations: RelatedRelations; // Voting (response) events voteRelations?: Relations; // Voting (response) events
endRelations: RelatedRelations; // Poll end events
} }
export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations { export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations {
@ -59,15 +62,7 @@ export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent,
return new RelatedRelations(relationsList); return new RelatedRelations(relationsList);
} }
export function findTopAnswer( export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): string {
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: GetRelationsForEvent,
): string {
if (!getRelationsForEvent) {
return "";
}
const pollEventId = pollEvent.getId(); const pollEventId = pollEvent.getId();
if (!pollEventId) { if (!pollEventId) {
logger.warn( logger.warn(
@ -87,25 +82,7 @@ export function findTopAnswer(
return poll.answers.find((a) => a.id === answerId)?.text ?? ""; return poll.answers.find((a) => a.id === answerId)?.text ?? "";
}; };
const voteRelations = createVoteRelations(getRelationsForEvent, pollEventId); const userVotes: Map<string, UserVote> = collectUserVotes(allVotes(voteRelations));
const relationsList: Relations[] = [];
const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name);
if (pollEndRelations) {
relationsList.push(pollEndRelations);
}
const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName);
if (pollEndAltRelations) {
relationsList.push(pollEndAltRelations);
}
const endRelations = new RelatedRelations(relationsList);
const userVotes: Map<string, UserVote> = collectUserVotes(
allVotes(pollEvent, matrixClient, voteRelations, endRelations),
);
const votes: Map<string, number> = countVotes(userVotes, poll); const votes: Map<string, number> = countVotes(userVotes, poll);
const highestScore: number = Math.max(...votes.values()); const highestScore: number = Math.max(...votes.values());
@ -122,62 +99,13 @@ export function findTopAnswer(
return formatCommaSeparatedList(bestAnswerTexts, 3); return formatCommaSeparatedList(bestAnswerTexts, 3);
} }
export function isPollEnded( export function isPollEnded(pollEvent: MatrixEvent, matrixClient: MatrixClient): boolean {
pollEvent: MatrixEvent, const room = matrixClient.getRoom(pollEvent.getRoomId());
matrixClient: MatrixClient, const poll = room?.polls.get(pollEvent.getId()!);
getRelationsForEvent?: GetRelationsForEvent, if (!poll || poll.isFetchingResponses) {
): boolean {
if (!getRelationsForEvent) {
return false; return false;
} }
return poll.isEnded;
const pollEventId = pollEvent.getId();
if (!pollEventId) {
logger.warn(
"isPollEnded: Poll event must have event ID in order to determine whether it has ended " +
"- assuming poll has not ended",
);
return false;
}
const roomId = pollEvent.getRoomId();
if (!roomId) {
logger.warn(
"isPollEnded: Poll event must have room ID in order to determine whether it has ended " +
"- assuming poll has not ended",
);
return false;
}
const roomCurrentState = matrixClient.getRoom(roomId)?.currentState;
function userCanRedact(endEvent: MatrixEvent): boolean {
const endEventSender = endEvent.getSender();
return (
endEventSender && roomCurrentState && roomCurrentState.maySendRedactionForEvent(pollEvent, endEventSender)
);
}
const relationsList: Relations[] = [];
const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name);
if (pollEndRelations) {
relationsList.push(pollEndRelations);
}
const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName);
if (pollEndAltRelations) {
relationsList.push(pollEndAltRelations);
}
const endRelations = new RelatedRelations(relationsList);
if (!endRelations) {
return false;
}
const authorisedRelations = endRelations.getRelations().filter(userCanRedact);
return authorisedRelations.length > 0;
} }
export function pollAlreadyHasVotes(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): boolean { export function pollAlreadyHasVotes(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): boolean {
@ -215,75 +143,58 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
public static contextType = MatrixClientContext; public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>; public context!: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen private seenEventIds: string[] = []; // Events we have already seen
private voteRelationsReceived = false;
private endRelationsReceived = false;
public constructor(props: IBodyProps) { public constructor(props: IBodyProps) {
super(props); super(props);
this.state = { this.state = {
selected: null, selected: null,
voteRelations: this.fetchVoteRelations(), pollInitialised: false,
endRelations: this.fetchEndRelations(),
}; };
}
this.addListeners(this.state.voteRelations, this.state.endRelations); public componentDidMount(): void {
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); const room = this.context.getRoom(this.props.mxEvent.getRoomId());
const poll = room?.polls.get(this.props.mxEvent.getId()!);
if (poll) {
this.setPollInstance(poll);
} else {
room?.on(PollEvent.New, this.setPollInstance.bind(this));
}
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
this.props.mxEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); this.removeListeners();
this.removeListeners(this.state.voteRelations, this.state.endRelations);
} }
private addListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void { private async setPollInstance(poll: Poll): Promise<void> {
if (voteRelations) { if (poll.pollId !== this.props.mxEvent.getId()) {
voteRelations.on(RelationsEvent.Add, this.onRelationsChange);
voteRelations.on(RelationsEvent.Remove, this.onRelationsChange);
voteRelations.on(RelationsEvent.Redaction, this.onRelationsChange);
}
if (endRelations) {
endRelations.on(RelationsEvent.Add, this.onRelationsChange);
endRelations.on(RelationsEvent.Remove, this.onRelationsChange);
endRelations.on(RelationsEvent.Redaction, this.onRelationsChange);
}
}
private removeListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void {
if (voteRelations) {
voteRelations.off(RelationsEvent.Add, this.onRelationsChange);
voteRelations.off(RelationsEvent.Remove, this.onRelationsChange);
voteRelations.off(RelationsEvent.Redaction, this.onRelationsChange);
}
if (endRelations) {
endRelations.off(RelationsEvent.Add, this.onRelationsChange);
endRelations.off(RelationsEvent.Remove, this.onRelationsChange);
endRelations.off(RelationsEvent.Redaction, this.onRelationsChange);
}
}
private onRelationsCreated = (relationType: string, eventType: string): void => {
if (relationType !== "m.reference") {
return; return;
} }
this.setState({ poll }, () => {
this.addListeners();
});
const responses = await poll.getResponses();
const voteRelations = responses;
if (M_POLL_RESPONSE.matches(eventType)) { this.setState({ pollInitialised: true, voteRelations });
this.voteRelationsReceived = true; }
const newVoteRelations = this.fetchVoteRelations();
this.addListeners(newVoteRelations);
this.removeListeners(this.state.voteRelations);
this.setState({ voteRelations: newVoteRelations });
} else if (M_POLL_END.matches(eventType)) {
this.endRelationsReceived = true;
const newEndRelations = this.fetchEndRelations();
this.addListeners(newEndRelations);
this.removeListeners(this.state.endRelations);
this.setState({ endRelations: newEndRelations });
}
if (this.voteRelationsReceived && this.endRelationsReceived) { private addListeners(): void {
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); this.state.poll?.on(PollEvent.Responses, this.onResponsesChange);
this.state.poll?.on(PollEvent.End, this.onRelationsChange);
}
private removeListeners(): void {
if (this.state.poll) {
this.state.poll.off(PollEvent.Responses, this.onResponsesChange);
this.state.poll.off(PollEvent.End, this.onRelationsChange);
} }
}
private onResponsesChange = (responses: Relations): void => {
this.setState({ voteRelations: responses });
this.onRelationsChange();
}; };
private onRelationsChange = (): void => { private onRelationsChange = (): void => {
@ -295,19 +206,19 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
}; };
private selectOption(answerId: string): void { private selectOption(answerId: string): void {
if (this.isEnded()) { if (this.state.poll?.isEnded) {
return; return;
} }
const userVotes = this.collectUserVotes(); const userVotes = this.collectUserVotes();
const userId = this.context.getUserId(); const userId = this.context.getSafeUserId();
const myVote = userVotes.get(userId)?.answers[0]; const myVote = userVotes.get(userId)?.answers[0];
if (answerId === myVote) { if (answerId === myVote) {
return; return;
} }
const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()).serialize(); const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize();
this.context.sendEvent(this.props.mxEvent.getRoomId(), response.type, response.content).catch((e: any) => { this.context.sendEvent(this.props.mxEvent.getRoomId()!, response.type, response.content).catch((e: any) => {
console.error("Failed to submit poll response event:", e); console.error("Failed to submit poll response event:", e);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
@ -323,51 +234,14 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
this.selectOption(e.currentTarget.value); this.selectOption(e.currentTarget.value);
}; };
private fetchVoteRelations(): RelatedRelations | null {
return this.fetchRelations(M_POLL_RESPONSE);
}
private fetchEndRelations(): RelatedRelations | null {
return this.fetchRelations(M_POLL_END);
}
private fetchRelations(eventType: NamespacedValue<string, string>): RelatedRelations | null {
if (this.props.getRelationsForEvent) {
const relationsList: Relations[] = [];
const eventId = this.props.mxEvent.getId();
if (!eventId) {
return null;
}
const relations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.name);
if (relations) {
relationsList.push(relations);
}
// If there is an alternatve experimental event type, also look for that
if (eventType.altName) {
const altRelations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.altName);
if (altRelations) {
relationsList.push(altRelations);
}
}
return new RelatedRelations(relationsList);
} else {
return null;
}
}
/** /**
* @returns userId -> UserVote * @returns userId -> UserVote
*/ */
private collectUserVotes(): Map<string, UserVote> { private collectUserVotes(): Map<string, UserVote> {
return collectUserVotes( if (!this.state.voteRelations) {
allVotes(this.props.mxEvent, this.context, this.state.voteRelations, this.state.endRelations), return new Map<string, UserVote>();
this.context.getUserId(), }
this.state.selected, return collectUserVotes(allVotes(this.state.voteRelations), this.context.getUserId(), this.state.selected);
);
} }
/** /**
@ -379,10 +253,10 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
* have already seen. * have already seen.
*/ */
private unselectIfNewEventFromMe(): void { private unselectIfNewEventFromMe(): void {
const newEvents: MatrixEvent[] = this.state.voteRelations const relations = this.state.voteRelations?.getRelations() || [];
.getRelations() const newEvents: MatrixEvent[] = relations.filter(
.filter(isPollResponse) (mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!),
.filter((mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!)); );
let newSelected = this.state.selected; let newSelected = this.state.selected;
if (newEvents.length > 0) { if (newEvents.length > 0) {
@ -392,7 +266,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
} }
} }
} }
const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()); const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()!);
this.seenEventIds = this.seenEventIds.concat(newEventIds); this.seenEventIds = this.seenEventIds.concat(newEventIds);
this.setState({ selected: newSelected }); this.setState({ selected: newSelected });
} }
@ -405,30 +279,30 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
return sum; return sum;
} }
private isEnded(): boolean { public render(): ReactNode {
return isPollEnded(this.props.mxEvent, this.context, this.props.getRelationsForEvent); const { poll, pollInitialised } = this.state;
} if (!poll?.pollEvent) {
return null;
}
public render(): JSX.Element { const pollEvent = poll.pollEvent;
const poll = this.props.mxEvent.unstableExtensibleEvent as PollStartEvent;
if (!poll?.isEquivalentTo(M_POLL_START)) return null; // invalid
const ended = this.isEnded(); const pollId = this.props.mxEvent.getId()!;
const pollId = this.props.mxEvent.getId(); const isFetchingResponses = !pollInitialised || poll.isFetchingResponses;
const userVotes = this.collectUserVotes(); const userVotes = this.collectUserVotes();
const votes = countVotes(userVotes, poll); const votes = countVotes(userVotes, pollEvent);
const totalVotes = this.totalVotes(votes); const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values()); const winCount = Math.max(...votes.values());
const userId = this.context.getUserId(); const userId = this.context.getUserId();
const myVote = userVotes?.get(userId!)?.answers[0]; const myVote = userVotes?.get(userId!)?.answers[0];
const disclosed = M_POLL_KIND_DISCLOSED.matches(poll.kind.name); const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name);
// Disclosed: votes are hidden until I vote or the poll ends // Disclosed: votes are hidden until I vote or the poll ends
// Undisclosed: votes are hidden until poll ends // Undisclosed: votes are hidden until poll ends
const showResults = ended || (disclosed && myVote !== undefined); const showResults = poll.isEnded || (disclosed && myVote !== undefined);
let totalText: string; let totalText: string;
if (ended) { if (poll.isEnded) {
totalText = _t("Final result based on %(count)s votes", { count: totalVotes }); totalText = _t("Final result based on %(count)s votes", { count: totalVotes });
} else if (!disclosed) { } else if (!disclosed) {
totalText = _t("Results will be visible when the poll is ended"); totalText = _t("Results will be visible when the poll is ended");
@ -449,11 +323,11 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
return ( return (
<div className="mx_MPollBody"> <div className="mx_MPollBody">
<h2 data-testid="pollQuestion"> <h2 data-testid="pollQuestion">
{poll.question.text} {pollEvent.question.text}
{editedSpan} {editedSpan}
</h2> </h2>
<div className="mx_MPollBody_allOptions"> <div className="mx_MPollBody_allOptions">
{poll.answers.map((answer: PollAnswerSubevent) => { {pollEvent.answers.map((answer: PollAnswerSubevent) => {
let answerVotes = 0; let answerVotes = 0;
let votesText = ""; let votesText = "";
@ -462,11 +336,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
votesText = _t("%(count)s votes", { count: answerVotes }); votesText = _t("%(count)s votes", { count: answerVotes });
} }
const checked = (!ended && myVote === answer.id) || (ended && answerVotes === winCount); const checked =
(!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount);
const cls = classNames({ const cls = classNames({
mx_MPollBody_option: true, mx_MPollBody_option: true,
mx_MPollBody_option_checked: checked, mx_MPollBody_option_checked: checked,
mx_MPollBody_option_ended: ended, mx_MPollBody_option_ended: poll.isEnded,
}); });
const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes); const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes);
@ -477,7 +352,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
className={cls} className={cls}
onClick={() => this.selectOption(answer.id)} onClick={() => this.selectOption(answer.id)}
> >
{ended ? ( {poll.isEnded ? (
<EndedPollOption answer={answer} checked={checked} votesText={votesText} /> <EndedPollOption answer={answer} checked={checked} votesText={votesText} />
) : ( ) : (
<LivePollOption <LivePollOption
@ -500,6 +375,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
</div> </div>
<div data-testid="totalVotes" className="mx_MPollBody_totalVotes"> <div data-testid="totalVotes" className="mx_MPollBody_totalVotes">
{totalText} {totalText}
{isFetchingResponses && <Spinner w={16} h={16} />}
</div> </div>
</div> </div>
); );
@ -562,68 +438,17 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
throw new Error("Failed to parse Poll Response Event to determine user response"); throw new Error("Failed to parse Poll Response Event to determine user response");
} }
return new UserVote(event.getTs(), event.getSender(), response.answerIds); return new UserVote(event.getTs(), event.getSender()!, response.answerIds);
} }
export function allVotes( export function allVotes(voteRelations: Relations): Array<UserVote> {
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
voteRelations: RelatedRelations,
endRelations: RelatedRelations,
): Array<UserVote> {
const endTs = pollEndTs(pollEvent, matrixClient, endRelations);
function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean {
// From MSC3381:
// "Votes sent on or before the end event's timestamp are valid votes"
return endTs === null || responseEvent.getTs() <= endTs;
}
if (voteRelations) { if (voteRelations) {
return voteRelations return voteRelations.getRelations().map(userResponseFromPollResponseEvent);
.getRelations()
.filter(isPollResponse)
.filter(isOnOrBeforeEnd)
.map(userResponseFromPollResponseEvent);
} else { } else {
return []; return [];
} }
} }
/**
* Returns the earliest timestamp from the supplied list of end_poll events
* or null if there are no authorised events.
*/
export function pollEndTs(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
endRelations: RelatedRelations,
): number | null {
if (!endRelations) {
return null;
}
const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState;
function userCanRedact(endEvent: MatrixEvent): boolean {
return roomCurrentState.maySendRedactionForEvent(pollEvent, endEvent.getSender());
}
const tss: number[] = endRelations
.getRelations()
.filter(userCanRedact)
.map((evt: MatrixEvent) => evt.getTs());
if (tss.length === 0) {
return null;
} else {
return Math.min(...tss);
}
}
function isPollResponse(responseEvent: MatrixEvent): boolean {
return responseEvent.unstableExtensibleEvent?.isEquivalentTo(M_POLL_RESPONSE);
}
/** /**
* Figure out the correct vote for each user. * Figure out the correct vote for each user.
* @param userResponses current vote responses in the poll * @param userResponses current vote responses in the poll
@ -662,7 +487,7 @@ function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent)
if (!tempResponse.spoiled) { if (!tempResponse.spoiled) {
for (const answerId of tempResponse.answerIds) { for (const answerId of tempResponse.answerIds) {
if (collected.has(answerId)) { if (collected.has(answerId)) {
collected.set(answerId, collected.get(answerId) + 1); collected.set(answerId, collected.get(answerId)! + 1);
} else { } else {
collected.set(answerId, 1); collected.set(answerId, 1);
} }

View file

@ -133,6 +133,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
if (event.isEncrypted()) { if (event.isEncrypted()) {
await cli.decryptEventIfNeeded(event); // TODO await? await cli.decryptEventIfNeeded(event); // TODO await?
} }
await room.processPollEvents([event]);
if (event && PinningUtils.isPinnable(event)) { if (event && PinningUtils.isPinnable(event)) {
// Inject sender information // Inject sender information

View file

@ -19,8 +19,6 @@ import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import { M_POLL_START, M_POLL_RESPONSE, M_POLL_END } from "matrix-js-sdk/src/@types/polls";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
@ -69,47 +67,6 @@ export default class PinnedEventTile extends React.Component<IProps> {
} }
}; };
public async componentDidMount(): Promise<void> {
// Fetch poll responses
if (M_POLL_START.matches(this.props.event.getType())) {
const eventId = this.props.event.getId();
const roomId = this.props.event.getRoomId();
const room = this.context.getRoom(roomId);
try {
await Promise.all(
[M_POLL_RESPONSE.name, M_POLL_RESPONSE.altName, M_POLL_END.name, M_POLL_END.altName].map(
async (eventType): Promise<void> => {
const relations = new Relations(RelationType.Reference, eventType, room);
relations.setTargetEvent(this.props.event);
if (!this.relations.has(RelationType.Reference)) {
this.relations.set(RelationType.Reference, new Map<string, Relations>());
}
this.relations.get(RelationType.Reference).set(eventType, relations);
let nextBatch: string | undefined;
do {
const page = await this.context.relations(
roomId,
eventId,
RelationType.Reference,
eventType,
{ from: nextBatch },
);
nextBatch = page.nextBatch;
page.events.forEach((event) => relations.addEvent(event));
} while (nextBatch);
},
),
);
} catch (err) {
logger.error(`Error fetching responses to pinned poll ${eventId} in room ${roomId}`);
logger.error(err);
}
}
}
public render(): JSX.Element { public render(): JSX.Element {
const sender = this.props.event.getSender(); const sender = this.props.event.getSender();

File diff suppressed because it is too large Load diff

View file

@ -456,6 +456,8 @@ exports[`MPollBody renders a finished poll with no votes 1`] = `
</div> </div>
`; `;
exports[`MPollBody renders a loader while responses are still loading 1`] = `"Based on 4 votes<div class="mx_Spinner"><div class="mx_Spinner_icon" style="width: 16px; height: 16px;" aria-label="Loading..." role="progressbar" data-testid="spinner"></div></div>"`;
exports[`MPollBody renders a poll that I have not voted in 1`] = ` exports[`MPollBody renders a poll that I have not voted in 1`] = `
<div> <div>
<div <div
@ -769,7 +771,6 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] =
class="mx_StyledRadioButton mx_MPollBody_live-option mx_StyledRadioButton_enabled mx_StyledRadioButton_checked" class="mx_StyledRadioButton mx_MPollBody_live-option mx_StyledRadioButton_enabled mx_StyledRadioButton_checked"
> >
<input <input
checked=""
name="poll_answer_select-$mypoll" name="poll_answer_select-$mypoll"
type="radio" type="radio"
value="italian" value="italian"
@ -1224,7 +1225,6 @@ exports[`MPollBody renders a poll with only non-local votes 1`] = `
class="mx_StyledRadioButton mx_MPollBody_live-option mx_StyledRadioButton_enabled mx_StyledRadioButton_checked" class="mx_StyledRadioButton mx_MPollBody_live-option mx_StyledRadioButton_enabled mx_StyledRadioButton_checked"
> >
<input <input
checked=""
name="poll_answer_select-$mypoll" name="poll_answer_select-$mypoll"
type="radio" type="radio"
value="wings" value="wings"

View file

@ -23,12 +23,12 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType, RelationType, MsgType } from "matrix-js-sdk/src/@types/event"; import { EventType, RelationType, MsgType } from "matrix-js-sdk/src/@types/event";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { IEvent, Room, EventTimelineSet, IMinimalEvent } from "matrix-js-sdk/src/matrix"; import { IEvent, Room, EventTimelineSet, IMinimalEvent } from "matrix-js-sdk/src/matrix";
import { M_POLL_RESPONSE, M_POLL_END, M_POLL_KIND_DISCLOSED } from "matrix-js-sdk/src/@types/polls"; import { M_POLL_KIND_DISCLOSED } from "matrix-js-sdk/src/@types/polls";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent"; import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
import { stubClient, mkStubRoom, mkEvent, mkMessage } from "../../../test-utils"; import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import PinnedMessagesCard from "../../../../src/components/views/right_panel/PinnedMessagesCard"; import PinnedMessagesCard from "../../../../src/components/views/right_panel/PinnedMessagesCard";
import PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile"; import PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile";
@ -40,16 +40,16 @@ describe("<PinnedMessagesCard />", () => {
stubClient(); stubClient();
const cli = mocked(MatrixClientPeg.get()); const cli = mocked(MatrixClientPeg.get());
cli.getUserId.mockReturnValue("@alice:example.org"); cli.getUserId.mockReturnValue("@alice:example.org");
cli.setRoomAccountData.mockReturnValue(undefined); cli.setRoomAccountData.mockResolvedValue({});
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] }); cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => { const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => {
const room = mkStubRoom("!room:example.org", "room", cli); const room = new Room("!room:example.org", cli, "@me:example.org");
// Deferred since we may be adding or removing pins later // Deferred since we may be adding or removing pins later
const pins = () => [...localPins, ...nonLocalPins]; const pins = () => [...localPins, ...nonLocalPins];
// Insert pin IDs into room state // Insert pin IDs into room state
mocked(room.currentState).getStateEvents.mockImplementation((): any => jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any =>
mkEvent({ mkEvent({
event: true, event: true,
type: EventType.RoomPinnedEvents, type: EventType.RoomPinnedEvents,
@ -61,6 +61,8 @@ describe("<PinnedMessagesCard />", () => {
}), }),
); );
jest.spyOn(room.currentState, "on");
// Insert local pins into local timeline set // Insert local pins into local timeline set
room.getUnfilteredTimelineSet = () => room.getUnfilteredTimelineSet = () =>
({ ({
@ -75,6 +77,8 @@ describe("<PinnedMessagesCard />", () => {
return Promise.resolve(event as IMinimalEvent); return Promise.resolve(event as IMinimalEvent);
}); });
cli.getRoom.mockReturnValue(room);
return room; return room;
}; };
@ -131,8 +135,8 @@ describe("<PinnedMessagesCard />", () => {
it("updates when messages are pinned", async () => { it("updates when messages are pinned", async () => {
// Start with nothing pinned // Start with nothing pinned
const localPins = []; const localPins: MatrixEvent[] = [];
const nonLocalPins = []; const nonLocalPins: MatrixEvent[] = [];
const pins = await mountPins(mkRoom(localPins, nonLocalPins)); const pins = await mountPins(mkRoom(localPins, nonLocalPins));
expect(pins.find(PinnedEventTile).length).toBe(0); expect(pins.find(PinnedEventTile).length).toBe(0);
@ -240,31 +244,27 @@ describe("<PinnedMessagesCard />", () => {
["@eve:example.org", 1], ["@eve:example.org", 1],
].map(([user, option], i) => ].map(([user, option], i) =>
mkEvent({ mkEvent({
...PollResponseEvent.from([answers[option].id], poll.getId()).serialize(), ...PollResponseEvent.from([answers[option as number].id], poll.getId()!).serialize(),
event: true, event: true,
room: "!room:example.org", room: "!room:example.org",
user: user as string, user: user as string,
}), }),
); );
const end = mkEvent({ const end = mkEvent({
...PollEndEvent.from(poll.getId(), "Closing the poll").serialize(), ...PollEndEvent.from(poll.getId()!, "Closing the poll").serialize(),
event: true, event: true,
room: "!room:example.org", room: "!room:example.org",
user: "@alice:example.org", user: "@alice:example.org",
}); });
// Make the responses available // Make the responses available
cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, { from }) => { cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, opts) => {
if (eventId === poll.getId() && relationType === RelationType.Reference) { if (eventId === poll.getId() && relationType === RelationType.Reference) {
switch (eventType) { // Paginate the results, for added challenge
case M_POLL_RESPONSE.name: return opts?.from === "page2"
// Paginate the results, for added challenge ? { originalEvent: poll, events: responses.slice(2) }
return from === "page2" : { originalEvent: poll, events: [...responses.slice(0, 2), end], nextBatch: "page2" };
? { originalEvent: poll, events: responses.slice(2) }
: { originalEvent: poll, events: responses.slice(0, 2), nextBatch: "page2" };
case M_POLL_END.name:
return { originalEvent: null, events: [end] };
}
} }
// type does not allow originalEvent to be falsy // type does not allow originalEvent to be falsy
// but code seems to // but code seems to
@ -272,8 +272,20 @@ describe("<PinnedMessagesCard />", () => {
return { originalEvent: undefined as unknown as MatrixEvent, events: [] }; return { originalEvent: undefined as unknown as MatrixEvent, events: [] };
}); });
const pins = await mountPins(mkRoom([], [poll])); const room = mkRoom([], [poll]);
// poll end event validates against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockReturnValue(true);
const pins = await mountPins(room);
// two pages of results
await flushPromises();
await flushPromises();
const pollInstance = room.polls.get(poll.getId()!);
expect(pollInstance).toBeTruthy();
const pinTile = pins.find(MPollBody); const pinTile = pins.find(MPollBody);
expect(pinTile.length).toEqual(1); expect(pinTile.length).toEqual(1);
expect(pinTile.find(".mx_MPollBody_option_ended").length).toEqual(2); expect(pinTile.find(".mx_MPollBody_option_ended").length).toEqual(2);
expect(pinTile.find(".mx_MPollBody_optionVoteCount").first().text()).toEqual("2 votes"); expect(pinTile.find(".mx_MPollBody_optionVoteCount").first().text()).toEqual("2 votes");