Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hand raise feature #2542

Merged
merged 47 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
48cf487
Initial support for Hand Raise feature
mgcm Aug 7, 2024
2d1917c
Refactored to use reaction and redaction events
mgcm Sep 8, 2024
f6ae6a0
Replacing button svg with raised hand emoji
mgcm Sep 8, 2024
ac7321d
SpotlightTile should not duplicate the raised hand
mgcm Sep 8, 2024
bcad500
Update src/room/useRaisedHands.tsx
fkwp Sep 9, 2024
0730ba5
Use relations to load existing reactions when joining the call
mgcm Sep 10, 2024
ab5654c
Links to sha commit of matrix-js-sdk that exposes the call membership…
mgcm Sep 10, 2024
7ac5642
Removing RaiseHand.svg
mgcm Sep 10, 2024
69a50fb
Check for reaction & redaction capabilities in widget mode
mgcm Sep 19, 2024
42a7b1e
Fix failing GridTile test
mgcm Sep 19, 2024
16afb56
Center align hand raise.
Half-Shot Oct 25, 2024
1c8e547
Add support for displaying the duration of a raised hand.
Half-Shot Oct 25, 2024
7f268a3
Add a sound for when a hand is raised.
Half-Shot Oct 25, 2024
a23d256
Refactor raised hand indicator and add tests.
Half-Shot Oct 28, 2024
43b4fc0
lint
Half-Shot Oct 28, 2024
4501e67
Refactor into own files.
Half-Shot Oct 28, 2024
4a712dc
Redact the right thing.
Half-Shot Oct 28, 2024
ba921f8
Tidy up useEffect
Half-Shot Oct 28, 2024
9d01e8c
Lint tests
Half-Shot Oct 28, 2024
38878d3
Remove extra layer
Half-Shot Oct 28, 2024
33724ef
Add better sound. (woosh)
Half-Shot Oct 28, 2024
198859d
Add a small mode for spotlight
Half-Shot Oct 28, 2024
07d3451
Fix timestamp calculation on relaod.
Half-Shot Oct 28, 2024
b7e8236
Fix call border resizing video
Half-Shot Oct 29, 2024
23d849b
lint
Half-Shot Oct 29, 2024
f13bd79
Fix and update tests
Half-Shot Oct 29, 2024
dbabf45
Allow timer to be configurable.
Half-Shot Oct 29, 2024
e1a4310
Add preferences tab for choosing to enable timer.
Half-Shot Oct 29, 2024
0b6cf18
Drop border from raised hand icon
Half-Shot Oct 29, 2024
528e692
Handle cases when a new member event happens.
Half-Shot Oct 29, 2024
cd73ad8
Prevent infinite loop
Half-Shot Oct 29, 2024
5a5c1be
Major refactor to support various state problems.
Half-Shot Oct 29, 2024
ff7da13
Tidy up and finish test rewrites
Half-Shot Oct 29, 2024
a45b01d
Add some explanation comments.
Half-Shot Oct 29, 2024
3229498
Even more comments.
Half-Shot Oct 29, 2024
2d95d4f
Use proper duration formatter
Half-Shot Oct 31, 2024
7229f4b
Remove rerender
Half-Shot Oct 31, 2024
a354a40
Fix redactions not working because they pick up events in transit.
Half-Shot Oct 31, 2024
e49eb55
More tidying
Half-Shot Oct 31, 2024
ec9dec8
Use deferred value
Half-Shot Oct 31, 2024
21380c7
linting
Half-Shot Oct 31, 2024
a9e6aa3
Add tests for cases where we got a reaction from someone else.
Half-Shot Oct 31, 2024
167caa3
Merge remote-tracking branch 'origin/livekit' into raise-hand-button
Half-Shot Oct 31, 2024
748cc58
Be even less brittle.
Half-Shot Nov 1, 2024
f54e1e2
Transpose border to GridTile.
Half-Shot Nov 1, 2024
2d41bf7
Merge branch 'livekit' into raise-hand-button
Half-Shot Nov 4, 2024
81fbdfc
lint
Half-Shot Nov 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@formatjs/intl-durationformat": "^0.6.1",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@opentelemetry/api": "^1.4.0",
Expand Down
6 changes: 6 additions & 0 deletions public/locales/en-GB/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -145,6 +147,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",
Expand Down
41 changes: 39 additions & 2 deletions src/ClientContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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(
Expand All @@ -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,
});
Expand Down Expand Up @@ -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) {
Expand All @@ -277,6 +283,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
};
}, [
alreadyOpenedErr,
Expand All @@ -285,6 +294,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
logout,
setClient,
isDisconnected,
supportsReactions,
]);

const onSync = useCallback(
Expand All @@ -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);
Expand All @@ -326,6 +360,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
};

type InitResult = {
widgetApi: WidgetApi | null;
client: MatrixClient;
passwordlessUser: boolean;
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -364,6 +400,7 @@ async function loadClient(): Promise<InitResult | null> {
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};
Expand Down
2 changes: 1 addition & 1 deletion src/Modal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
133 changes: 133 additions & 0 deletions src/button/RaisedHandToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
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 InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
}

const InnerButton: FC<InnerButtonProps> = ({ 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-hidden
style={{
width: "30px",
height: "0px",
display: "inline-block",
fontSize: "22px",
}}
>
</p>
</CpdButton>
</Tooltip>
);
};

interface RaisedHandToggleButtonProps {
rtcSession: MatrixRTCSession;
client: MatrixClient;
}

export function RaiseHandToggleButton({
client,
rtcSession,
}: RaisedHandToggleButtonProps): ReactNode {
const { raisedHands, myReactionId } = useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);

const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
}
try {
setBusy(true);
await client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
} finally {
setBusy(false);
}
} else {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
logger.error("Cannot find own membership event");
return;
}
const parentEventId = myMembership.eventId;
try {
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: parentEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send reaction event", ex);
} finally {
setBusy(false);
}
}
};

void raiseHand();
}, [
client,
isHandRaised,
memberships,
myReactionId,
rtcSession.room.roomId,
userId,
]);

return (
<InnerButton
disabled={busy}
onClick={toggleRaisedHand}
raised={isHandRaised}
/>
);
}
1 change: 1 addition & 0 deletions src/button/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ Please see LICENSE in the repository root for full details.

export * from "./Button";
export * from "./LinkButton";
export * from "./RaisedHandToggleButton";
52 changes: 52 additions & 0 deletions src/reactions/RaisedHandIndicator.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
.raisedHandWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
}

.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;
}
43 changes: 43 additions & 0 deletions src/reactions/RaisedHandIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading