diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx
index 8d750e4f..18bcf080 100644
--- a/src/button/ReactionToggleButton.test.tsx
+++ b/src/button/ReactionToggleButton.test.tsx
@@ -103,7 +103,7 @@ test("Can can lower hand", () => {
const { getByRole, getByText, container } = render(
,
);
- const reactionEvent = room.testSendReaction(memberEventAlice, membership);
+ const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
act(() => {
getByRole("button").click();
});
@@ -185,7 +185,7 @@ test("Can search for and send emoji with the keyboard", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
- const { getByText, getByRole, getByPlaceholderText, container } = render(
+ const { getByRole, getByPlaceholderText, container } = render(
,
);
act(() => {
diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx
index fbfe1d36..f61b7289 100644
--- a/src/button/ReactionToggleButton.tsx
+++ b/src/button/ReactionToggleButton.tsx
@@ -21,12 +21,9 @@ import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
- FormEventHandler,
- KeyboardEvent,
KeyboardEventHandler,
ReactNode,
useCallback,
- useEffect,
useMemo,
useState,
} from "react";
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index fb64cb88..9c9c8383 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -236,7 +236,7 @@ export const InCallView: FC = ({
containerRef1,
toggleMicrophone,
toggleCamera,
- (muted) => muteStates.audio.setEnabled?.(!muteprefered),
+ (muted) => muteStates.audio.setEnabled?.(!muted),
);
const mobile = boundsValid && bounds.width <= 660;
diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx
new file mode 100644
index 00000000..6be08a5b
--- /dev/null
+++ b/src/room/ReactionAudioRenderer.test.tsx
@@ -0,0 +1,101 @@
+/*
+Copyright 2024 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only
+Please see LICENSE in the repository root for full details.
+*/
+
+import { act, render } from "@testing-library/react";
+import { expect, test } from "vitest";
+import { TooltipProvider } from "@vector-im/compound-web";
+
+import {
+ MockRoom,
+ MockRTCSession,
+ TestReactionsWrapper,
+} from "../utils/testReactions";
+import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
+import { ReactionSet } from "../reactions";
+
+const memberUserIdAlice = "@alice:example.org";
+const memberUserIdBob = "@bob:example.org";
+const memberUserIdCharlie = "@charlie:example.org";
+const memberEventAlice = "$membership-alice:example.org";
+const memberEventBob = "$membership-bob:example.org";
+const memberEventCharlie = "$membership-charlie:example.org";
+
+const membership: Record = {
+ [memberEventAlice]: memberUserIdAlice,
+ [memberEventBob]: memberUserIdBob,
+ [memberEventCharlie]: memberUserIdCharlie,
+};
+
+function TestComponent({ rtcSession }: { rtcSession: MockRTCSession }) {
+ return (
+
+
+
+
+
+ );
+}
+
+test("defaults to no audio elements", async () => {
+ const rtcSession = new MockRTCSession(
+ new MockRoom(memberUserIdAlice),
+ membership,
+ );
+ const { container } = render();
+ expect(container.getElementsByTagName("audio")).toHaveLength(0);
+});
+
+test("will play an audio sound when there is a reaction", async () => {
+ const room = new MockRoom(memberUserIdAlice);
+ const rtcSession = new MockRTCSession(room, membership);
+ const { container } = render();
+
+ // Find the first reaction with a sound effect
+ const chosenReaction = ReactionSet.find((r) => !!r.sound);
+ if (!chosenReaction) {
+ throw Error(
+ "No reactions have sounds configured, this test cannot succeed",
+ );
+ }
+ act(() =>
+ room.testSendReaction(memberEventAlice, chosenReaction, membership),
+ );
+ const elements = container.getElementsByTagName("audio");
+ expect(elements).toHaveLength(1);
+ const audioElement = elements[0];
+
+ expect(audioElement.autoplay).toBe(true);
+
+ const sources = audioElement.getElementsByTagName("source");
+ expect(sources).toHaveLength(2);
+
+ // The element will be the full URL, whereas the chosenReaction will have the path.
+ expect(sources[0].src).toContain(chosenReaction.sound?.ogg);
+ expect(sources[1].src).toContain(chosenReaction.sound?.mp3);
+});
+
+test("will play multiple audio sounds when there are multiple different reactions", async () => {
+ const room = new MockRoom(memberUserIdAlice);
+ const rtcSession = new MockRTCSession(room, membership);
+ const { container } = render();
+
+ // Find the first reaction with a sound effect
+ const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
+ if (!reaction1 || !reaction2) {
+ throw Error(
+ "No reactions have sounds configured, this test cannot succeed",
+ );
+ }
+ act(() => {
+ room.testSendReaction(memberEventAlice, reaction1, membership);
+ room.testSendReaction(memberEventBob, reaction2, membership);
+ room.testSendReaction(memberEventCharlie, reaction1, membership);
+ });
+ const elements = container.getElementsByTagName("audio");
+ // Do not play the same reaction twice.
+ expect(elements).toHaveLength(2);
+});
diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx
index 04f4bf8b..e362b606 100644
--- a/src/room/ReactionAudioRenderer.tsx
+++ b/src/room/ReactionAudioRenderer.tsx
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
-import { ReactNode } from "react";
+import { ReactNode, useMemo } from "react";
import { useReactions } from "../useReactions";
import { playReactionsSound, useSetting } from "../settings/settings";
@@ -14,9 +14,20 @@ export function ReactionsAudioRenderer(): ReactNode {
const { reactions } = useReactions();
const [shouldPlay] = useSetting(playReactionsSound);
- const expectedReactions = shouldPlay
- ? [...new Set([...Object.values(reactions)])]
- : [];
+ const expectedReactions = useMemo(() => {
+ if (!shouldPlay) {
+ return [];
+ }
+ const reactionsToPlayNames = new Set();
+ return Object.values(reactions).filter((r) => {
+ if (reactionsToPlayNames.has(r.name)) {
+ return false;
+ }
+ reactionsToPlayNames.add(r.name);
+ return true;
+ });
+ }, [shouldPlay, reactions]);
+
return (
<>
{expectedReactions.map(
diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx
index f9b323fd..6e5d06de 100644
--- a/src/useReactions.test.tsx
+++ b/src/useReactions.test.tsx
@@ -12,7 +12,7 @@ import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { useReactions } from "./useReactions";
import {
- createReaction,
+ createHandRaisedReaction,
createRedaction,
MockRoom,
MockRTCSession,
@@ -75,7 +75,7 @@ describe("useReactions", () => {
,
);
- await act(() => room.testSendReaction(memberEventAlice, membership));
+ await act(() => room.testSendHandRaise(memberEventAlice, membership));
expect(queryByText("Local reaction")).toBeTruthy();
});
test("handles incoming raised hand", async () => {
@@ -86,9 +86,9 @@ describe("useReactions", () => {
,
);
- await act(() => room.testSendReaction(memberEventAlice, membership));
+ await act(() => room.testSendHandRaise(memberEventAlice, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(1);
- await act(() => room.testSendReaction(memberEventBob, membership));
+ await act(() => room.testSendHandRaise(memberEventBob, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
test("handles incoming unraised hand", async () => {
@@ -100,7 +100,7 @@ describe("useReactions", () => {
,
);
const reactionEventId = await act(() =>
- room.testSendReaction(memberEventAlice, membership),
+ room.testSendHandRaise(memberEventAlice, membership),
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
await act(() =>
@@ -115,7 +115,7 @@ describe("useReactions", () => {
});
test("handles loading prior raised hand events", () => {
const room = new MockRoom(memberUserIdAlice, [
- createReaction(memberEventAlice, membership),
+ createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
@@ -129,7 +129,7 @@ describe("useReactions", () => {
// the raised hand event.
test("will remove reaction when a member leaves the call", () => {
const room = new MockRoom(memberUserIdAlice, [
- createReaction(memberEventAlice, membership),
+ createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
@@ -143,7 +143,7 @@ describe("useReactions", () => {
});
test("will remove reaction when a member joins via a new event", () => {
const room = new MockRoom(memberUserIdAlice, [
- createReaction(memberEventAlice, membership),
+ createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
@@ -161,7 +161,7 @@ describe("useReactions", () => {
});
test("ignores invalid sender for historic event", () => {
const room = new MockRoom(memberUserIdAlice, [
- createReaction(memberEventAlice, memberUserIdBob),
+ createHandRaisedReaction(memberEventAlice, memberUserIdBob),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
@@ -179,7 +179,7 @@ describe("useReactions", () => {
,
);
- await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob));
+ await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob));
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
});
diff --git a/src/utils/testReactions.tsx b/src/utils/testReactions.tsx
index 8efbce41..99557e8a 100644
--- a/src/utils/testReactions.tsx
+++ b/src/utils/testReactions.tsx
@@ -9,7 +9,7 @@ import { PropsWithChildren, ReactNode } from "react";
import { randomUUID } from "crypto";
import EventEmitter from "events";
import { MatrixClient } from "matrix-js-sdk/src/client";
-import { EventType, RoomEvent } from "matrix-js-sdk/src/matrix";
+import { EventType, RoomEvent, RelationType } from "matrix-js-sdk/src/matrix";
import {
MatrixEvent,
EventTimeline,
@@ -22,6 +22,11 @@ import {
} from "matrix-js-sdk/src/matrixrtc";
import { ReactionsProvider } from "../useReactions";
+import {
+ ECallReactionEventContent,
+ ElementCallReactionEventType,
+ ReactionOption,
+} from "../reactions";
export const TestReactionsWrapper = ({
rtcSession,
@@ -70,7 +75,7 @@ export class MockRTCSession extends EventEmitter {
}
}
-export function createReaction(
+export function createHandRaisedReaction(
parentMemberEvent: string,
membershipOrOverridenSender: Record | string,
): MatrixEvent {
@@ -149,19 +154,43 @@ export class MockRoom extends EventEmitter {
} as unknown as Room["relations"];
}
- public testSendReaction(
- parentMemberEvent: string,
- overridenSender: string,
- ): string;
- public testSendReaction(
- parentMemberEvent: string,
- membershipOrOverridenSender: Record,
- ): string;
- public testSendReaction(
+ public testSendHandRaise(
parentMemberEvent: string,
membershipOrOverridenSender: Record | string,
): string {
- const evt = createReaction(parentMemberEvent, membershipOrOverridenSender);
+ const evt = createHandRaisedReaction(
+ parentMemberEvent,
+ membershipOrOverridenSender,
+ );
+ this.emit(RoomEvent.Timeline, evt, this, undefined, false, {
+ timeline: new EventTimeline(new EventTimelineSet(undefined)),
+ });
+ return evt.getId()!;
+ }
+
+ public testSendReaction(
+ parentMemberEvent: string,
+ reaction: ReactionOption,
+ membershipOrOverridenSender: Record | string,
+ ): string {
+ const evt = new MatrixEvent({
+ sender:
+ typeof membershipOrOverridenSender === "string"
+ ? membershipOrOverridenSender
+ : membershipOrOverridenSender[parentMemberEvent],
+ type: ElementCallReactionEventType,
+ origin_server_ts: new Date().getTime(),
+ content: {
+ "m.relates_to": {
+ rel_type: RelationType.Reference,
+ event_id: parentMemberEvent,
+ },
+ emoji: reaction.emoji,
+ name: reaction.name,
+ } satisfies ECallReactionEventContent,
+ event_id: randomUUID(),
+ });
+
this.emit(RoomEvent.Timeline, evt, this, undefined, false, {
timeline: new EventTimeline(new EventTimelineSet(undefined)),
});