Add feature to release hand raised when the tile indicator is clicked. (#2721)

* Refactor to add support for lowering hand on indicator click.

* Cleanup and lint.

* fix icon being a little off
This commit is contained in:
Will Hunt
2024-11-06 11:00:19 +00:00
committed by GitHub
parent 110914a4d6
commit bc0ab92394
8 changed files with 106 additions and 49 deletions

View File

@@ -62,7 +62,7 @@ export function RaiseHandToggleButton({
client, client,
rtcSession, rtcSession,
}: RaisedHandToggleButtonProps): ReactNode { }: RaisedHandToggleButtonProps): ReactNode {
const { raisedHands, myReactionId } = useReactions(); const { raisedHands, lowerHand } = useReactions();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const userId = client.getUserId()!; const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId]; const isHandRaised = !!raisedHands[userId];
@@ -71,16 +71,9 @@ export function RaiseHandToggleButton({
const toggleRaisedHand = useCallback(() => { const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => { const raiseHand = async (): Promise<void> => {
if (isHandRaised) { if (isHandRaised) {
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
}
try { try {
setBusy(true); setBusy(true);
await client.redactEvent(rtcSession.room.roomId, myReactionId); await lowerHand();
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -118,9 +111,9 @@ export function RaiseHandToggleButton({
client, client,
isHandRaised, isHandRaised,
memberships, memberships,
myReactionId,
rtcSession.room.roomId, rtcSession.room.roomId,
userId, userId,
lowerHand,
]); ]);
return ( return (

View File

@@ -5,6 +5,11 @@
color: var(--cpd-color-icon-secondary); color: var(--cpd-color-icon-secondary);
} }
.button {
display: contents;
background: none;
}
.raisedHandWidget > p { .raisedHandWidget > p {
padding: none; padding: none;
margin-top: auto; margin-top: auto;
@@ -42,11 +47,11 @@
height: var(--cpd-space-6x); height: var(--cpd-space-6x);
display: inline-block; display: inline-block;
text-align: center; text-align: center;
font-size: 16px; font-size: 1.3em;
} }
.raisedHandLarge > span { .raisedHandLarge > span {
width: var(--cpd-space-8x); width: var(--cpd-space-8x);
height: var(--cpd-space-8x); height: var(--cpd-space-8x);
font-size: 22px; font-size: 1.9em;
} }

View File

@@ -40,4 +40,16 @@ describe("RaisedHandIndicator", () => {
); );
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
test("can be clicked", () => {
const dateTime = new Date();
let wasClicked = false;
const { getByRole } = render(
<RaisedHandIndicator
raisedHandTime={dateTime}
onClick={() => (wasClicked = true)}
/>,
);
getByRole("button").click();
expect(wasClicked).toBe(true);
});
}); });

View File

@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { ReactNode, useEffect, useState } from "react"; import {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import classNames from "classnames"; import classNames from "classnames";
import "@formatjs/intl-durationformat/polyfill"; import "@formatjs/intl-durationformat/polyfill";
import { DurationFormat } from "@formatjs/intl-durationformat"; import { DurationFormat } from "@formatjs/intl-durationformat";
@@ -23,13 +29,26 @@ export function RaisedHandIndicator({
raisedHandTime, raisedHandTime,
minature, minature,
showTimer, showTimer,
onClick,
}: { }: {
raisedHandTime?: Date; raisedHandTime?: Date;
minature?: boolean; minature?: boolean;
showTimer?: boolean; showTimer?: boolean;
onClick?: () => void;
}): ReactNode { }): ReactNode {
const [raisedHandDuration, setRaisedHandDuration] = useState(""); const [raisedHandDuration, setRaisedHandDuration] = useState("");
const clickCallback = useCallback<MouseEventHandler<HTMLButtonElement>>(
(event) => {
if (!onClick) {
return;
}
event.preventDefault();
onClick();
},
[onClick],
);
// This effect creates a simple timer effect. // This effect creates a simple timer effect.
useEffect(() => { useEffect(() => {
if (!raisedHandTime || !showTimer) { if (!raisedHandTime || !showTimer) {
@@ -52,26 +71,40 @@ export function RaisedHandIndicator({
return (): void => clearInterval(to); return (): void => clearInterval(to);
}, [setRaisedHandDuration, raisedHandTime, showTimer]); }, [setRaisedHandDuration, raisedHandTime, showTimer]);
if (raisedHandTime) { if (!raisedHandTime) {
return ( return;
}
const content = (
<div
className={classNames(styles.raisedHandWidget, {
[styles.raisedHandWidgetLarge]: !minature,
})}
>
<div <div
className={classNames(styles.raisedHandWidget, { className={classNames(styles.raisedHand, {
[styles.raisedHandWidgetLarge]: !minature, [styles.raisedHandLarge]: !minature,
})} })}
> >
<div <span role="img" aria-label="raised hand">
className={classNames(styles.raisedHand, {
[styles.raisedHandLarge]: !minature, </span>
})}
>
<span role="img" aria-label="raised hand">
</span>
</div>
{showTimer && <p>{raisedHandDuration}</p>}
</div> </div>
{showTimer && <p>{raisedHandDuration}</p>}
</div>
);
if (onClick) {
return (
<button
aria-label="lower raised hand"
className={styles.button}
onClick={clickCallback}
>
{content}
</button>
); );
} }
return null; return content;
} }

View File

@@ -92,7 +92,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
}, },
[vm], [vm],
); );
const { raisedHands } = useReactions(); const { raisedHands, lowerHand } = useReactions();
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
@@ -111,6 +111,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
); );
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
const raisedHandOnClick =
vm.local && handRaised ? (): void => void lowerHand() : undefined;
const showSpeaking = showSpeakingIndicators && speaking; const showSpeaking = showSpeakingIndicators && speaking;
@@ -153,6 +155,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
</Menu> </Menu>
} }
raisedHandTime={handRaised} raisedHandTime={handRaised}
raisedHandOnClick={raisedHandOnClick}
{...props} {...props}
/> />
); );

View File

@@ -35,6 +35,7 @@ interface Props extends ComponentProps<typeof animated.div> {
displayName: string; displayName: string;
primaryButton?: ReactNode; primaryButton?: ReactNode;
raisedHandTime?: Date; raisedHandTime?: Date;
raisedHandOnClick?: () => void;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -54,6 +55,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
displayName, displayName,
primaryButton, primaryButton,
raisedHandTime, raisedHandTime,
raisedHandOnClick,
...props ...props
}, },
ref, ref,
@@ -97,6 +99,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
raisedHandTime={raisedHandTime} raisedHandTime={raisedHandTime}
minature={avatarSize < 96} minature={avatarSize < 96}
showTimer={handRaiseTimerVisible} showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/> />
<div className={styles.nameTag}> <div className={styles.nameTag}>
{nameTagLeadingIcon} {nameTagLeadingIcon}

View File

@@ -45,7 +45,7 @@ const membership: Record<string, string> = {
}; };
const TestComponent: FC = () => { const TestComponent: FC = () => {
const { raisedHands, myReactionId } = useReactions(); const { raisedHands } = useReactions();
return ( return (
<div> <div>
<ul> <ul>
@@ -56,7 +56,6 @@ const TestComponent: FC = () => {
</li> </li>
))} ))}
</ul> </ul>
<p>{myReactionId ? "Local reaction" : "No local reaction"}</p>
</div> </div>
); );
}; };
@@ -172,15 +171,6 @@ describe("useReactions", () => {
); );
expect(queryByRole("list")?.children).to.have.lengthOf(0); expect(queryByRole("list")?.children).to.have.lengthOf(0);
}); });
test("handles own raised hand", async () => {
const room = new MockRoom();
const rtcSession = new MockRTCSession(room);
const { queryByText } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
);
await act(() => room.testSendReaction(memberEventAlice));
expect(queryByText("Local reaction")).toBeTruthy();
});
test("handles incoming raised hand", async () => { test("handles incoming raised hand", async () => {
const room = new MockRoom(); const room = new MockRoom();
const rtcSession = new MockRTCSession(room); const rtcSession = new MockRTCSession(room);

View File

@@ -30,7 +30,7 @@ import { useClientState } from "./ClientContext";
interface ReactionsContextType { interface ReactionsContextType {
raisedHands: Record<string, Date>; raisedHands: Record<string, Date>;
supportsReactions: boolean; supportsReactions: boolean;
myReactionId: string | null; lowerHand: () => Promise<void>;
} }
const ReactionsContext = createContext<ReactionsContextType | undefined>( const ReactionsContext = createContext<ReactionsContextType | undefined>(
@@ -80,13 +80,6 @@ export const ReactionsProvider = ({
const room = rtcSession.room; const room = rtcSession.room;
const myUserId = room.client.getUserId(); const myUserId = room.client.getUserId();
// Calculate our own reaction event.
const myReactionId = useMemo(
(): string | null =>
(myUserId && raisedHands[myUserId]?.reactionEventId) ?? null,
[raisedHands, myUserId],
);
// Reduce the data down for the consumers. // Reduce the data down for the consumers.
const resultRaisedHands = useMemo( const resultRaisedHands = useMemo(
() => () =>
@@ -235,12 +228,37 @@ export const ReactionsProvider = ({
}; };
}, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]); }, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]);
const lowerHand = useCallback(async () => {
if (
!myUserId ||
clientState?.state !== "valid" ||
!clientState.authenticated ||
!raisedHands[myUserId]
) {
return;
}
const myReactionId = raisedHands[myUserId].reactionEventId;
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
}
try {
await clientState.authenticated.client.redactEvent(
rtcSession.room.roomId,
myReactionId,
);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
}
}, [myUserId, raisedHands, clientState, rtcSession]);
return ( return (
<ReactionsContext.Provider <ReactionsContext.Provider
value={{ value={{
raisedHands: resultRaisedHands, raisedHands: resultRaisedHands,
supportsReactions, supportsReactions,
myReactionId, lowerHand,
}} }}
> >
{children} {children}