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 {
display: flex;
flex-direction: inline;
justify-content: start;
color: $secondary-content;
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 (
M_POLL_START.matches(mxEvent.getType()) &&
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> {
private onFinished = (endPoll: boolean): 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 });
private onFinished = async (endPoll: boolean): Promise<void> => {
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
.sendEvent(this.props.event.getRoomId(), endEvent.type, endEvent.content)
.catch((e: any) => {
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."),
});
if (!poll) {
throw new Error("No poll instance found in room.");
}
try {
const responses = await poll.getResponses();
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);
};

View file

@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
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 { NamespacedValue } from "matrix-events-sdk";
import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
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 Modal from "../../../Modal";
@ -36,11 +36,14 @@ import ErrorDialog from "../dialogs/ErrorDialog";
import { GetRelationsForEvent } from "../rooms/EventTile";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Spinner from "../elements/Spinner";
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
voteRelations: RelatedRelations; // Voting (response) events
endRelations: RelatedRelations; // Poll end events
voteRelations?: Relations; // Voting (response) events
}
export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations {
@ -59,15 +62,7 @@ export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent,
return new RelatedRelations(relationsList);
}
export function findTopAnswer(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: GetRelationsForEvent,
): string {
if (!getRelationsForEvent) {
return "";
}
export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): string {
const pollEventId = pollEvent.getId();
if (!pollEventId) {
logger.warn(
@ -87,25 +82,7 @@ export function findTopAnswer(
return poll.answers.find((a) => a.id === answerId)?.text ?? "";
};
const voteRelations = createVoteRelations(getRelationsForEvent, pollEventId);
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 userVotes: Map<string, UserVote> = collectUserVotes(allVotes(voteRelations));
const votes: Map<string, number> = countVotes(userVotes, poll);
const highestScore: number = Math.max(...votes.values());
@ -122,62 +99,13 @@ export function findTopAnswer(
return formatCommaSeparatedList(bestAnswerTexts, 3);
}
export function isPollEnded(
pollEvent: MatrixEvent,
matrixClient: MatrixClient,
getRelationsForEvent?: GetRelationsForEvent,
): boolean {
if (!getRelationsForEvent) {
export function isPollEnded(pollEvent: MatrixEvent, matrixClient: MatrixClient): boolean {
const room = matrixClient.getRoom(pollEvent.getRoomId());
const poll = room?.polls.get(pollEvent.getId()!);
if (!poll || poll.isFetchingResponses) {
return false;
}
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;
return poll.isEnded;
}
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 context!: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen
private voteRelationsReceived = false;
private endRelationsReceived = false;
public constructor(props: IBodyProps) {
super(props);
this.state = {
selected: null,
voteRelations: this.fetchVoteRelations(),
endRelations: this.fetchEndRelations(),
pollInitialised: false,
};
}
this.addListeners(this.state.voteRelations, this.state.endRelations);
this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
public componentDidMount(): void {
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 {
this.props.mxEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
this.removeListeners(this.state.voteRelations, this.state.endRelations);
this.removeListeners();
}
private addListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void {
if (voteRelations) {
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") {
private async setPollInstance(poll: Poll): Promise<void> {
if (poll.pollId !== this.props.mxEvent.getId()) {
return;
}
this.setState({ poll }, () => {
this.addListeners();
});
const responses = await poll.getResponses();
const voteRelations = responses;
if (M_POLL_RESPONSE.matches(eventType)) {
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 });
}
this.setState({ pollInitialised: true, voteRelations });
}
if (this.voteRelationsReceived && this.endRelationsReceived) {
this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onRelationsCreated);
private addListeners(): void {
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 => {
@ -295,19 +206,19 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
};
private selectOption(answerId: string): void {
if (this.isEnded()) {
if (this.state.poll?.isEnded) {
return;
}
const userVotes = this.collectUserVotes();
const userId = this.context.getUserId();
const userId = this.context.getSafeUserId();
const myVote = userVotes.get(userId)?.answers[0];
if (answerId === myVote) {
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);
Modal.createDialog(ErrorDialog, {
@ -323,51 +234,14 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
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
*/
private collectUserVotes(): Map<string, UserVote> {
return collectUserVotes(
allVotes(this.props.mxEvent, this.context, this.state.voteRelations, this.state.endRelations),
this.context.getUserId(),
this.state.selected,
);
if (!this.state.voteRelations) {
return new Map<string, UserVote>();
}
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.
*/
private unselectIfNewEventFromMe(): void {
const newEvents: MatrixEvent[] = this.state.voteRelations
.getRelations()
.filter(isPollResponse)
.filter((mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!));
const relations = this.state.voteRelations?.getRelations() || [];
const newEvents: MatrixEvent[] = relations.filter(
(mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!),
);
let newSelected = this.state.selected;
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.setState({ selected: newSelected });
}
@ -405,30 +279,30 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
return sum;
}
private isEnded(): boolean {
return isPollEnded(this.props.mxEvent, this.context, this.props.getRelationsForEvent);
}
public render(): ReactNode {
const { poll, pollInitialised } = this.state;
if (!poll?.pollEvent) {
return null;
}
public render(): JSX.Element {
const poll = this.props.mxEvent.unstableExtensibleEvent as PollStartEvent;
if (!poll?.isEquivalentTo(M_POLL_START)) return null; // invalid
const pollEvent = poll.pollEvent;
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 votes = countVotes(userVotes, poll);
const votes = countVotes(userVotes, pollEvent);
const totalVotes = this.totalVotes(votes);
const winCount = Math.max(...votes.values());
const userId = this.context.getUserId();
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
// Undisclosed: votes are hidden until poll ends
const showResults = ended || (disclosed && myVote !== undefined);
const showResults = poll.isEnded || (disclosed && myVote !== undefined);
let totalText: string;
if (ended) {
if (poll.isEnded) {
totalText = _t("Final result based on %(count)s votes", { count: totalVotes });
} else if (!disclosed) {
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 (
<div className="mx_MPollBody">
<h2 data-testid="pollQuestion">
{poll.question.text}
{pollEvent.question.text}
{editedSpan}
</h2>
<div className="mx_MPollBody_allOptions">
{poll.answers.map((answer: PollAnswerSubevent) => {
{pollEvent.answers.map((answer: PollAnswerSubevent) => {
let answerVotes = 0;
let votesText = "";
@ -462,11 +336,12 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
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({
mx_MPollBody_option: true,
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);
@ -477,7 +352,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
className={cls}
onClick={() => this.selectOption(answer.id)}
>
{ended ? (
{poll.isEnded ? (
<EndedPollOption answer={answer} checked={checked} votesText={votesText} />
) : (
<LivePollOption
@ -500,6 +375,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
</div>
<div data-testid="totalVotes" className="mx_MPollBody_totalVotes">
{totalText}
{isFetchingResponses && <Spinner w={16} h={16} />}
</div>
</div>
);
@ -562,68 +438,17 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
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(
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;
}
export function allVotes(voteRelations: Relations): Array<UserVote> {
if (voteRelations) {
return voteRelations
.getRelations()
.filter(isPollResponse)
.filter(isOnOrBeforeEnd)
.map(userResponseFromPollResponseEvent);
return voteRelations.getRelations().map(userResponseFromPollResponseEvent);
} else {
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.
* @param userResponses current vote responses in the poll
@ -662,7 +487,7 @@ function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent)
if (!tempResponse.spoiled) {
for (const answerId of tempResponse.answerIds) {
if (collected.has(answerId)) {
collected.set(answerId, collected.get(answerId) + 1);
collected.set(answerId, collected.get(answerId)! + 1);
} else {
collected.set(answerId, 1);
}

View file

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

View file

@ -19,8 +19,6 @@ import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
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 { 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 {
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>
`;
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`] = `
<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"
>
<input
checked=""
name="poll_answer_select-$mypoll"
type="radio"
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"
>
<input
checked=""
name="poll_answer_select-$mypoll"
type="radio"
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 { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
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 { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
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 PinnedMessagesCard from "../../../../src/components/views/right_panel/PinnedMessagesCard";
import PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile";
@ -40,16 +40,16 @@ describe("<PinnedMessagesCard />", () => {
stubClient();
const cli = mocked(MatrixClientPeg.get());
cli.getUserId.mockReturnValue("@alice:example.org");
cli.setRoomAccountData.mockReturnValue(undefined);
cli.setRoomAccountData.mockResolvedValue({});
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
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
const pins = () => [...localPins, ...nonLocalPins];
// Insert pin IDs into room state
mocked(room.currentState).getStateEvents.mockImplementation((): any =>
jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any =>
mkEvent({
event: true,
type: EventType.RoomPinnedEvents,
@ -61,6 +61,8 @@ describe("<PinnedMessagesCard />", () => {
}),
);
jest.spyOn(room.currentState, "on");
// Insert local pins into local timeline set
room.getUnfilteredTimelineSet = () =>
({
@ -75,6 +77,8 @@ describe("<PinnedMessagesCard />", () => {
return Promise.resolve(event as IMinimalEvent);
});
cli.getRoom.mockReturnValue(room);
return room;
};
@ -131,8 +135,8 @@ describe("<PinnedMessagesCard />", () => {
it("updates when messages are pinned", async () => {
// Start with nothing pinned
const localPins = [];
const nonLocalPins = [];
const localPins: MatrixEvent[] = [];
const nonLocalPins: MatrixEvent[] = [];
const pins = await mountPins(mkRoom(localPins, nonLocalPins));
expect(pins.find(PinnedEventTile).length).toBe(0);
@ -240,31 +244,27 @@ describe("<PinnedMessagesCard />", () => {
["@eve:example.org", 1],
].map(([user, option], i) =>
mkEvent({
...PollResponseEvent.from([answers[option].id], poll.getId()).serialize(),
...PollResponseEvent.from([answers[option as number].id], poll.getId()!).serialize(),
event: true,
room: "!room:example.org",
user: user as string,
}),
);
const end = mkEvent({
...PollEndEvent.from(poll.getId(), "Closing the poll").serialize(),
...PollEndEvent.from(poll.getId()!, "Closing the poll").serialize(),
event: true,
room: "!room:example.org",
user: "@alice:example.org",
});
// 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) {
switch (eventType) {
case M_POLL_RESPONSE.name:
// Paginate the results, for added challenge
return from === "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] };
}
// Paginate the results, for added challenge
return opts?.from === "page2"
? { originalEvent: poll, events: responses.slice(2) }
: { originalEvent: poll, events: [...responses.slice(0, 2), end], nextBatch: "page2" };
}
// type does not allow originalEvent to be falsy
// but code seems to
@ -272,8 +272,20 @@ describe("<PinnedMessagesCard />", () => {
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);
expect(pinTile.length).toEqual(1);
expect(pinTile.find(".mx_MPollBody_option_ended").length).toEqual(2);
expect(pinTile.find(".mx_MPollBody_optionVoteCount").first().text()).toEqual("2 votes");