Show Profile Pictures according to Votes on Poll Options

This commit is contained in:
Tim Vahlbrock 2024-10-25 15:33:51 +02:00
parent 7de5c84b3d
commit 4c90e4775d
No known key found for this signature in database
3 changed files with 68 additions and 55 deletions

View file

@ -6,34 +6,34 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { ReactNode } from "react"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { PollAnswerSubevent, PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { import {
MatrixEvent,
MatrixClient,
Relations,
Poll,
PollEvent,
M_POLL_KIND_DISCLOSED, M_POLL_KIND_DISCLOSED,
M_POLL_RESPONSE, M_POLL_RESPONSE,
M_POLL_START, M_POLL_START,
MatrixClient,
MatrixEvent,
Poll,
PollEvent,
Relations,
TimelineEvents, TimelineEvents,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import React, { ReactNode } from "react";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import { IBodyProps } from "./IBodyProps";
import { formatList } from "../../../utils/FormattingUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import ErrorDialog from "../dialogs/ErrorDialog"; import { _t } from "../../../languageHandler";
import { GetRelationsForEvent } from "../rooms/EventTile";
import PollCreateDialog from "../elements/PollCreateDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import { formatList } from "../../../utils/FormattingUtils";
import ErrorDialog from "../dialogs/ErrorDialog";
import PollCreateDialog from "../elements/PollCreateDialog";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { PollOption } from "../polls/PollOption"; import { PollOption } from "../polls/PollOption";
import { GetRelationsForEvent } from "../rooms/EventTile";
import { IBodyProps } from "./IBodyProps";
interface IState { interface IState {
poll?: Poll; poll?: Poll;
@ -81,12 +81,12 @@ export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations):
const userVotes: Map<string, UserVote> = collectUserVotes(allVotes(voteRelations)); const userVotes: Map<string, UserVote> = collectUserVotes(allVotes(voteRelations));
const votes: Map<string, number> = countVotes(userVotes, poll); const votes: Map<string, UserVote[]> = countVotes(userVotes, poll);
const highestScore: number = Math.max(...votes.values()); const highestScore: number = Math.max(...Array.from(votes.values()).map((votes) => votes.length));
const bestAnswerIds: string[] = []; const bestAnswerIds: string[] = [];
for (const [answerId, score] of votes) { for (const [answerId, answerVotes] of votes) {
if (score == highestScore) { if (answerVotes.length == highestScore) {
bestAnswerIds.push(answerId); bestAnswerIds.push(answerId);
} }
} }
@ -243,7 +243,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
if (!this.state.voteRelations || !this.context) { if (!this.state.voteRelations || !this.context) {
return new Map<string, UserVote>(); return new Map<string, UserVote>();
} }
return collectUserVotes(allVotes(this.state.voteRelations), this.context.getUserId(), this.state.selected); return collectUserVotes(allVotes(this.state.voteRelations), null, this.state.selected);
} }
/** /**
@ -273,10 +273,10 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
this.setState({ selected: newSelected }); this.setState({ selected: newSelected });
} }
private totalVotes(collectedVotes: Map<string, number>): number { private totalVotes(collectedVotes: Map<string, UserVote[]>): number {
let sum = 0; let sum = 0;
for (const v of collectedVotes.values()) { for (const v of collectedVotes.values()) {
sum += v; sum += v.length;
} }
return sum; return sum;
} }
@ -294,7 +294,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
const userVotes = this.collectUserVotes(); const userVotes = this.collectUserVotes();
const votes = countVotes(userVotes, pollEvent); 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(...Array.from(votes.values()).map((votes) => votes.length));
const userId = this.context.getSafeUserId(); const userId = this.context.getSafeUserId();
const myVote = userVotes?.get(userId)?.answers[0]; const myVote = userVotes?.get(userId)?.answers[0];
const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name); const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name);
@ -335,7 +335,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
let answerVotes = 0; let answerVotes = 0;
if (showResults) { if (showResults) {
answerVotes = votes.get(answer.id) ?? 0; answerVotes = votes.get(answer.id)?.length ?? 0;
} }
const checked = const checked =
@ -348,7 +348,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
answer={answer} answer={answer}
isChecked={checked} isChecked={checked}
isEnded={poll.isEnded} isEnded={poll.isEnded}
voteCount={answerVotes} votes={votes.get(answer.id) ?? []}
totalVoteCount={totalVotes} totalVoteCount={totalVotes}
displayVoteCount={showResults} displayVoteCount={showResults}
onOptionSelected={this.selectOption.bind(this)} onOptionSelected={this.selectOption.bind(this)}
@ -392,7 +392,7 @@ export function allVotes(voteRelations: Relations): Array<UserVote> {
/** /**
* 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
* @param {string?} userId The userId for which the `selected` option will apply to. * @param {string?} user The userId for which the `selected` option will apply to.
* Should be set to the current user ID. * Should be set to the current user ID.
* @param {string?} selected Local echo selected option for the userId * @param {string?} selected Local echo selected option for the userId
* @returns a Map of user ID to their vote info * @returns a Map of user ID to their vote info
@ -418,19 +418,17 @@ export function collectUserVotes(
return userVotes; return userVotes;
} }
export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, number> { export function countVotes(userVotes: Map<string, UserVote>, pollStart: PollStartEvent): Map<string, UserVote[]> {
const collected = new Map<string, number>(); const collected = new Map<string, UserVote[]>();
for (const response of userVotes.values()) { for (const response of userVotes.values()) {
const tempResponse = PollResponseEvent.from(response.answers, "$irrelevant"); const tempResponse = PollResponseEvent.from(response.answers, "$irrelevant");
tempResponse.validateAgainst(pollStart); tempResponse.validateAgainst(pollStart);
if (!tempResponse.spoiled) { if (!tempResponse.spoiled) {
for (const answerId of tempResponse.answerIds) { for (const answerId of tempResponse.answerIds) {
if (collected.has(answerId)) { const previousVotes = collected.get(answerId) ?? [];
collected.set(answerId, collected.get(answerId)! + 1); previousVotes.push(response);
} else { collected.set(answerId, previousVotes);
collected.set(answerId, 1);
}
} }
} }
} }

View file

@ -6,28 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import React, { ReactNode, useContext } from "react";
import { _t } from "../../../languageHandler";
import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg";
import RoomContext from "../../../contexts/RoomContext";
import { useRoomMembers } from "../../../hooks/useRoomMembers";
import { _t } from "../../../languageHandler";
import FacePile from "../elements/FacePile";
import StyledRadioButton from "../elements/StyledRadioButton"; import StyledRadioButton from "../elements/StyledRadioButton";
import { UserVote } from "../messages/MPollBody";
type PollOptionContentProps = { type PollOptionContentProps = {
answer: PollAnswerSubevent; answer: PollAnswerSubevent;
voteCount: number; votes: UserVote[];
displayVoteCount?: boolean; displayVoteCount?: boolean;
isWinner?: boolean; isWinner?: boolean;
}; };
const PollOptionContent: React.FC<PollOptionContentProps> = ({ isWinner, answer, voteCount, displayVoteCount }) => { const PollOptionContent: React.FC<PollOptionContentProps> = ({ isWinner, answer, votes, displayVoteCount }) => {
const votesText = displayVoteCount ? _t("timeline|m.poll|count_of_votes", { count: voteCount }) : ""; const votesText = displayVoteCount ? _t("timeline|m.poll|count_of_votes", { count: votes.length }) : "";
const room = useContext(RoomContext).room!;
const members = useRoomMembers(room);
return ( return (
<div className="mx_PollOption_content"> <div className="mx_PollOption_content">
<div className="mx_PollOption_optionText">{answer.text}</div> <div className="mx_PollOption_optionText">{answer.text}</div>
<div className="mx_PollOption_optionVoteCount"> <div className="mx_PollOption_optionVoteCount">
{isWinner && <TrophyIcon className="mx_PollOption_winnerIcon" />} {isWinner && <TrophyIcon className="mx_PollOption_winnerIcon" />}
{votesText} <div style={{ display: "flex" }}>
<FacePile
members={members.filter((m) => votes.some((v) => v.sender === m.userId))}
size="24px"
overflow={false}
style={{ marginRight: "10px" }}
/>
{votesText}
</div>
</div> </div>
</div> </div>
); );
@ -42,7 +57,7 @@ interface PollOptionProps extends PollOptionContentProps {
children?: ReactNode; children?: ReactNode;
} }
const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({ const EndedPollOption: React.FC<Omit<PollOptionProps, "votes" | "totalVoteCount">> = ({
isChecked, isChecked,
children, children,
answer, answer,
@ -57,7 +72,7 @@ const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCo
</div> </div>
); );
const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({ const ActivePollOption: React.FC<Omit<PollOptionProps, "votes" | "totalVoteCount">> = ({
pollId, pollId,
isChecked, isChecked,
children, children,
@ -78,7 +93,7 @@ const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteC
export const PollOption: React.FC<PollOptionProps> = ({ export const PollOption: React.FC<PollOptionProps> = ({
pollId, pollId,
answer, answer,
voteCount, votes: voteCount,
totalVoteCount, totalVoteCount,
displayVoteCount, displayVoteCount,
isEnded, isEnded,
@ -91,7 +106,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
mx_PollOption_ended: isEnded, mx_PollOption_ended: isEnded,
}); });
const isWinner = isEnded && isChecked; const isWinner = isEnded && isChecked;
const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount); const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount.length) / totalVoteCount);
const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption; const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption;
return ( return (
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}> <div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
@ -104,7 +119,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
<PollOptionContent <PollOptionContent
isWinner={isWinner} isWinner={isWinner}
answer={answer} answer={answer}
voteCount={voteCount} votes={voteCount}
displayVoteCount={displayVoteCount} displayVoteCount={displayVoteCount}
/> />
</PollOptionWrapper> </PollOptionWrapper>

View file

@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { useEffect, useState } from "react"; import { Tooltip } from "@vector-im/compound-web";
import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { MatrixEvent, Poll, PollEvent, Relations } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, Poll, PollEvent, Relations } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web"; import React, { useEffect, useState } from "react";
import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg"; import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg";
import { _t } from "../../../../languageHandler";
import { formatLocalDateShort } from "../../../../DateUtils"; import { formatLocalDateShort } from "../../../../DateUtils";
import { allVotes, collectUserVotes, countVotes } from "../../messages/MPollBody"; import { _t } from "../../../../languageHandler";
import { allVotes, collectUserVotes, countVotes, UserVote } from "../../messages/MPollBody";
import { PollOption } from "../../polls/PollOption"; import { PollOption } from "../../polls/PollOption";
import { Caption } from "../../typography/Caption"; import { Caption } from "../../typography/Caption";
@ -27,23 +27,23 @@ interface Props {
type EndedPollState = { type EndedPollState = {
winningAnswers: { winningAnswers: {
answer: PollAnswerSubevent; answer: PollAnswerSubevent;
voteCount: number; votes: UserVote[];
}[]; }[];
totalVoteCount: number; totalVoteCount: number;
}; };
const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollState => { const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollState => {
const userVotes = collectUserVotes(allVotes(responseRelations)); const userVotes = collectUserVotes(allVotes(responseRelations));
const votes = countVotes(userVotes, poll.pollEvent); const votes = countVotes(userVotes, poll.pollEvent);
const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote, 0); const totalVoteCount = [...votes.values()].reduce((sum, vote) => sum + vote.length, 0);
const winCount = Math.max(...votes.values()); const winCount = Math.max(...Array.from(votes.values()).map(v => v.length));
return { return {
totalVoteCount, totalVoteCount,
winningAnswers: poll.pollEvent.answers winningAnswers: poll.pollEvent.answers
.filter((answer) => votes.get(answer.id) === winCount) .filter((answer) => votes.get(answer.id)?.length === winCount)
.map((answer) => ({ .map((answer) => ({
answer, answer,
voteCount: votes.get(answer.id) || 0, votes: votes.get(answer.id) || [],
})), })),
}; };
}; };
@ -100,11 +100,11 @@ export const PollListItemEnded: React.FC<Props> = ({ event, poll, onClick }) =>
</div> </div>
{!!winningAnswers?.length && ( {!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers"> <div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => ( {winningAnswers?.map(({ answer, votes }) => (
<PollOption <PollOption
key={answer.id} key={answer.id}
answer={answer} answer={answer}
voteCount={voteCount} votes={votes}
totalVoteCount={totalVoteCount!} totalVoteCount={totalVoteCount!}
pollId={poll.pollId} pollId={poll.pollId}
displayVoteCount displayVoteCount