Display and send votes in polls (#7158)
Co-authored-by: Travis Ralston <travpc@gmail.com>
This commit is contained in:
parent
a156ba8be9
commit
d705fdd6e4
9 changed files with 1740 additions and 15 deletions
|
@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { Relations } from "matrix-js-sdk/src/models/relations";
|
||||
|
||||
export interface IBodyProps {
|
||||
mxEvent: MatrixEvent;
|
||||
|
@ -41,4 +42,7 @@ export interface IBodyProps {
|
|||
onMessageAllowed: () => void; // TODO: Docs
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
mediaEventHelper: MediaEventHelper;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
|
||||
}
|
||||
|
|
|
@ -18,14 +18,24 @@ import React from 'react';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { IPollAnswer, IPollContent, POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import {
|
||||
IPollAnswer,
|
||||
IPollContent,
|
||||
IPollResponse,
|
||||
POLL_RESPONSE_EVENT_TYPE,
|
||||
POLL_START_EVENT_TYPE,
|
||||
} from '../../../polls/consts';
|
||||
import StyledRadioButton from '../elements/StyledRadioButton';
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
|
||||
// TODO: [andyb] Use extensible events library when ready
|
||||
const TEXT_NODE_TYPE = "org.matrix.msc1767.text";
|
||||
|
||||
interface IState {
|
||||
selected?: string;
|
||||
pollRelations: Relations;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MPollBody")
|
||||
|
@ -33,12 +43,88 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selected: null,
|
||||
};
|
||||
const pollRelations = this.fetchPollRelations();
|
||||
let selected = null;
|
||||
|
||||
const userVotes = collectUserVotes(allVotes(pollRelations), null);
|
||||
const userId = MatrixClientPeg.get().getUserId();
|
||||
const currentVote = userVotes.get(userId);
|
||||
if (currentVote) {
|
||||
selected = currentVote.answers[0];
|
||||
}
|
||||
|
||||
this.state = { selected, pollRelations };
|
||||
|
||||
this.addListeners(this.state.pollRelations);
|
||||
this.props.mxEvent.on("Event.relationsCreated", this.onPollRelationsCreated);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.mxEvent.off("Event.relationsCreated", this.onPollRelationsCreated);
|
||||
this.removeListeners(this.state.pollRelations);
|
||||
}
|
||||
|
||||
private addListeners(pollRelations?: Relations) {
|
||||
if (pollRelations) {
|
||||
pollRelations.on("Relations.add", this.onRelationsChange);
|
||||
pollRelations.on("Relations.remove", this.onRelationsChange);
|
||||
pollRelations.on("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
}
|
||||
|
||||
private removeListeners(pollRelations?: Relations) {
|
||||
if (pollRelations) {
|
||||
pollRelations.off("Relations.add", this.onRelationsChange);
|
||||
pollRelations.off("Relations.remove", this.onRelationsChange);
|
||||
pollRelations.off("Relations.redaction", this.onRelationsChange);
|
||||
}
|
||||
}
|
||||
|
||||
private onPollRelationsCreated = (relationType: string, eventType: string) => {
|
||||
if (
|
||||
relationType === "m.reference" &&
|
||||
POLL_RESPONSE_EVENT_TYPE.matches(eventType)
|
||||
) {
|
||||
this.props.mxEvent.removeListener(
|
||||
"Event.relationsCreated", this.onPollRelationsCreated);
|
||||
|
||||
const newPollRelations = this.fetchPollRelations();
|
||||
this.addListeners(newPollRelations);
|
||||
this.removeListeners(this.state.pollRelations);
|
||||
|
||||
this.setState({
|
||||
pollRelations: newPollRelations,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onRelationsChange = () => {
|
||||
// We hold pollRelations in our state, and it has changed under us
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
private selectOption(answerId: string) {
|
||||
if (answerId === this.state.selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseContent: IPollResponse = {
|
||||
[POLL_RESPONSE_EVENT_TYPE.name]: {
|
||||
"answers": [answerId],
|
||||
},
|
||||
"m.relates_to": {
|
||||
"event_id": this.props.mxEvent.getId(),
|
||||
"rel_type": "m.reference",
|
||||
},
|
||||
};
|
||||
MatrixClientPeg.get().sendEvent(
|
||||
this.props.mxEvent.getRoomId(),
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
responseContent,
|
||||
).catch(e => {
|
||||
console.error("Failed to submit poll response event:", e);
|
||||
});
|
||||
|
||||
this.setState({ selected: answerId });
|
||||
}
|
||||
|
||||
|
@ -46,20 +132,60 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
this.selectOption(e.currentTarget.value);
|
||||
};
|
||||
|
||||
private fetchPollRelations(): Relations | null {
|
||||
if (this.props.getRelationsForEvent) {
|
||||
return this.props.getRelationsForEvent(
|
||||
this.props.mxEvent.getId(),
|
||||
"m.reference",
|
||||
POLL_RESPONSE_EVENT_TYPE.name,
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns answer-id -> number-of-votes
|
||||
*/
|
||||
private collectVotes(): Map<string, number> {
|
||||
return countVotes(
|
||||
collectUserVotes(allVotes(this.state.pollRelations), this.state.selected),
|
||||
this.props.mxEvent.getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
private totalVotes(collectedVotes: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const v of collectedVotes.values()) {
|
||||
sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
render() {
|
||||
const pollStart: IPollContent =
|
||||
this.props.mxEvent.getContent()[POLL_START_EVENT_TYPE.name];
|
||||
const pollStart: IPollContent = this.props.mxEvent.getContent();
|
||||
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
|
||||
|
||||
if (pollInfo.answers.length < 1 || pollInfo.answers.length > 20) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pollId = this.props.mxEvent.getId();
|
||||
const votes = this.collectVotes();
|
||||
const totalVotes = this.totalVotes(votes);
|
||||
|
||||
return <div className="mx_MPollBody">
|
||||
<h2>{ pollStart.question[TEXT_NODE_TYPE] }</h2>
|
||||
<h2>{ pollInfo.question[TEXT_NODE_TYPE] }</h2>
|
||||
<div className="mx_MPollBody_allOptions">
|
||||
{
|
||||
pollStart.answers.map((answer: IPollAnswer) => {
|
||||
pollInfo.answers.map((answer: IPollAnswer) => {
|
||||
const checked = this.state.selected === answer.id;
|
||||
const classNames = `mx_MPollBody_option${
|
||||
checked ? " mx_MPollBody_option_checked": ""
|
||||
}`;
|
||||
const answerVotes = votes.get(answer.id) ?? 0;
|
||||
const answerPercent = Math.round(
|
||||
100.0 * answerVotes / totalVotes);
|
||||
return <div
|
||||
key={answer.id}
|
||||
className={classNames}
|
||||
|
@ -72,22 +198,116 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
|
|||
onChange={this.onOptionSelected}
|
||||
>
|
||||
<div className="mx_MPollBody_optionVoteCount">
|
||||
{ _t("%(number)s votes", { number: 0 }) }
|
||||
{ _t("%(count)s votes", { count: answerVotes }) }
|
||||
</div>
|
||||
<div className="mx_MPollBody_optionText">
|
||||
{ answer[TEXT_NODE_TYPE] }
|
||||
</div>
|
||||
</StyledRadioButton>
|
||||
<div className="mx_MPollBody_popularityBackground">
|
||||
<div className="mx_MPollBody_popularityAmount" />
|
||||
<div
|
||||
className="mx_MPollBody_popularityAmount"
|
||||
style={{ "width": `${answerPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div className="mx_MPollBody_totalVotes">
|
||||
{ _t( "Based on %(total)s votes", { total: 0 } ) }
|
||||
{ _t( "Based on %(count)s votes", { count: totalVotes } ) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserVote {
|
||||
constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
|
||||
const pr = event.getContent() as IPollResponse;
|
||||
const answers = pr[POLL_RESPONSE_EVENT_TYPE.name].answers;
|
||||
|
||||
return new UserVote(
|
||||
event.getTs(),
|
||||
event.getSender(),
|
||||
answers,
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
.map(userResponseFromPollResponseEvent);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out the correct vote for each user.
|
||||
* @returns a Map of user ID to their vote info
|
||||
*/
|
||||
function collectUserVotes(
|
||||
userResponses: Array<UserVote>,
|
||||
selected?: string,
|
||||
): Map<string, UserVote> {
|
||||
const userVotes: Map<string, UserVote> = new Map();
|
||||
|
||||
for (const response of userResponses) {
|
||||
const otherResponse = userVotes.get(response.sender);
|
||||
if (!otherResponse || otherResponse.ts < response.ts) {
|
||||
userVotes.set(response.sender, response);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const userId = client.getUserId();
|
||||
userVotes.set(userId, new UserVote(0, userId, [selected]));
|
||||
}
|
||||
|
||||
return userVotes;
|
||||
}
|
||||
|
||||
function countVotes(
|
||||
userVotes: Map<string, UserVote>,
|
||||
pollStart: IPollContent,
|
||||
): Map<string, number> {
|
||||
const collected = new Map<string, number>();
|
||||
|
||||
const pollInfo = pollStart[POLL_START_EVENT_TYPE.name];
|
||||
const maxSelections = 1; // See MSC3381 - later this will be in pollInfo
|
||||
|
||||
const allowedAnswerIds = pollInfo.answers.map((ans: IPollAnswer) => ans.id);
|
||||
function isValidAnswer(answerId: string) {
|
||||
return allowedAnswerIds.includes(answerId);
|
||||
}
|
||||
|
||||
for (const response of userVotes.values()) {
|
||||
if (response.answers.every(isValidAnswer)) {
|
||||
for (const [index, answerId] of response.answers.entries()) {
|
||||
if (index >= maxSelections) {
|
||||
break;
|
||||
}
|
||||
if (collected.has(answerId)) {
|
||||
collected.set(answerId, collected.get(answerId) + 1);
|
||||
} else {
|
||||
collected.set(answerId, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
|
|
@ -28,12 +28,16 @@ import { ReactAnyComponent } from "../../../@types/common";
|
|||
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
|
||||
import { Relations } from 'matrix-js-sdk/src/models/relations';
|
||||
|
||||
// onMessageAllowed is handled internally
|
||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes?: Record<string, React.Component>;
|
||||
overrideEventTypes?: Record<string, React.Component>;
|
||||
|
||||
// helper function to access relations for this event
|
||||
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageEvent")
|
||||
|
@ -154,6 +158,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
|||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
mediaEventHelper={this.mediaHelper}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
/> : null;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue