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:
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user