Compare commits

...

28 Commits

Author SHA1 Message Date
Half-Shot
e1a4310e29 Add preferences tab for choosing to enable timer. 2024-10-29 13:13:01 +00:00
Half-Shot
dbabf45ca7 Allow timer to be configurable. 2024-10-29 13:12:20 +00:00
Half-Shot
f13bd7923b Fix and update tests 2024-10-29 09:45:30 +00:00
Half-Shot
23d849bd9a lint 2024-10-29 09:35:10 +00:00
Half-Shot
b7e82362b8 Fix call border resizing video 2024-10-29 09:28:42 +00:00
Half-Shot
07d345191b Fix timestamp calculation on relaod. 2024-10-28 22:24:20 +00:00
Half-Shot
198859db08 Add a small mode for spotlight 2024-10-28 22:24:11 +00:00
Half-Shot
33724efee3 Add better sound. (woosh) 2024-10-28 18:00:39 +00:00
Half-Shot
38878d3ee8 Remove extra layer 2024-10-28 17:45:12 +00:00
Half-Shot
9d01e8c129 Lint tests 2024-10-28 17:44:17 +00:00
Half-Shot
ba921f8c67 Tidy up useEffect 2024-10-28 16:23:17 +00:00
Half-Shot
4a712dcaa2 Redact the right thing. 2024-10-28 16:11:28 +00:00
Half-Shot
4501e670b2 Refactor into own files. 2024-10-28 15:57:55 +00:00
Half-Shot
43b4fc0a0c lint 2024-10-28 12:46:26 +00:00
Half-Shot
a23d256fb6 Refactor raised hand indicator and add tests. 2024-10-28 12:32:33 +00:00
Half-Shot
7f268a3e10 Add a sound for when a hand is raised. 2024-10-25 17:10:14 +01:00
Half-Shot
1c8e547b9a Add support for displaying the duration of a raised hand. 2024-10-25 17:09:45 +01:00
Half-Shot
16afb568fd Center align hand raise. 2024-10-25 11:37:40 +01:00
Milton Moura
42a7b1ec2a Fix failing GridTile test
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:59:06 +01:00
Milton Moura
69a50fb2a8 Check for reaction & redaction capabilities in widget mode
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:40:46 +01:00
Milton Moura
7ac5642245 Removing RaiseHand.svg 2024-09-19 11:39:52 +01:00
Milton Moura
ab5654cf32 Links to sha commit of matrix-js-sdk that exposes the call membership event id and refactors some async code
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:39:52 +01:00
Milton Moura
0730ba5c70 Use relations to load existing reactions when joining the call
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:36:11 +01:00
fkwp
bcad5003e7 Update src/room/useRaisedHands.tsx
Element Call recently changed to AGPL-3.0
2024-09-19 11:36:11 +01:00
Milton Moura
ac7321d1e6 SpotlightTile should not duplicate the raised hand
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:36:11 +01:00
Milton Moura
f6ae6a0765 Replacing button svg with raised hand emoji
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
Milton Moura
2d1917c22a Refactored to use reaction and redaction events
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
Milton Moura
48cf487e0a Initial support for Hand Raise feature
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
23 changed files with 900 additions and 20 deletions

View File

@@ -53,7 +53,9 @@
"next": "Next",
"options": "Options",
"password": "Password",
"preferences": "Preferences",
"profile": "Profile",
"raise_hand": "Raise hand",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",
@@ -144,6 +146,10 @@
"feedback_tab_title": "Feedback",
"more_tab_title": "More",
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"preferences_tab_body": "Here you can configure extra options for an improved experience",
"preferences_tab_h4": "Preferences",
"preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
"preferences_tab_show_hand_raised_timer_label": "Show hand raise duration",
"speaker_device_selection_label": "Speaker"
},
"star_rating_input_label_one": "{{count}} stars",

View File

@@ -25,6 +25,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { WidgetApi } from "matrix-widget-api";
import { ErrorView } from "./FullScreenView";
import { fallbackICEServerAllowed, initClient } from "./utils/matrix";
@@ -52,6 +53,9 @@ export type ValidClientState = {
// 'Disconnected' rather than 'connected' because it tracks specifically
// whether the client is supposed to be connected but is not
disconnected: boolean;
supportedFeatures: {
reactions: boolean;
};
setClient: (params?: SetClientParams) => void;
};
@@ -188,11 +192,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
saveSession({ ...session, passwordlessUser: false });
setInitClientState({
client: initClientState.client,
...initClientState,
passwordlessUser: false,
});
},
[initClientState?.client],
[initClientState],
);
const setClient = useCallback(
@@ -206,6 +210,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
if (clientParams) {
saveSession(clientParams.session);
setInitClientState({
widgetApi: null,
client: clientParams.client,
passwordlessUser: clientParams.session.passwordlessUser,
});
@@ -254,6 +259,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
);
const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false);
const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) {
@@ -277,6 +283,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
};
}, [
alreadyOpenedErr,
@@ -285,6 +294,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
logout,
setClient,
isDisconnected,
supportsReactions,
]);
const onSync = useCallback(
@@ -309,6 +319,30 @@ export const ClientProvider: FC<Props> = ({ children }) => {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (initClientState.widgetApi) {
const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction",
);
const redactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.room.redaction",
);
const reactRcv = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.receive.event:m.reaction",
);
const redactRcv = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.receive.event:m.room.redaction",
);
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
logger.warn("Widget does not support reactions");
setSupportsReactions(false);
} else {
setSupportsReactions(true);
}
} else {
setSupportsReactions(true);
}
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
@@ -326,6 +360,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
};
type InitResult = {
widgetApi: WidgetApi | null;
client: MatrixClient;
passwordlessUser: boolean;
};
@@ -336,6 +371,7 @@ async function loadClient(): Promise<InitResult | null> {
logger.log("Using a matryoshka client");
const client = await widget.client;
return {
widgetApi: widget.api,
client,
passwordlessUser: false,
};
@@ -364,6 +400,7 @@ async function loadClient(): Promise<InitResult | null> {
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};

View File

@@ -12,7 +12,7 @@ Please see LICENSE in the repository root for full details.
.dialog {
box-sizing: border-box;
inline-size: 520px;
inline-size: 580px;
max-inline-size: 90%;
max-block-size: 600px;
}

View File

@@ -0,0 +1,143 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
ComponentPropsWithoutRef,
FC,
ReactNode,
useCallback,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
interface InnerButtonButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
}
const InnerButton: FC<InnerButtonButtonProps> = ({ raised, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.raise_hand")}>
<CpdButton
kind={raised ? "primary" : "secondary"}
{...props}
style={{ paddingLeft: 8, paddingRight: 8 }}
>
<p
role="img"
aria-label="raised hand"
style={{
width: "30px",
height: "0px",
display: "inline-block",
fontSize: "22px",
}}
>
</p>
</CpdButton>
</Tooltip>
);
};
interface RaisedHandToggleButton {
key: string;
rtcSession: MatrixRTCSession;
client: MatrixClient;
}
export function RaiseHandToggleButton({
key,
client,
rtcSession,
}: RaisedHandToggleButton): ReactNode {
const {
raisedHands,
removeRaisedHand,
addRaisedHand,
myReactionId,
setMyReactionId,
} = useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const toggleRaisedHand = useCallback(() => {
if (isHandRaised) {
if (myReactionId) {
setBusy(true);
client
.redactEvent(rtcSession.room.roomId, myReactionId)
.then(() => {
logger.debug("Redacted raise hand event");
setMyReactionId(null);
removeRaisedHand(userId);
})
.catch((e) => {
logger.error("Failed to redact reaction event", e);
})
.finally(() => {
setBusy(false);
});
}
} else {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
logger.error("Cannot find own membership event");
return;
}
setBusy(true);
client
.sendEvent(rtcSession.room.roomId, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembership.eventId,
key: "🖐️",
},
})
.then((reaction) => {
logger.debug("Sent raise hand event", reaction.event_id);
setMyReactionId(reaction.event_id);
addRaisedHand(userId, new Date());
})
.catch((e) => {
logger.error("Failed to send reaction event", e);
})
.finally(() => {
setBusy(false);
});
}
}, [
client,
isHandRaised,
memberships,
myReactionId,
rtcSession.room.roomId,
addRaisedHand,
removeRaisedHand,
setMyReactionId,
userId,
]);
return (
<InnerButton
key={key}
disabled={busy}
onClick={toggleRaisedHand}
raised={isHandRaised}
/>
);
}

View File

@@ -7,3 +7,4 @@ Please see LICENSE in the repository root for full details.
export * from "./Button";
export * from "./LinkButton";
export * from "./RaisedHandToggleButton";

View File

@@ -0,0 +1,53 @@
.raisedHandWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
border: 1px solid var(--cpd-color-yellow-1200);
}
.raisedHandWidget > p {
padding: none;
margin-top: auto;
margin-bottom: auto;
width: 4em;
}
.raisedHandWidgetLarge > p {
padding: var(--cpd-space-2x);
}
.raisedHandLarge {
margin: var(--cpd-space-2x);
padding: var(--cpd-space-2x);
padding-block: var(--cpd-space-2x);
}
.raisedHand {
margin: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
background-color: var(--cpd-color-icon-secondary);
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
max-width: fit-content;
}
.raisedHand > span {
width: var(--cpd-space-6x);
height: var(--cpd-space-6x);
display: inline-block;
text-align: center;
font-size: 16px;
}
.raisedHandLarge > span {
width: var(--cpd-space-8x);
height: var(--cpd-space-8x);
font-size: 22px;
}

View File

@@ -0,0 +1,43 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { render, configure } from "@testing-library/react";
import { RaisedHandIndicator } from "./RaisedHandIndicator";
configure({
defaultHidden: true,
});
describe("RaisedHandIndicator", () => {
test("renders nothing when no hand has been raised", () => {
const { container } = render(<RaisedHandIndicator />);
expect(container.firstChild).toBeNull();
});
test("renders an indicator when a hand has been raised", () => {
const dateTime = new Date();
const { container } = render(
<RaisedHandIndicator raisedHandTime={dateTime} showTimer />,
);
expect(container.firstChild).toMatchSnapshot();
});
test("renders an indicator when a hand has been raised with the expected time", () => {
const dateTime = new Date(new Date().getTime() - 60000);
const { container } = render(
<RaisedHandIndicator raisedHandTime={dateTime} showTimer />,
);
expect(container.firstChild).toMatchSnapshot();
});
test("renders a smaller indicator when minature is specified", () => {
const dateTime = new Date();
const { container } = render(
<RaisedHandIndicator raisedHandTime={dateTime} minature showTimer />,
);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,65 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ReactNode, useEffect, useState } from "react";
import classNames from "classnames";
import styles from "./RaisedHandIndicator.module.css";
export function RaisedHandIndicator({
raisedHandTime,
minature,
showTimer,
}: {
raisedHandTime?: Date;
minature?: boolean;
showTimer?: boolean;
}): ReactNode {
const [raisedHandDuration, setRaisedHandDuration] = useState("");
useEffect(() => {
if (!raisedHandTime || !showTimer) {
return;
}
const calculateTime = (): void => {
const totalSeconds = Math.ceil(
(new Date().getTime() - raisedHandTime.getTime()) / 1000,
);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60);
setRaisedHandDuration(
`${minutes < 10 ? "0" : ""}${minutes}:${seconds < 10 ? "0" : ""}${seconds}`,
);
};
calculateTime();
const to = setInterval(calculateTime, 1000);
return (): void => clearInterval(to);
}, [setRaisedHandDuration, raisedHandTime, showTimer]);
if (raisedHandTime) {
return (
<div
className={classNames(styles.raisedHandWidget, {
[styles.raisedHandWidgetLarge]: !minature,
})}
>
<div
className={classNames(styles.raisedHand, {
[styles.raisedHandLarge]: !minature,
})}
>
<span role="img" aria-label="raised hand">
</span>
</div>
{showTimer && <p>{raisedHandDuration}</p>}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,61 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`RaisedHandIndicator > renders a smaller indicator when minature is specified 1`] = `
<div
class="raisedHandWidget"
>
<div
class="raisedHand"
>
<span
aria-label="raised hand"
role="img"
>
</span>
</div>
<p>
00:01
</p>
</div>
`;
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = `
<div
class="raisedHandWidget raisedHandWidgetLarge"
>
<div
class="raisedHand raisedHandLarge"
>
<span
aria-label="raised hand"
role="img"
>
</span>
</div>
<p>
00:01
</p>
</div>
`;
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = `
<div
class="raisedHandWidget raisedHandWidgetLarge"
>
<div
class="raisedHand raisedHandLarge"
>
<span
aria-label="raised hand"
role="img"
>
</span>
</div>
<p>
01:01
</p>
</div>
`;

View File

@@ -40,6 +40,7 @@ import {
VideoButton,
ShareScreenButton,
SettingsButton,
RaiseHandToggleButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useUrlParams } from "../UrlParams";
@@ -78,6 +79,9 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
import { ReactionsProvider, useReactions } from "../useReactions";
import handSoundOgg from "../sound/raise_hand.ogg?url";
import handSoundMp3 from "../sound/raise_hand.mp3?url";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -130,12 +134,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (
<RoomContext.Provider value={livekitRoom}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
<ReactionsProvider rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
</ReactionsProvider>
</RoomContext.Provider>
);
};
@@ -168,6 +174,10 @@ export const InCallView: FC<InCallViewProps> = ({
connState,
onShareClick,
}) => {
const { supportsReactions, raisedHandCount } = useReactions();
const [previousRaisedHandCount, setPreviousRaisedHandCount] =
useState(raisedHandCount);
useWakeLock();
useEffect(() => {
@@ -298,6 +308,20 @@ export const InCallView: FC<InCallViewProps> = ({
[vm],
);
// Play a sound when the raised hand count increases.
const handRaisePlayer = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (!handRaisePlayer.current) {
return;
}
if (previousRaisedHandCount < raisedHandCount) {
handRaisePlayer.current.play().catch((ex) => {
logger.warn("Failed to play raise hand sound", ex);
});
}
setPreviousRaisedHandCount(raisedHandCount);
}, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]);
useEffect(() => {
widget?.api.transport
.send(
@@ -513,7 +537,16 @@ export const InCallView: FC<InCallViewProps> = ({
/>,
);
}
buttons.push(<SettingsButton key="4" onClick={openSettings} />);
if (supportsReactions) {
buttons.push(
<RaiseHandToggleButton
client={client}
rtcSession={rtcSession}
key="4"
/>,
);
}
buttons.push(<SettingsButton key="5" onClick={openSettings} />);
}
buttons.push(
@@ -594,6 +627,10 @@ export const InCallView: FC<InCallViewProps> = ({
))}
<RoomAudioRenderer />
{renderContent()}
<audio ref={handRaisePlayer} hidden>
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={handSoundMp3} type="audio/mpeg" />
</audio>
{footer}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal

View File

@@ -0,0 +1,53 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ChangeEvent, FC, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@vector-im/compound-web";
import { FieldRow, InputField } from "../input/Input";
import {
showHandRaisedTimer as showHandRaisedTimerSetting,
useSetting,
} from "./settings";
interface Props {
roomId?: string;
}
export const PreferencesSettingsTab: FC<Props> = ({}) => {
const { t } = useTranslation();
const [showHandRaisedTimer, setShowHandRaisedTimer] = useSetting(
showHandRaisedTimerSetting,
);
const onChangeSetting = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setShowHandRaisedTimer(e.target.checked);
},
[setShowHandRaisedTimer],
);
return (
<div>
<h4>{t("settings.preferences_tab_h4")}</h4>
<Text>{t("settings.preferences_tab_body")}</Text>
<FieldRow>
<InputField
id="showHandRaisedTimer"
label={t("settings.preferences_tab_show_hand_raised_timer_label")}
description={t(
"settings.preferences_tab_show_hand_raised_timer_description",
)}
type="checkbox"
checked={showHandRaisedTimer}
onChange={onChangeSetting}
/>
</FieldRow>
</div>
);
};

View File

@@ -30,11 +30,13 @@ import {
useOptInAnalytics,
} from "./settings";
import { isFirefox } from "../Platform";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
type SettingsTab =
| "audio"
| "video"
| "profile"
| "preferences"
| "feedback"
| "more"
| "developer";
@@ -135,6 +137,12 @@ export const SettingsModal: FC<Props> = ({
content: generateDeviceSelection(devices.videoInput, t("common.camera")),
};
const preferencesTab: Tab<SettingsTab> = {
key: "preferences",
name: t("common.preferences"),
content: <PreferencesSettingsTab />,
};
const profileTab: Tab<SettingsTab> = {
key: "profile",
name: t("common.profile"),
@@ -234,7 +242,7 @@ export const SettingsModal: FC<Props> = ({
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
tabs.push(feedbackTab, moreTab);
tabs.push(preferencesTab, feedbackTab, moreTab);
if (developerSettingsTab) tabs.push(developerTab);
return (

View File

@@ -85,4 +85,9 @@ export const videoInput = new Setting<string | undefined>(
undefined,
);
export const showHandRaisedTimer = new Setting<boolean>(
"hand-raised-show-timer",
false,
);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);

BIN
src/sound/raise_hand.mp3 Normal file

Binary file not shown.

BIN
src/sound/raise_hand.ogg Normal file

Binary file not shown.

View File

@@ -9,9 +9,11 @@ import { RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { GridTile } from "./GridTile";
import { withRemoteMedia } from "../utils/test";
import { ReactionsProvider } from "../useReactions";
test("GridTile is accessible", async () => {
await withRemoteMedia(
@@ -25,15 +27,26 @@ test("GridTile is accessible", async () => {
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
},
async (vm) => {
const fakeRtcSession = {
on: () => {},
off: () => {},
room: {
on: () => {},
off: () => {},
},
memberships: [],
} as unknown as MatrixRTCSession;
const { container } = render(
<GridTile
vm={vm}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}
showVideo
showSpeakingIndicators
/>,
<ReactionsProvider rtcSession={fakeRtcSession}>
<GridTile
vm={vm}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}
showVideo
showSpeakingIndicators
/>
</ReactionsProvider>,
);
expect(await axe(container)).toHaveNoViolations();
// Name should be visible

View File

@@ -44,6 +44,7 @@ import {
import { Slider } from "../Slider";
import { MediaView } from "./MediaView";
import { useLatest } from "../useLatest";
import { useReactions } from "../useReactions";
interface TileProps {
className?: string;
@@ -90,6 +91,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
},
[vm],
);
const { raisedHands } = useReactions();
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
@@ -144,6 +146,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
{menu}
</Menu>
}
raisedHandTime={raisedHands[vm.member?.userId ?? ""]}
{...props}
/>
);

View File

@@ -90,6 +90,15 @@ unconditionally select the container so we can use cqmin units */
place-items: start;
}
.raisedHandBorder {
border: var(--cpd-space-1x) solid var(--cpd-color-yellow-1200);
}
/* Offset by the size of the border to prevent resizing */
.media:not(.raisedHandBorder) {
margin: var(--cpd-space-1x);
}
.nameTag {
grid-area: nameTag;
padding: var(--cpd-space-1x);

View File

@@ -17,6 +17,8 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./MediaView.module.css";
import { Avatar } from "../Avatar";
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
import { showHandRaisedTimer, useSetting } from "../settings/settings";
interface Props extends ComponentProps<typeof animated.div> {
className?: string;
@@ -32,6 +34,7 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTagLeadingIcon?: ReactNode;
displayName: string;
primaryButton?: ReactNode;
raisedHandTime?: Date;
}
export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -50,17 +53,22 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
nameTagLeadingIcon,
displayName,
primaryButton,
raisedHandTime,
...props
},
ref,
) => {
const { t } = useTranslation();
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
return (
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
[styles.videoMuted]: !videoEnabled,
[styles.raisedHandBorder]: !!raisedHandTime,
})}
style={style}
ref={ref}
@@ -72,7 +80,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
size={avatarSize}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
@@ -86,6 +94,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)}
</div>
<div className={styles.fg}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
minature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
/>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>

View File

@@ -53,6 +53,7 @@ interface SpotlightItemBaseProps {
unencryptedWarning: boolean;
displayName: string;
"aria-hidden"?: boolean;
raisedHand: boolean;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -157,6 +158,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
unencryptedWarning,
displayName,
"aria-hidden": ariaHidden,
raisedHand: false,
};
return vm instanceof ScreenShareViewModel ? (

162
src/useReactions.test.tsx Normal file
View File

@@ -0,0 +1,162 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { FC, ReactNode } from "react";
import { describe, expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import {
EventTimeline,
EventTimelineSet,
EventType,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
} from "matrix-js-sdk/src/matrix";
import EventEmitter from "events";
import { randomUUID } from "crypto";
import { ReactionsProvider, useReactions } from "./useReactions";
const membership = [
"@alice:example.org",
"@bob:example.org",
"@charlie:example.org",
];
const TestComponent: FC = () => {
const { raisedHands } = useReactions();
return (
<ul>
{Object.entries(raisedHands).map(([userId, date]) => (
<li key={userId}>
<span>{userId}</span>
<time>{date.getTime()}</time>
</li>
))}
</ul>
);
};
const TestComponentWrapper = ({ room }: { room: MockRoom }): ReactNode => {
const fakeRtcSession = {
on: () => {},
off: () => {},
room,
memberships: membership.map((sender) => ({
sender,
eventId: "!fake:event",
createdTs: (): Date => new Date(),
})),
} as unknown as MatrixRTCSession;
return (
<ReactionsProvider rtcSession={fakeRtcSession}>
<TestComponent />
</ReactionsProvider>
);
};
function createReaction(sender: string): MatrixEvent {
return new MatrixEvent({
sender,
type: EventType.Reaction,
origin_server_ts: new Date().getTime(),
content: {
"m.relates_to": {
key: "🖐️",
},
},
event_id: randomUUID(),
});
}
function createRedaction(sender: string): MatrixEvent {
return new MatrixEvent({
sender,
type: EventType.RoomRedaction,
origin_server_ts: new Date().getTime(),
content: {},
event_id: randomUUID(),
});
}
export class MockRoom extends EventEmitter {
public constructor(private readonly existingRelations: MatrixEvent[] = []) {
super();
}
public get client(): MatrixClient {
return {
getUserId: (): string => "@alice:example.org",
} as unknown as MatrixClient;
}
public get relations(): Room["relations"] {
return {
getChildEventsForEvent: () => ({
getRelations: () => this.existingRelations,
}),
} as unknown as Room["relations"];
}
public testSendReaction(sender: string): void {
this.emit(
RoomEvent.Timeline,
createReaction(sender),
this,
undefined,
false,
{
timeline: new EventTimeline(new EventTimelineSet(undefined)),
},
);
}
}
describe("useReactions", () => {
test("starts with an empty list", () => {
const room = new MockRoom();
const { queryByRole } = render(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles incoming raised hand", () => {
const room = new MockRoom();
const { queryByRole, rerender } = render(
<TestComponentWrapper room={room} />,
);
room.testSendReaction("@foo:bar");
rerender(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
room.testSendReaction("@baz:bar");
rerender(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
test("handles incoming unraised hand", () => {
const room = new MockRoom();
const { queryByRole, rerender } = render(
<TestComponentWrapper room={room} />,
);
room.testSendReaction("@foo:bar");
rerender(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
room.emit(
RoomEvent.Redaction,
createRedaction("@foo:bar"),
room,
undefined,
);
rerender(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles loading events from cold", () => {
const room = new MockRoom([createReaction(membership[0])]);
const { queryByRole } = render(<TestComponentWrapper room={room} />);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
});
});

164
src/useReactions.tsx Normal file
View File

@@ -0,0 +1,164 @@
/*
Copyright 2024 Milton Moura <miltonmoura@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
EventType,
MatrixEvent,
RelationType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
import { ReactionEventContent } from "matrix-js-sdk/src/types";
import {
createContext,
useContext,
useState,
ReactNode,
useCallback,
useEffect,
} from "react";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext";
interface ReactionsContextType {
raisedHands: Record<string, Date>;
raisedHandCount: number;
addRaisedHand: (userId: string, date: Date) => void;
removeRaisedHand: (userId: string) => void;
supportsReactions: boolean;
myReactionId: string | null;
setMyReactionId: (id: string | null) => void;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
undefined,
);
export const useReactions = (): ReactionsContextType => {
const context = useContext(ReactionsContext);
if (!context) {
throw new Error("useReactions must be used within a ReactionsProvider");
}
return context;
};
export const ReactionsProvider = ({
children,
rtcSession,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
}): JSX.Element => {
const [raisedHands, setRaisedHands] = useState<Record<string, Date>>({});
const [myReactionId, setMyReactionId] = useState<string | null>(null);
const [raisedHandCount, setRaisedHandCount] = useState(0);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
const addRaisedHand = useCallback(
(userId: string, time: Date) => {
setRaisedHands({
...raisedHands,
[userId]: time,
});
setRaisedHandCount(Object.keys(raisedHands).length + 1);
},
[raisedHands],
);
const removeRaisedHand = useCallback(
(userId: string) => {
delete raisedHands[userId];
setRaisedHands(raisedHands);
setRaisedHandCount(Object.keys(raisedHands).length);
},
[raisedHands],
);
// Load any existing reactions.
useEffect(() => {
const getLastReactionEvent = (eventId: string): MatrixEvent | undefined => {
const relations = room.relations.getChildEventsForEvent(
eventId,
RelationType.Annotation,
EventType.Reaction,
);
const allEvents = relations?.getRelations() ?? [];
return allEvents.length > 0 ? allEvents[0] : undefined;
};
for (const m of memberships) {
if (!m.sender || !m.eventId) {
continue;
}
const reaction = getLastReactionEvent(m.eventId);
const eventId = reaction?.getId();
if (!eventId) {
continue;
}
if (reaction && reaction.getType() === EventType.Reaction) {
const content = reaction.getContent() as ReactionEventContent;
if (content?.["m.relates_to"]?.key === "🖐️") {
addRaisedHand(m.sender, new Date(reaction.localTimestamp));
if (m.sender === room.client.getUserId()) {
setMyReactionId(eventId);
}
}
}
}
// Deliberately ignoring addRaisedHand which was causing looping.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, memberships]);
useEffect(() => {
const handleReactionEvent = (event: MatrixEvent): void => {
const sender = event.getSender();
if (!sender) {
// Skip any event without a sender.
return;
}
if (event.getType() === EventType.Reaction) {
// TODO: check if target of reaction is a call membership event
const content = event.getContent() as ReactionEventContent;
if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(sender, new Date(event.localTimestamp));
}
} else if (event.getType() === EventType.RoomRedaction) {
// TODO: check target of redaction event
removeRaisedHand(sender);
}
};
room.on(MatrixRoomEvent.Timeline, handleReactionEvent);
room.on(MatrixRoomEvent.Redaction, handleReactionEvent);
return (): void => {
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
};
}, [room, addRaisedHand, removeRaisedHand]);
return (
<ReactionsContext.Provider
value={{
raisedHands,
raisedHandCount,
addRaisedHand,
removeRaisedHand,
supportsReactions,
myReactionId,
setMyReactionId,
}}
>
{children}
</ReactionsContext.Provider>
);
};

View File

@@ -103,6 +103,8 @@ export const widget = ((): WidgetHelpers | null => {
const sendRecvEvent = [
"org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix,
EventType.Reaction,
EventType.RoomRedaction,
];
const sendState = [