Compare commits

...

33 Commits

Author SHA1 Message Date
David Langley
7ffcffb1a0 prettier 2025-12-03 17:44:02 +00:00
David Langley
9242717b94 Fix check style in poll option 2025-12-03 16:52:16 +00:00
David Langley
1491fdfe18 fix snapshots 2025-12-03 16:27:11 +00:00
David Langley
e9f8f262b5 Merge branch 'develop' of https://github.com/element-hq/element-web into langleyd/improve-poll-ended-ux 2025-12-03 16:08:30 +00:00
David Langley
727fd369f7 remove O(n^2) code 2025-12-03 15:55:14 +00:00
Will Hunt
a352a3838e Add option to pick call options for voice calls. (#31407)
* Add option to pick call options for voice calls.

* hook on the right thing

* Fix wrong call being disabled

* update snaps

* Add tests for menus

* more snaps

* snap snap
2025-12-03 15:21:15 +00:00
David Baker
61168f0531 Make shared components a regular dependency (#31402)
As other packages need the types too. Also bump version
2025-12-03 13:25:48 +00:00
R Midhun Suresh
3c6f3f7814 Implement new renderNotificationDecoration from module API (#31389)
* Upgrade module api package

* Add a wrapper component

So that we can render the decoration component with just the room.

* Implement module API method

* Add more tests
2025-12-03 11:11:47 +00:00
David Baker
3e2ee7c829 Bump shared components version 2025-12-03 11:19:26 +00:00
David Baker
ac399e8afd Add module API as a ependency of shared components (#31393)
As it refers to the i18n API for types
2025-12-03 10:58:44 +00:00
ElementRobot
4987d6c573 [create-pull-request] automated change (#31397)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-12-03 06:31:32 +00:00
ElementRobot
c883ceeb4b [create-pull-request] automated change (#31396)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-12-03 06:23:32 +00:00
David Langley
da5f1d4820 Remove unneeded translations 2025-11-24 16:41:22 +00:00
David Langley
2c25470666 Fix translation order 2025-11-24 12:24:48 +00:00
David Langley
3f45f4e46c Don't concatenate label 2025-11-24 12:09:36 +00:00
David Langley
9c8bf902f7 Move positioning of total votes label back to the left side
as we are no longer changing the copy to match mobile
2025-11-24 11:29:54 +00:00
David Langley
c47799fe0f Merge branch 'develop' of https://github.com/element-hq/element-web into langleyd/improve-poll-ended-ux 2025-11-24 10:14:34 +00:00
David Langley
5013e90bd3 Update screenshots 2025-11-19 16:27:01 +00:00
David Langley
063c32c188 Fix e2e test 2025-11-19 16:24:11 +00:00
David Langley
9c2cdf508d "Total votes" working doesn't really work with the current web behaviour
Web doesn't show the votes for undisclosed polls(mobile does). reverting and that behaviour change should be addressed in a different PR(or on mobile.).
2025-11-19 16:22:57 +00:00
David Langley
ee07dc18a3 Don't compute optionNumber on each render 2025-11-19 15:16:57 +00:00
David Langley
e880023dd9 Update checkmark and progress colours to more closely match mobile 2025-11-19 14:59:56 +00:00
David Langley
3d4dfc85f7 Add back text for undisclosed poll (total should be hidden) 2025-11-19 14:32:44 +00:00
David Langley
55c5a4a729 Clean up CSS 2025-11-19 14:23:08 +00:00
David Langley
5220ad7294 fix more e2e tests 2025-11-19 13:07:41 +00:00
David Langley
e67a5dbf48 Update e2e tests 2025-11-19 12:43:46 +00:00
David Langley
dc942ebd34 Update snapshot 2025-11-19 12:37:19 +00:00
David Langley
77080a0af9 Fix screenshots 2025-11-19 12:35:38 +00:00
David Langley
0625597a4d Merge branch 'develop' of https://github.com/element-hq/element-web into langleyd/improve-poll-ended-ux 2025-11-19 11:24:18 +00:00
David Langley
8d8ec1dd93 Fix total votes alignment 2025-11-14 17:23:11 +00:00
David Langley
056fbfd69a Update jest tests 2025-11-14 16:15:26 +00:00
David Langley
f7a388bf28 Add better aria labels for screen reader and change ui to match mobile UX.
- Checkmark and progress bar are only green if the poll is ended.
- Updated the Poll icon for open and ended state and added labels
- Right align total votes count and update text
2025-11-13 19:43:57 +00:00
David Langley
413e13f5d4 Remove poll ended event UI. 2025-11-13 17:50:48 +00:00
39 changed files with 1353 additions and 927 deletions

View File

@@ -81,7 +81,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.7.0",
"@element-hq/element-web-module-api": "1.8.0",
"@element-hq/web-shared-components": "link:packages/shared-components",
"@fontsource/fira-code": "^5",
"@fontsource/inter": "^5",

View File

@@ -1,6 +1,6 @@
{
"name": "@element-hq/web-shared-components",
"version": "0.0.0-test.9",
"version": "0.0.0-test.11",
"description": "Shared components for Element",
"author": "New Vector Ltd.",
"repository": {
@@ -49,6 +49,7 @@
"playwright": "1.57.0"
},
"dependencies": {
"@element-hq/element-web-module-api": "^1.8.0",
"@vector-im/compound-design-tokens": "^6.3.0",
"classnames": "^2.5.1",
"counterpart": "^0.18.6",

View File

@@ -352,6 +352,11 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@element-hq/element-web-module-api@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.8.0.tgz#95aa4ec22609cf0f4a7f24274473af0645a16f2a"
integrity sha512-lMiDA9ubP3mZZupIMT8T3wS0riX30rYZj3pFpdP4cfZhkWZa3FJFStokAy5OnaHyENC7Px1cqkBGqilOWewY/A==
"@element-hq/element-web-playwright-common@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-2.0.0.tgz#30cf741a33c69540b4bc434f5349d0fe900bc611"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -10,7 +10,7 @@ import {
type StartedPostgreSqlContainer,
} from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "main@sha256:01927e5be5e860ce51174e1b99213568d96aa9f10dd7b7d771182b4a1c752d2e";
const TAG = "main@sha256:326ef2fbd740ce0050b46807dc51d513228ce02dca681c453815cc6cc3d2d207";
/**
* MatrixAuthenticationServiceContainer which freezes the docker digest to

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:8e798d78f66c9a52574c974aad371d8f6d1b4b0ded9af640a551beb0a823639d";
const TAG = "develop@sha256:169c6f61a658630690e09a0548fdb5bea92096a2d58752152b0c2199d14417ad";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -238,7 +238,6 @@
@import "./views/messages/_MLocationBody.pcss";
@import "./views/messages/_MNoticeBody.pcss";
@import "./views/messages/_MPollBody.pcss";
@import "./views/messages/_MPollEndBody.pcss";
@import "./views/messages/_MStickerBody.pcss";
@import "./views/messages/_MTextBody.pcss";
@import "./views/messages/_MVideoBody.pcss";

View File

@@ -50,8 +50,43 @@ Please see LICENSE files in the repository root for full details.
}
.mx_PollOption_checked {
border-color: var(--cpd-color-border-interactive-hovered);
.mx_PollOption_popularityBackground {
.mx_PollOption_popularityAmount {
background-color: var(--cpd-color-icon-primary);
}
}
/* override checked radio button styling to show checkmark instead */
.mx_StyledRadioButton_checked {
input[type="radio"]:checked + div {
position: relative;
border-width: 2px;
border-color: var(--cpd-color-icon-primary);
background-color: var(--cpd-color-icon-primary);
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
mask-image: url("@vector-im/compound-design-tokens/icons/check.svg");
mask-size: 12px 12px;
mask-repeat: no-repeat;
mask-position: center;
background-color: var(--cpd-color-icon-on-solid-primary);
width: 12px;
height: 12px;
}
div {
visibility: hidden;
}
}
}
}
.mx_PollOption_ended.mx_PollOption_checked {
.mx_PollOption_popularityBackground {
.mx_PollOption_popularityAmount {
background-color: var(--cpd-color-icon-accent-tertiary);
@@ -61,17 +96,8 @@ Please see LICENSE files in the repository root for full details.
/* override checked radio button styling to show checkmark instead */
.mx_StyledRadioButton_checked {
input[type="radio"]:checked + div {
border-width: 2px;
border-color: var(--cpd-color-icon-accent-tertiary);
background-color: var(--cpd-color-icon-accent-tertiary);
background-image: url("@vector-im/compound-design-tokens/icons/check.svg");
background-size: 12px;
background-repeat: no-repeat;
background-position: center;
div {
visibility: hidden;
}
}
}
}
@@ -94,6 +120,6 @@ Please see LICENSE files in the repository root for full details.
width: 0%;
height: 8px;
border-radius: 8px;
background-color: $quaternary-content;
background-color: var(--cpd-color-icon-primary);
}
}

View File

@@ -6,8 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
$poll-max-width: 550px;
.mx_MPollBody {
margin-top: 8px;
margin-top: var(--cpd-space-2x);
min-width: 0; /* Override fieldset default min-width: min-content */
width: 100%; /* Ensure fieldset takes full available width */
border: none; /* Remove default fieldset border */
@@ -18,8 +20,16 @@ Please see LICENSE files in the repository root for full details.
font-size: $font-15px;
line-height: $font-24px;
margin-top: 0;
margin-bottom: 8px;
margin-bottom: var(--cpd-space-2x);
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
svg {
flex-shrink: 0;
color: var(--cpd-color-icon-primary);
}
.mx_MPollBody_edited {
color: $roomtopic-color;
@@ -28,22 +38,6 @@ Please see LICENSE files in the repository root for full details.
}
}
legend::before {
content: "";
position: relative;
display: inline-block;
margin-right: 12px;
top: 3px;
left: 3px;
height: 20px;
width: 20px;
background-color: $secondary-content;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
mask-image: url("@vector-im/compound-design-tokens/icons/polls.svg");
}
.mx_MPollBody_totalVotes {
display: flex;
flex-direction: inline;
@@ -67,5 +61,5 @@ Please see LICENSE files in the repository root for full details.
display: grid;
gap: $spacing-16;
margin-bottom: $spacing-8;
max-width: 550px;
max-width: $poll-max-width;
}

View File

@@ -1,14 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MPollEndBody_icon {
height: 14px;
margin-right: $spacing-8;
vertical-align: middle;
color: $secondary-content;
}

View File

@@ -355,11 +355,6 @@ Please see LICENSE files in the repository root for full details.
/* Keep height equal to text for shield alignment, additional 2px because of 1px padding on text */
height: calc($font-18px + 2px);
}
.mx_MPollEndBody {
/* Prevent the poll end body from exceeding the tile width */
width: 100%;
}
}
&:not(.mx_EventTile_noBubble) .mx_EventTile_line:not(.mx_EventTile_mediaLine) {

View File

@@ -22,6 +22,8 @@ import {
import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations";
import { type PollStartEvent, type PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import PollsIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls";
import PollsEndIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls-end";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
@@ -324,14 +326,18 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
<span className="mx_MPollBody_edited"> ({_t("common|edited")})</span>
) : null;
const PollIcon = poll.isEnded ? PollsEndIcon : PollsIcon;
const pollLabel = poll.isEnded ? _t("poll|ended_poll_label") : _t("poll|poll_label");
return (
<fieldset className="mx_MPollBody">
<legend data-testid="pollQuestion">
<PollIcon width="20" height="20" aria-label={pollLabel} />
{pollEvent.question.text}
{editedSpan}
</legend>
<div className="mx_MPollBody_allOptions">
{pollEvent.answers.map((answer: PollAnswerSubevent) => {
{pollEvent.answers.map((answer: PollAnswerSubevent, index: number) => {
let answerVotes = 0;
if (showResults) {
@@ -346,6 +352,7 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
key={answer.id}
pollId={pollId}
answer={answer}
optionNumber={index + 1}
isChecked={checked}
isEnded={poll.isEnded}
voteCount={answerVotes}

View File

@@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, useState, useContext, type JSX } from "react";
import { MatrixEvent, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { PollsEndIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import MatrixClientContext, { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { _t } from "../../../languageHandler";
import { textForEvent } from "../../../TextForEvent";
import { Caption } from "../typography/Caption";
import { type IBodyProps } from "./IBodyProps";
import MPollBody from "./MPollBody";
const getRelatedPollStartEventId = (event: MatrixEvent): string | undefined => {
const relation = event.getRelation();
return relation?.event_id;
};
/**
* Attempt to retrieve the related poll start event for this end event
* If the event already exists in the rooms timeline, return it
* Otherwise try to fetch the event from the server
* @param event
* @returns
*/
const usePollStartEvent = (event: MatrixEvent): { pollStartEvent?: MatrixEvent; isLoadingPollStartEvent: boolean } => {
const matrixClient = useContext(MatrixClientContext);
const [pollStartEvent, setPollStartEvent] = useState<MatrixEvent>();
const [isLoadingPollStartEvent, setIsLoadingPollStartEvent] = useState(false);
const pollStartEventId = getRelatedPollStartEventId(event);
useEffect(() => {
const room = matrixClient.getRoom(event.getRoomId());
const fetchPollStartEvent = async (roomId: string, pollStartEventId: string): Promise<void> => {
setIsLoadingPollStartEvent(true);
try {
const startEventJson = await matrixClient.fetchRoomEvent(roomId, pollStartEventId);
const startEvent = new MatrixEvent(startEventJson);
// add the poll to the room polls state
room?.processPollEvents([startEvent, event]);
// end event is not a valid end to the related start event
// if not sent by the same user
if (startEvent.getSender() === event.getSender()) {
setPollStartEvent(startEvent);
}
} catch (error) {
logger.error("Failed to fetch related poll start event", error);
} finally {
setIsLoadingPollStartEvent(false);
}
};
if (pollStartEvent || !room || !pollStartEventId) {
return;
}
const timelineSet = room.getUnfilteredTimelineSet();
const localEvent = timelineSet
?.getTimelineForEvent(pollStartEventId)
?.getEvents()
.find((e) => e.getId() === pollStartEventId);
if (localEvent) {
// end event is not a valid end to the related start event
// if not sent by the same user
if (localEvent.getSender() === event.getSender()) {
setPollStartEvent(localEvent);
}
} else {
// pollStartEvent is not in the current timeline,
// fetch it
fetchPollStartEvent(room.roomId, pollStartEventId);
}
}, [event, pollStartEventId, pollStartEvent, matrixClient]);
return { pollStartEvent, isLoadingPollStartEvent };
};
export const MPollEndBody = ({ mxEvent, ref, ...props }: IBodyProps): JSX.Element => {
const cli = useMatrixClientContext();
const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent);
if (!pollStartEvent) {
const pollEndFallbackMessage = M_TEXT.findIn<string>(mxEvent.getContent()) || textForEvent(mxEvent, cli);
return (
<>
<PollsEndIcon className="mx_MPollEndBody_icon" />
{!isLoadingPollStartEvent && pollEndFallbackMessage}
</>
);
}
return (
<div className="mx_MPollEndBody" ref={ref}>
<Caption>{_t("timeline|m.poll.end|ended")}</Caption>
<MPollBody mxEvent={pollStartEvent} {...props} />
</div>
);
};

View File

@@ -15,7 +15,6 @@ import {
MatrixEventEvent,
M_BEACON_INFO,
M_LOCATION,
M_POLL_END,
M_POLL_START,
type IContent,
} from "matrix-js-sdk/src/matrix";
@@ -34,7 +33,6 @@ import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
import MVideoBody from "./MVideoBody";
import MStickerBody from "./MStickerBody";
import MPollBody from "./MPollBody";
import { MPollEndBody } from "./MPollEndBody";
import MLocationBody from "./MLocationBody";
import MjolnirBody from "./MjolnirBody";
import MBeaconBody from "./MBeaconBody";
@@ -75,8 +73,6 @@ const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
[EventType.Sticker, MStickerBody],
[M_POLL_START.name, MPollBody],
[M_POLL_START.altName, MPollBody],
[M_POLL_END.name, MPollEndBody],
[M_POLL_END.altName, MPollEndBody],
[M_BEACON_INFO.name, MBeaconBody],
[M_BEACON_INFO.altName, MBeaconBody],
]);

View File

@@ -36,50 +36,67 @@ const PollOptionContent: React.FC<PollOptionContentProps> = ({ isWinner, answer,
interface PollOptionProps extends PollOptionContentProps {
pollId: string;
totalVoteCount: number;
optionNumber: number;
isEnded?: boolean;
isChecked?: boolean;
onOptionSelected?: (id: string) => void;
children?: ReactNode;
}
const EndedPollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
isChecked,
children,
answer,
}) => (
<div
className={classNames("mx_PollOption_endedOption", {
mx_PollOption_endedOptionWinner: isChecked,
})}
data-value={answer.id}
>
{children}
</div>
);
const ActivePollOption: React.FC<Omit<PollOptionProps, "voteCount" | "totalVoteCount">> = ({
const ActivePollOption: React.FC<Omit<PollOptionProps, "totalVoteCount"> & { children: ReactNode }> = ({
pollId,
isChecked,
isEnded,
optionNumber,
isWinner,
voteCount,
displayVoteCount,
children,
answer,
onOptionSelected,
}) => (
<StyledRadioButton
className="mx_PollOption_live-option"
name={`poll_answer_select-${pollId}`}
value={answer.id}
checked={isChecked}
onChange={() => onOptionSelected?.(answer.id)}
>
{children}
</StyledRadioButton>
);
}) => {
let ariaLabel: string;
if (displayVoteCount && isWinner) {
ariaLabel = _t("poll|option_label_winning_with_total", {
number: optionNumber,
answer: answer.text,
count: voteCount,
});
} else if (displayVoteCount) {
ariaLabel = _t("poll|option_label_with_total", {
number: optionNumber,
answer: answer.text,
count: voteCount,
});
} else {
ariaLabel = _t("poll|option_label", {
number: optionNumber,
answer: answer.text,
});
}
return (
<StyledRadioButton
className="mx_PollOption_live-option"
name={`poll_answer_select-${pollId}`}
value={answer.id}
checked={isChecked}
disabled={isEnded}
aria-label={ariaLabel}
onChange={() => onOptionSelected?.(answer.id)}
>
<div aria-hidden="true">{children}</div>
</StyledRadioButton>
);
};
export const PollOption: React.FC<PollOptionProps> = ({
pollId,
answer,
voteCount,
totalVoteCount,
optionNumber,
displayVoteCount,
isEnded,
isChecked,
@@ -92,13 +109,17 @@ export const PollOption: React.FC<PollOptionProps> = ({
});
const isWinner = isEnded && isChecked;
const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount);
const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption;
return (
<div data-testid={`pollOption-${answer.id}`} className={cls} onClick={() => onOptionSelected?.(answer.id)}>
<PollOptionWrapper
<ActivePollOption
pollId={pollId}
answer={answer}
optionNumber={optionNumber}
isChecked={isChecked}
isEnded={isEnded}
isWinner={isWinner}
voteCount={voteCount}
displayVoteCount={displayVoteCount}
onOptionSelected={onOptionSelected}
>
<PollOptionContent
@@ -107,7 +128,7 @@ export const PollOption: React.FC<PollOptionProps> = ({
voteCount={voteCount}
displayVoteCount={displayVoteCount}
/>
</PollOptionWrapper>
</ActivePollOption>
<div className="mx_PollOption_popularityBackground">
<div className="mx_PollOption_popularityAmount" style={{ width: `${answerPercent}%` }} />
</div>

View File

@@ -28,6 +28,7 @@ type EndedPollState = {
winningAnswers: {
answer: PollAnswerSubevent;
voteCount: number;
optionNumber: number;
}[];
totalVoteCount: number;
};
@@ -40,10 +41,12 @@ const getWinningAnswers = (poll: Poll, responseRelations: Relations): EndedPollS
return {
totalVoteCount,
winningAnswers: poll.pollEvent.answers
.filter((answer) => votes.get(answer.id) === winCount)
.map((answer) => ({
.map((answer, index) => ({ answerIndex: index, answer })) // keep track of original answer index
.filter(({ answer }) => votes.get(answer.id) === winCount)
.map(({ answer, answerIndex }) => ({
answer,
voteCount: votes.get(answer.id) || 0,
optionNumber: answerIndex + 1,
})),
};
};
@@ -100,13 +103,14 @@ export const PollListItemEnded: React.FC<Props> = ({ event, poll, onClick }) =>
</div>
{!!winningAnswers?.length && (
<div className="mx_PollListItemEnded_answers">
{winningAnswers?.map(({ answer, voteCount }) => (
{winningAnswers?.map(({ answer, voteCount, optionNumber }) => (
<PollOption
key={answer.id}
answer={answer}
voteCount={voteCount}
totalVoteCount={totalVoteCount!}
pollId={poll.pollId}
optionNumber={optionNumber}
displayVoteCount
isChecked
isEnded

View File

@@ -1,4 +1,5 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@@ -6,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useCallback, useMemo, useState } from "react";
import React, { type JSX, useCallback, useState } from "react";
import { Text, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
@@ -29,7 +30,6 @@ import { _t } from "../../../../languageHandler.tsx";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
import SdkConfig from "../../../../SdkConfig.ts";
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
@@ -78,16 +78,6 @@ function RoomHeaderButtons({
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
/**
* A special mode where only Element Call is used. In this case we want to
* hide the voice call button
*/
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively && groupCallsEnabled;
}, [groupCallsEnabled]);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
@@ -101,6 +91,11 @@ function RoomHeaderButtons({
[callOptions, videoCallClick],
);
const voiceClick = useCallback(
(ev: React.MouseEvent) => voiceCallClick(ev, callOptions[0]),
[callOptions, voiceCallClick],
);
const toggleCallButton = (
<Tooltip label={isViewingCall ? _t("voip|minimise_call") : _t("voip|maximise_call")}>
<IconButton onClick={toggleCall}>
@@ -126,35 +121,50 @@ function RoomHeaderButtons({
</Tooltip>
);
const callIconWithTooltip = (
const videoCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|video_call")}>
<VideoCallIcon />
</Tooltip>
);
const [menuOpen, setMenuOpen] = useState(false);
const voiceCallIconWithTooltip = (
<Tooltip label={videoCallDisabledReason ?? _t("voip|voice_call")}>
<VoiceCallIcon />
</Tooltip>
);
const onOpenChange = useCallback(
const [videoMenuOpen, setVideoMenuOpen] = useState(false);
const onVideoOpenChange = useCallback(
(newOpen: boolean) => {
if (!videoCallDisabledReason) setMenuOpen(newOpen);
if (!videoCallDisabledReason) setVideoMenuOpen(newOpen);
},
[videoCallDisabledReason],
);
const [voiceMenuOpen, setVoiceMenuOpen] = useState(false);
const onVoiceOpenChange = useCallback(
(newOpen: boolean) => {
if (!voiceCallDisabledReason) setVoiceMenuOpen(newOpen);
},
[voiceCallDisabledReason],
);
const startVideoCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={menuOpen}
onOpenChange={onOpenChange}
open={videoMenuOpen}
onOpenChange={onVideoOpenChange}
title={_t("voip|video_call_using")}
trigger={
<IconButton
disabled={!!videoCallDisabledReason}
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
}
side="left"
@@ -170,7 +180,7 @@ function RoomHeaderButtons({
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setMenuOpen(false);
setVideoMenuOpen(false);
videoCallClick(ev, option);
}}
Icon={VideoCallIcon}
@@ -185,25 +195,61 @@ function RoomHeaderButtons({
aria-label={videoCallDisabledReason ?? _t("voip|video_call")}
onClick={videoClick}
>
{callIconWithTooltip}
{videoCallIconWithTooltip}
</IconButton>
)}
</>
);
let voiceCallButton: JSX.Element | undefined = (
<Tooltip label={voiceCallDisabledReason ?? _t("voip|voice_call")}>
<IconButton
// We need both: isViewingCall and isConnectedToCall
// - in the Lobby we are viewing a call but are not connected to it.
// - in pip view we are connected to the call but not viewing it.
disabled={!!voiceCallDisabledReason || isViewingCall || isConnectedToCall}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={(ev) => voiceCallClick(ev, callOptions[0])}
>
<VoiceCallIcon />
</IconButton>
</Tooltip>
const startVoiceCallButton = (
<>
{/* Can be either a menu or just a button depending on the number of call options.*/}
{callOptions.length > 1 ? (
<Menu
open={voiceMenuOpen}
onOpenChange={onVoiceOpenChange}
title={_t("voip|voice_call_using")}
trigger={
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
>
{voiceCallIconWithTooltip}
</IconButton>
}
side="left"
align="start"
>
{callOptions.map((option) => {
const { label, children } = getPlatformCallTypeProps(option);
return (
<MenuItem
key={option}
label={label}
aria-label={label}
children={children}
className="mx_RoomHeader_videoCallOption"
onClick={(ev) => {
setVoiceMenuOpen(false);
voiceCallClick(ev, option);
}}
Icon={VoiceCallIcon}
onSelect={() => {} /* Dummy handler since we want the click event.*/}
/>
);
})}
</Menu>
) : (
<IconButton
disabled={!!voiceCallDisabledReason}
aria-label={voiceCallDisabledReason ?? _t("voip|voice_call")}
onClick={voiceClick}
>
{voiceCallIconWithTooltip}
</IconButton>
)}
</>
);
const closeLobbyButton = (
<Tooltip label={_t("voip|close_lobby")}>
<IconButton onClick={toggleCall}>
@@ -212,15 +258,19 @@ function RoomHeaderButtons({
</Tooltip>
);
let videoCallButton: JSX.Element | undefined = startVideoCallButton;
let voiceCallButton: JSX.Element | undefined = startVoiceCallButton;
if (isConnectedToCall) {
videoCallButton = toggleCallButton;
voiceCallButton = undefined;
} else if (isViewingCall) {
videoCallButton = closeLobbyButton;
voiceCallButton = undefined;
}
if (!showVideoCallButton) {
videoCallButton = undefined;
}
if (!showVoiceCallButton) {
voiceCallButton = undefined;
}
@@ -258,7 +308,7 @@ function RoomHeaderButtons({
) : (
<>
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
{!isVideoRoom && voiceCallButton}
</>
)}

View File

@@ -842,6 +842,7 @@
"notification_state": "Benachrichtigungsstand ist <strong>%(notificationState)s</strong>",
"notifications_debug": "Debug-Modus für Benachrichtigungen",
"number_of_users": "Benutzeranzahl",
"only_joined_members": "Nur Mitglieder",
"original_event_source": "Ursprüngliche Rohdaten",
"room_encrypted": "Chat ist <strong>verschlüsselt ✅</strong>",
"room_id": "Chat-ID: %(roomId)s",

View File

@@ -1762,6 +1762,7 @@
"end_message": "The poll has ended. Top answer: %(topAnswer)s",
"end_message_no_votes": "The poll has ended. No votes were cast.",
"end_title": "End Poll",
"ended_poll_label": "Poll ended",
"error_ending_description": "Sorry, the poll did not end. Please try again.",
"error_ending_title": "Failed to end poll",
"error_voting_description": "Sorry, your vote was not registered. Please try again.",
@@ -1769,10 +1770,20 @@
"failed_send_poll_description": "Sorry, the poll you tried to create was not posted.",
"failed_send_poll_title": "Failed to post poll",
"notes": "Results are only revealed when you end the poll",
"option_label": "Option %(number)s, %(answer)s",
"option_label_winning_with_total": {
"one": "Option %(number)s, %(answer)s, winning option, %(count)s vote",
"other": "Option %(number)s, %(answer)s, winning option, %(count)s votes"
},
"option_label_with_total": {
"one": "Option %(number)s, %(answer)s, %(count)s vote",
"other": "Option %(number)s, %(answer)s, %(count)s votes"
},
"options_add_button": "Add option",
"options_heading": "Create options",
"options_label": "Option %(number)s",
"options_placeholder": "Write an option",
"poll_label": "Poll",
"topic_heading": "What is your poll question or topic?",
"topic_label": "Question or topic",
"topic_placeholder": "Write something…",
@@ -3526,7 +3537,6 @@
}
},
"m.poll.end": {
"ended": "Ended a poll",
"sender_ended": "%(senderName)s has ended a poll"
},
"m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s",
@@ -4103,6 +4113,7 @@
"video_call_using": "Video call using:",
"voice_call": "Voice call",
"voice_call_incoming": "Incoming voice call",
"voice_call_using": "Voice call using:",
"you_are_presenting": "You are presenting"
},
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",

View File

@@ -93,6 +93,7 @@
"maximise": "Maximizar",
"mention": "Mencionar",
"minimise": "Minimizar",
"new_conversation": "Nova conversa",
"new_room": "Nova sala",
"new_video_room": "Nova sala de vídeo",
"next": "Próximo",

View File

@@ -97,6 +97,7 @@
"maximise": "Maximalizovať",
"mention": "Zmieniť sa",
"minimise": "Minimalizovať",
"new_conversation": "Nová konverzácia",
"new_room": "Nová miestnosť",
"new_video_room": "Nová video miestnosť",
"next": "Ďalej",

View File

@@ -10,6 +10,7 @@ import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-mo
import { MatrixClientPeg } from "../MatrixClientPeg";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ModuleNotificationDecorationProps } from "./components/ModuleNotificationDecoration";
interface RoomViewPropsWithRoomId extends RoomViewProps {
/**
@@ -26,11 +27,14 @@ interface RoomAvatarProps {
interface Components {
roomView: React.ComponentType<RoomViewPropsWithRoomId>;
roomAvatar: React.ComponentType<RoomAvatarProps>;
notificationDecoration: React.ComponentType<ModuleNotificationDecorationProps>;
}
export class ElementWebBuiltinsApi implements BuiltinsApi {
private _roomView?: Components["roomView"];
private _roomAvatar?: Components["roomAvatar"];
private _notificationDecoration?: Components["notificationDecoration"];
/**
* Sets the components used by the API.
*
@@ -43,13 +47,13 @@ export class ElementWebBuiltinsApi implements BuiltinsApi {
public setComponents(components: Components): void {
this._roomView = components.roomView;
this._roomAvatar = components.roomAvatar;
this._notificationDecoration = components.notificationDecoration;
}
public getRoomViewComponent(): React.ComponentType<RoomViewPropsWithRoomId> {
if (!this._roomView) {
throw new Error("No RoomView component has been set");
}
return this._roomView;
}
@@ -57,10 +61,16 @@ export class ElementWebBuiltinsApi implements BuiltinsApi {
if (!this._roomAvatar) {
throw new Error("No RoomAvatar component has been set");
}
return this._roomAvatar;
}
public getNotificationDecorationComponent(): React.ComponentType<ModuleNotificationDecorationProps> {
if (!this._notificationDecoration) {
throw new Error("No NotificationDecoration component has been set");
}
return this._notificationDecoration;
}
public renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode {
const Component = this.getRoomViewComponent();
return <Component roomId={roomId} {...props} />;
@@ -74,4 +84,13 @@ export class ElementWebBuiltinsApi implements BuiltinsApi {
const Component = this.getRoomAvatarComponent();
return <Component room={room} size={size} />;
}
public renderNotificationDecoration(roomId: string): React.ReactNode {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
throw new Error(`No room such room: ${roomId}`);
}
const Component = this.getNotificationDecorationComponent();
return <Component room={room} />;
}
}

View File

@@ -0,0 +1,29 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useMemo } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { useCall } from "../../hooks/useCall";
import { NotificationDecoration } from "../../components/views/rooms/NotificationDecoration";
export interface ModuleNotificationDecorationProps {
/**
* The room for which the decoration is rendered.
*/
room: Room;
}
/**
* React component that takes a room as prop and renders {@link NotificationDecoration} with it.
* Used by the module API to render notification decoration without having to expose a bunch of stores.
*/
export const ModuleNotificationDecoration: React.FC<ModuleNotificationDecorationProps> = ({ room }) => {
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const call = useCall(room.roomId);
return <NotificationDecoration notificationState={notificationState} callType={call?.callType} />;
};

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { type MatrixEvent, EventType, RelationType, M_POLL_END } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import SettingsStore from "./settings/SettingsStore";
@@ -50,6 +50,9 @@ function memberEventDiff(ev: MatrixEvent): IDiff {
* hitting the settings store
*/
export default function shouldHideEvent(ev: MatrixEvent, ctx?: IRoomState): boolean {
// Hide all poll end events
if (M_POLL_END.matches(ev.getType())) return true;
// Accessing the settings store directly can be expensive if done frequently,
// so we should prefer using cached values if a RoomContext is available
const isEnabled = ctx

View File

@@ -33,6 +33,7 @@ import { UserFriendlyError } from "../languageHandler";
import { ModuleApi } from "../modules/Api";
import { RoomView } from "../components/structures/RoomView";
import RoomAvatar from "../components/views/avatars/RoomAvatar";
import { ModuleNotificationDecoration } from "../modules/components/ModuleNotificationDecoration";
logger.log(`Application is running in ${process.env.NODE_ENV} mode`);
@@ -58,7 +59,11 @@ function onTokenLoginCompleted(): void {
export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref<MatrixChat>): Promise<ReactElement> {
// XXX: This lives here because certain components import so many things that importing it in a sensible place (eg.
// the builtins module or init.tsx) causes a circular dependency.
ModuleApi.instance.builtins.setComponents({ roomView: RoomView, roomAvatar: RoomAvatar });
ModuleApi.instance.builtins.setComponents({
roomView: RoomView,
roomAvatar: RoomAvatar,
notificationDecoration: ModuleNotificationDecoration,
});
initRouting();
const platform = PlatformPeg.get();

View File

@@ -1023,7 +1023,6 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_5_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1035,6 +1034,7 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1519,7 +1519,6 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_5g_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1531,6 +1530,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_5g_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -1953,7 +1953,6 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_2j_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -1965,6 +1964,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_2j_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2387,7 +2387,6 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_ag_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -2399,6 +2398,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -2599,7 +2599,6 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_ag_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -2611,6 +2610,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@@ -505,11 +505,11 @@ describe("MPollBody", () => {
expect(runFindTopAnswer([])).toEqual("");
});
it("shows non-radio buttons if the poll is ended", async () => {
it("shows disabled radio buttons if the poll is ended", async () => {
const events = [newPollEndEvent()];
const { container } = await newMPollBody([], events);
expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument();
expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument();
expect(container.querySelector(".mx_StyledRadioButton")).toBeInTheDocument();
expect(container.querySelector('input[type="radio"][disabled]')).toBeInTheDocument();
});
it("counts votes as normal if the poll is ended", async () => {
@@ -551,8 +551,8 @@ describe("MPollBody", () => {
const ends = [newPollEndEvent("@me:example.com", 25)];
const renderResult = await newMPollBody(votes, ends);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0);
expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(0);
expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(4);
expect(renderResult.container.querySelectorAll('input[type="radio"][disabled]')).toHaveLength(4);
expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes");
expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes");
expect(endedVotesCount(renderResult, "italian")).toBe("0 votes");
@@ -646,9 +646,9 @@ describe("MPollBody", () => {
expect(endedVoteChecked(renderResult, "wings")).toBe(true);
expect(endedVoteChecked(renderResult, "pizza")).toBe(false);
// Double-check by looking for the endedOptionWinner class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false);
// Double-check by looking for the checked class
expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_checked")).toBe(true);
expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_checked")).toBe(false);
});
it("highlights multiple winning votes", async () => {
@@ -731,9 +731,7 @@ describe("MPollBody", () => {
});
pollEvent.makeReplaced(replacingEvent);
const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []);
expect(getByTestId("pollQuestion").innerHTML).toEqual(
'new question<span class="mx_MPollBody_edited"> (edited)</span>',
);
expect(getByTestId("pollQuestion").textContent).toEqual("new question (edited)");
const inputs = container.querySelectorAll('input[type="radio"]');
expect(inputs).toHaveLength(3);
expect(inputs[0].getAttribute("value")).toEqual("n1");
@@ -951,7 +949,7 @@ function endedVoteChecked({ getByTestId }: RenderResult, value: string): boolean
}
function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element {
return getByTestId(`pollOption-${value}`).firstElementChild!;
return getByTestId(`pollOption-${value}`);
}
function endedVotesCount(renderResult: RenderResult, value: string): string {

View File

@@ -1,193 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, waitFor } from "jest-matrix-react";
import { type EventTimeline, type MatrixEvent, Room, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type IBodyProps } from "../../../../../src/components/views/messages/IBodyProps";
import { MPollEndBody } from "../../../../../src/components/views/messages/MPollEndBody";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import { type RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import {
flushPromises,
getMockClientWithEventEmitter,
makePollEndEvent,
makePollStartEvent,
mockClientMethodsEvents,
mockClientMethodsUser,
setupRoomWithPollEvents,
} from "../../../../test-utils";
describe("<MPollEndBody />", () => {
const userId = "@alice:domain.org";
const roomId = "!room:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
relations: jest.fn(),
fetchRoomEvent: jest.fn(),
});
const pollStartEvent = makePollStartEvent("Question?", userId, undefined, { roomId });
const pollEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise<Room> => {
if (pollStart) {
await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient);
}
const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId);
// end events validate against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation(
(_evt: MatrixEvent, id: string) => {
return id === mockClient.getSafeUserId();
},
);
const timelineSet = room.getUnfilteredTimelineSet();
const getTimelineForEventSpy = jest.spyOn(timelineSet, "getTimelineForEvent");
// if we have a pollStart, mock the room timeline to include it
if (pollStart) {
const eventTimeline = {
getEvents: jest.fn().mockReturnValue([pollEnd, pollStart]),
} as unknown as EventTimeline;
getTimelineForEventSpy.mockReturnValue(eventTimeline);
}
mockClient.getRoom.mockReturnValue(room);
return room;
};
const defaultProps = {
mxEvent: pollEndEvent,
highlightLink: "unused",
mediaEventHelper: {} as unknown as MediaEventHelper,
onMessageAllowed: () => {},
permalinkCreator: {} as unknown as RoomPermalinkCreator,
ref: undefined as any,
};
const getComponent = (props: Partial<IBodyProps> = {}) =>
render(<MPollEndBody {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
beforeEach(() => {
mockClient.getRoom.mockReset();
mockClient.relations.mockResolvedValue({
events: [],
});
mockClient.fetchRoomEvent.mockResolvedValue(pollStartEvent.getEffectiveEvent());
});
afterEach(() => {
jest.spyOn(logger, "error").mockRestore();
});
describe("when poll start event exists in current timeline", () => {
it("renders an ended poll", async () => {
await setupRoomWithEventsTimeline(pollEndEvent, pollStartEvent);
const { container } = getComponent();
// ended poll rendered
expect(container).toMatchSnapshot();
// didnt try to fetch start event while it was already in timeline
expect(mockClient.fetchRoomEvent).not.toHaveBeenCalled();
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent, pollStartEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
});
describe("when poll start event does not exist in current timeline", () => {
it("fetches the related poll start event and displays a poll tile", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
const { container, getByTestId, getByRole, queryByRole } = getComponent();
// while fetching event, only icon is shown
expect(container).toMatchSnapshot();
await waitFor(() => expect(getByRole("progressbar")).toBeInTheDocument());
await waitFor(() => expect(queryByRole("progressbar")).not.toBeInTheDocument());
expect(mockClient.fetchRoomEvent).toHaveBeenCalledWith(roomId, pollStartEvent.getId());
// quick check for poll tile
expect(getByTestId("pollQuestion").innerHTML).toEqual("Question?");
expect(getByTestId("totalVotes").innerHTML).toEqual("Final result based on 0 votes");
});
it("does not render a poll tile when end event is invalid", async () => {
// sender of end event does not match start event
const invalidEndEvent = makePollEndEvent(pollStartEvent.getId()!, roomId, "@mallory:domain.org", 123);
await setupRoomWithEventsTimeline(invalidEndEvent);
const { getByText } = getComponent({ mxEvent: invalidEndEvent });
// flush the fetch event promise
await flushPromises();
// no poll tile rendered
expect(getByText("The poll has ended. Something.")).toBeTruthy();
});
it("logs an error and displays the text fallback when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("logs an error and displays the extensible event text when fetching the start event fails", async () => {
await setupRoomWithEventsTimeline(pollEndEvent);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const logSpy = jest.spyOn(logger, "error").mockImplementation(() => {});
const { getByText } = getComponent();
// flush the fetch event promise
await flushPromises();
// poll end event fallback text used
expect(getByText("The poll has ended. Something.")).toBeTruthy();
expect(logSpy).toHaveBeenCalledWith("Failed to fetch related poll start event", { code: 404 });
});
it("displays fallback text when the poll end event does not have text", async () => {
const endWithoutText = makePollEndEvent(pollStartEvent.getId()!, roomId, userId, 123);
delete endWithoutText.getContent()[M_TEXT.name];
await setupRoomWithEventsTimeline(endWithoutText);
mockClient.fetchRoomEvent.mockRejectedValue({ code: 404 });
const { getByText } = getComponent({ mxEvent: endWithoutText });
// flush the fetch event promise
await flushPromises();
// default fallback text used
expect(getByText("@alice:domain.org has ended a poll")).toBeTruthy();
});
});
});

View File

@@ -1,129 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<MPollEndBody /> when poll start event does not exist in current timeline fetches the related poll start event and displays a poll tile 1`] = `
<div>
<svg
class="mx_MPollEndBody_icon"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 10.659V19q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h8.341A6 6 0 0 0 13 5H5v14h14v-8a6 6 0 0 0 2-.341"
/>
<path
d="M13.803 8a6 6 0 0 0 1.88 2H13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 9q0-.424.287-.713A.97.97 0 0 1 13 8zm2.91 7.713A.97.97 0 0 1 16 16h-3a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 15q0-.424.287-.713A.97.97 0 0 1 13 14h3q.424 0 .712.287.288.288.288.713 0 .424-.288.713m-6.299-5.3A1.93 1.93 0 0 1 9 11q-.825 0-1.412-.588A1.93 1.93 0 0 1 7 9q0-.825.588-1.412A1.93 1.93 0 0 1 9 7q.825 0 1.412.588Q11 8.175 11 9t-.588 1.412m.001 6.001A1.93 1.93 0 0 1 9 17q-.825 0-1.412-.587A1.93 1.93 0 0 1 7 15q0-.825.588-1.412A1.93 1.93 0 0 1 9 13q.825 0 1.412.588Q11 14.175 11 15q0 .824-.588 1.413m12.295-14.12a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L18 5.586l3.293-3.293a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
`;
exports[`<MPollEndBody /> when poll start event exists in current timeline renders an ended poll 1`] = `
<div>
<div
class="mx_MPollEndBody"
>
<span
class="mx_Caption"
>
Ended a poll
</span>
<fieldset
class="mx_MPollBody"
>
<legend
data-testid="pollQuestion"
>
Question?
</legend>
<div
class="mx_MPollBody_allOptions"
>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-socks"
>
<div
class="mx_PollOption_endedOption"
data-value="socks"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Socks
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
<div
class="mx_PollOption mx_PollOption_ended"
data-testid="pollOption-shoes"
>
<div
class="mx_PollOption_endedOption"
data-value="shoes"
>
<div
class="mx_PollOption_content"
>
<div
class="mx_PollOption_optionText"
>
Shoes
</div>
<div
class="mx_PollOption_optionVoteCount"
>
0 votes
</div>
</div>
</div>
<div
class="mx_PollOption_popularityBackground"
>
<div
class="mx_PollOption_popularityAmount"
style="width: 0%;"
/>
</div>
</div>
</div>
<div
class="mx_MPollBody_totalVotes"
data-testid="totalVotes"
>
Final result based on 0 votes
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 16px; height: 16px;"
/>
</div>
</div>
</fieldset>
</div>
</div>
`;

View File

@@ -158,4 +158,53 @@ describe("<PollListItemEnded />", () => {
expect(getByText("Nissan Silvia S15")).toBeInTheDocument();
expect(queryByText("Mitsubishi Lancer Evolution IX")).not.toBeInTheDocument();
});
it("maintains correct option numbers when only later answers win", async () => {
// Create a poll with 3 answers
const answerThree = {
id: "answerThreeId",
[M_TEXT.name]: "Toyota Supra MK4",
};
const pollStartEventThreeAnswers = makePollStartEvent(
"Question?",
userId,
[answerOne, answerTwo, answerThree],
{
roomId,
id: pollId,
ts: timestamp,
},
);
// Only answer 3 (index 2) wins with 2 votes, answers 1 and 2 (indices 0 and 1) get 0 or 1 votes
const responses = [
makePollResponseEvent(pollId, [answerOne.id], userId, roomId, timestamp + 1),
makePollResponseEvent(pollId, [answerThree.id], "@bob:domain.org", roomId, timestamp + 1),
makePollResponseEvent(pollId, [answerThree.id], "@charlie:domain.org", roomId, timestamp + 1),
];
await setupRoomWithPollEvents([pollStartEventThreeAnswers], responses, [pollEndEvent], mockClient, room);
const poll = room.polls.get(pollId)!;
const { getByText, queryByText, findByText, getByTestId } = getComponent({
event: pollStartEventThreeAnswers,
poll,
});
await expect(findByText("Final result based on 3 votes")).resolves.toBeInTheDocument();
// Only the third answer should be shown (it won)
expect(queryByText("Nissan Silvia S15")).not.toBeInTheDocument();
expect(queryByText("Mitsubishi Lancer Evolution IX")).not.toBeInTheDocument();
expect(getByText("Toyota Supra MK4")).toBeInTheDocument();
// The option number should be 3 (original index 2 + 1), not 1
// PollOption component receives optionNumber prop which is used in its aria-label
const pollOption = getByTestId("pollOption-answerThreeId");
expect(pollOption).toBeInTheDocument();
// The optionNumber is maintained correctly
const radioInput = pollOption.querySelector('input[type="radio"]');
expect(radioInput).toHaveAttribute("aria-label", expect.stringContaining("Option 3, Toyota Supra MK4"));
});
});

View File

@@ -4,6 +4,18 @@ exports[`<PollHistory /> Poll detail displays poll detail on active poll list it
<legend
data-testid="pollQuestion"
>
<svg
aria-label="Poll"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 10q.424 0 .712-.287A.97.97 0 0 0 17 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 16 8h-3a.97.97 0 0 0-.713.287A.97.97 0 0 0 12 9q0 .424.287.713.288.287.713.287zm0 6q.424 0 .712-.287A.97.97 0 0 0 17 15a.97.97 0 0 0-.288-.713A.97.97 0 0 0 16 14h-3a.97.97 0 0 0-.713.287A.97.97 0 0 0 12 15q0 .424.287.713.288.287.713.287zm-7-5q.825 0 1.412-.588Q11 9.826 11 9t-.588-1.412A1.93 1.93 0 0 0 9 7q-.825 0-1.412.588A1.93 1.93 0 0 0 7 9q0 .825.588 1.412Q8.175 11 9 11m0 6q.825 0 1.412-.587Q11 15.825 11 15t-.588-1.412A1.93 1.93 0 0 0 9 13q-.825 0-1.412.588A1.93 1.93 0 0 0 7 15q0 .824.588 1.413Q8.175 17 9 17m-4 4q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h14q.824 0 1.413.587Q21 4.176 21 5v14q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21zm0-2h14V5H5z"
/>
</svg>
Question?
</legend>
`;
@@ -12,6 +24,21 @@ exports[`<PollHistory /> Poll detail displays poll detail on past poll list item
<legend
data-testid="pollQuestion"
>
<svg
aria-label="Poll ended"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 10.659V19q0 .824-.587 1.413A1.93 1.93 0 0 1 19 21H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19V5q0-.824.587-1.412A1.93 1.93 0 0 1 5 3h8.341A6 6 0 0 0 13 5H5v14h14v-8a6 6 0 0 0 2-.341"
/>
<path
d="M13.803 8a6 6 0 0 0 1.88 2H13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 9q0-.424.287-.713A.97.97 0 0 1 13 8zm2.91 7.713A.97.97 0 0 1 16 16h-3a.97.97 0 0 1-.713-.287A.97.97 0 0 1 12 15q0-.424.287-.713A.97.97 0 0 1 13 14h3q.424 0 .712.287.288.288.288.713 0 .424-.288.713m-6.299-5.3A1.93 1.93 0 0 1 9 11q-.825 0-1.412-.588A1.93 1.93 0 0 1 7 9q0-.825.588-1.412A1.93 1.93 0 0 1 9 7q.825 0 1.412.588Q11 8.175 11 9t-.588 1.412m.001 6.001A1.93 1.93 0 0 1 9 17q-.825 0-1.412-.587A1.93 1.93 0 0 1 7 15q0-.825.588-1.412A1.93 1.93 0 0 1 9 13q.825 0 1.412.588Q11 14.175 11 15q0 .824-.588 1.413m12.295-14.12a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L18 5.586l3.293-3.293a1 1 0 0 1 1.414 0"
/>
</svg>
What?
</legend>
`;

View File

@@ -1,4 +1,5 @@
/*
Copyright (C) 2025 Element Creations Ltd
Copyright 2024, 2025 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
@@ -458,7 +459,10 @@ describe("RoomHeader", () => {
} as unknown as Call);
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
render(<RoomHeader room={room} />, getWrapper());
expect(screen.getByRole("button", { name: "Ongoing call" })).toHaveAttribute("aria-disabled", "true");
// Voice and video
for (const button of screen.getAllByRole("button", { name: "Ongoing call" })) {
expect(button).toHaveAttribute("aria-disabled", "true");
}
});
it("clicking on ongoing (unpinned) call re-pins it", async () => {
@@ -632,6 +636,41 @@ describe("RoomHeader", () => {
expect(getByLabelText(document.body, _t("voip|get_call_link"))).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for video", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Video call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
it("gives the option of element call or legacy calling for voice in DM rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === ElementCallMemberEventType.name) return true;
return false;
});
render(<RoomHeader room={room} />, getWrapper());
const button = screen.getByRole("button", { name: "Voice call" });
expect(button).not.toHaveAttribute("aria-disabled", "true");
await user.click(button);
const elementCallButton = screen.getByRole("menuitem", { name: "Element Call" });
const legacyCallButton = screen.getByRole("menuitem", { name: "Legacy Call" });
expect(elementCallButton).toBeInTheDocument();
expect(legacyCallButton).toBeInTheDocument();
});
});
describe("public room", () => {

View File

@@ -56,7 +56,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_14k_"
aria-labelledby="_r_17e_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -72,7 +72,6 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<button
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="_r_14p_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -84,6 +83,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_17j_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -98,7 +98,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_14u_"
aria-labelledby="_r_17o_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@@ -125,7 +125,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_153_"
aria-labelledby="_r_17t_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"

View File

@@ -44,10 +44,26 @@ describe("ElementWebBuiltinsApi", () => {
expect(container).toHaveTextContent("50");
});
it("returns rendered NotificationDecoration component", () => {
stubClient();
const builtinsApi = new ElementWebBuiltinsApi();
const NotificationDecoration = () => <div>notification decoration</div>;
builtinsApi.setComponents({
roomView: {},
roomAvatar: Avatar,
notificationDecoration: NotificationDecoration,
} as any);
const { container } = render(<> {builtinsApi.renderNotificationDecoration("!foo:m.org")}</>);
expect(container).toHaveTextContent("notification decoration");
});
it("should throw error if called before components are set", () => {
stubClient();
const builtinsApi = new ElementWebBuiltinsApi();
expect(() => builtinsApi.renderRoomAvatar("!foo:m.org")).toThrow("No RoomAvatar component has been set");
expect(() => builtinsApi.renderRoomView("!foo:m.org")).toThrow("No RoomView component has been set");
expect(() => builtinsApi.renderNotificationDecoration("!foo:m.org")).toThrow(
"No NotificationDecoration component has been set",
);
});
});

View File

@@ -0,0 +1,33 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { ModuleNotificationDecoration } from "../../../../src/modules/components/ModuleNotificationDecoration";
import { mkStubRoom, stubClient } from "../../../test-utils";
import { NotificationLevel } from "../../../../src/stores/notifications/NotificationLevel";
import { RoomNotificationStateStore } from "../../../../src/stores/notifications/RoomNotificationStateStore";
import { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
class MockedNotificationState extends RoomNotificationState {
public constructor(room: Room, level: NotificationLevel, count: number) {
super(room, false);
this._level = level;
this._count = count;
}
}
it("Should be able to render component just with room as prop", () => {
const cli = stubClient();
const room = mkStubRoom("!foo:matrix.org", "Foo Room", cli);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(
new MockedNotificationState(room, NotificationLevel.Notification, 5),
);
render(<ModuleNotificationDecoration room={room} />);
expect(screen.getByTestId("notification-decoration")).toBeInTheDocument();
});

View File

@@ -1559,10 +1559,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.16.1.tgz#28bdbde426051cc2a3228a36e7196e0a254569d3"
integrity sha512-g3v/QFuNy8YVRGrKC5SxjIYvgBh6biOHgejhJT2Jk/yjOOUEuP0y2PBaADm+suPD9BB/Vk1jPxFk2uEIpEzhpA==
"@element-hq/element-web-module-api@1.7.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.7.0.tgz#7657df25cc1e7075718af2c6ea8a4ebfaa9cfb2c"
integrity sha512-WhiJTmdETK8vvaYExqyhQ9rtLjxBv9PprWr6dCa1/1VRFSkfFZRlzy2P08nHX2YXpRMTpXb39SLeleR1dgLzow==
"@element-hq/element-web-module-api@1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.8.0.tgz#95aa4ec22609cf0f4a7f24274473af0645a16f2a"
integrity sha512-lMiDA9ubP3mZZupIMT8T3wS0riX30rYZj3pFpdP4cfZhkWZa3FJFStokAy5OnaHyENC7Px1cqkBGqilOWewY/A==
"@element-hq/element-web-playwright-common@^2.0.0":
version "2.0.0"
@@ -4151,7 +4151,7 @@
classnames "^2.5.1"
vaul "^1.0.0"
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
"@vector-im/matrix-wysiwyg-wasm@link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
uid ""