Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions res/css/views/messages/_MPollBody.scss
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ limitations under the License.

.mx_MPollBody_option_checked {
border-color: $accent;

.mx_MPollBody_popularityBackground {
.mx_MPollBody_popularityAmount {
background-color: $accent;
}
}
}

.mx_StyledRadioButton_checked input[type="radio"] + div {
Expand Down
6 changes: 5 additions & 1 deletion src/components/views/messages/IBodyProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
242 changes: 231 additions & 11 deletions src/components/views/messages/MPollBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,174 @@ 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")
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 });
}

private onOptionSelected = (e: React.FormEvent<HTMLInputElement>): void => {
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}
Expand All @@ -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;
}
5 changes: 5 additions & 0 deletions src/components/views/messages/MessageEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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