Refactor into own files.

This commit is contained in:
Half-Shot
2024-10-28 15:57:55 +00:00
parent 43b4fc0a0c
commit 4501e670b2
8 changed files with 308 additions and 217 deletions

View File

@@ -28,7 +28,6 @@ import { Initializer } from "./initializer";
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
import { ReactionsProvider } from "./useReactions";
const SentryRoute = Sentry.withSentryRouting(Route);
@@ -83,29 +82,27 @@ export const App: FC<AppProps> = ({ history }) => {
<TooltipProvider>
{loaded ? (
<Suspense fallback={null}>
<ReactionsProvider>
<ClientProvider>
<MediaDevicesProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<DisconnectedBanner />
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="*">
<RoomPage />
</SentryRoute>
</Switch>
</Sentry.ErrorBoundary>
</MediaDevicesProvider>
</ClientProvider>
</ReactionsProvider>
<ClientProvider>
<MediaDevicesProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<DisconnectedBanner />
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="*">
<RoomPage />
</SentryRoute>
</Switch>
</Sentry.ErrorBoundary>
</MediaDevicesProvider>
</ClientProvider>
</Suspense>
) : (
<LoadingView />

View File

@@ -37,7 +37,6 @@ import {
import { translatedError } from "./TranslatedError";
import { useEventTarget } from "./useEvents";
import { Config } from "./config/Config";
import { useReactions } from "./useReactions";
declare global {
interface Window {
@@ -54,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;
};
@@ -146,7 +148,6 @@ interface Props {
}
export const ClientProvider: FC<Props> = ({ children }) => {
const { setSupportsReactions } = useReactions();
const history = useHistory();
// null = signed out, undefined = loading
@@ -258,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) {
@@ -281,6 +283,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
};
}, [
alreadyOpenedErr,
@@ -289,6 +294,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
logout,
setClient,
isDisconnected,
supportsReactions,
]);
const onSync = useCallback(
@@ -314,8 +320,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}
if (initClientState.widgetApi) {
let supportsReactions = true;
const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction",
);
@@ -330,15 +334,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
);
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
supportsReactions = false;
}
setSupportsReactions(supportsReactions);
if (!supportsReactions) {
logger.warn("Widget does not support reactions");
setSupportsReactions(false);
} else {
logger.warn("Widget does support reactions");
setSupportsReactions(true);
}
} else {
setSupportsReactions(true);
}
return (): void => {
@@ -346,7 +348,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync, setSupportsReactions]);
}, [initClientState, onSync]);
if (alreadyOpenedErr) {
return <ErrorView error={alreadyOpenedErr} />;

View File

@@ -91,39 +91,6 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
);
};
interface RaiseHandButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
}
export const RaiseHandButton: FC<RaiseHandButtonProps> = ({
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>
);
};
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
className,
...props

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

@@ -11,10 +11,6 @@ import {
useLocalParticipant,
} from "@livekit/components-react";
import { ConnectionState, Room } from "livekit-client";
import {
MatrixEvent,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {
FC,
@@ -34,8 +30,6 @@ import classNames from "classnames";
import { BehaviorSubject, of } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { ReactionEventContent } from "matrix-js-sdk/src/types";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -45,8 +39,8 @@ import {
MicButton,
VideoButton,
ShareScreenButton,
RaiseHandButton,
SettingsButton,
RaiseHandToggleButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useUrlParams } from "../UrlParams";
@@ -85,8 +79,7 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useReactions } from "../useReactions";
import { ReactionsProvider, useReactions } from "../useReactions";
import handSound from "../res/sounds/raise-hand.ogg?url";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -140,12 +133,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>
);
};
@@ -178,7 +173,9 @@ export const InCallView: FC<InCallViewProps> = ({
connState,
onShareClick,
}) => {
const { supportsReactions } = useReactions();
const { supportsReactions, raisedHandCount } = useReactions();
const [previousRaisedHandCount, setPreviousRaisedHandCount] =
useState(raisedHandCount);
useWakeLock();
@@ -310,87 +307,19 @@ export const InCallView: FC<InCallViewProps> = ({
[vm],
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const { raisedHands, addRaisedHand, removeRaisedHand } = useReactions();
const [reactionId, setReactionId] = useState<string | null>(null);
const userId = client.getUserId()!;
// Play a sound when the raised hand count increases.
const handRaisePlayer = useRef<HTMLAudioElement>(null);
const isHandRaised = !!raisedHands[userId];
useEffect(() => {
const getLastReactionEvent = async (
eventId: string,
): Promise<MatrixEvent | undefined> => {
return client
.relations(
rtcSession.room.roomId,
eventId,
RelationType.Annotation,
EventType.Reaction,
{
limit: 1,
},
)
.then((rels) => {
return rels.events.length > 0 ? rels.events[0] : undefined;
});
};
const fetchReactions = async (): Promise<void> => {
for (const m of memberships) {
if (!m.sender) {
continue;
}
const reaction = await getLastReactionEvent(m.eventId!);
if (reaction && reaction.getType() === EventType.Reaction) {
const content = reaction.getContent() as ReactionEventContent;
if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(m.sender, new Date(m.createdTs()));
if (m.sender === userId) {
setReactionId(m.eventId!);
}
}
}
}
};
void fetchReactions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleReactionEvent = (event: MatrixEvent): void => {
const sender = event.getSender();
if (!sender) {
// Weird, skip.
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));
handRaisePlayer.current?.play().catch((ex) => {
logger.warn("Failed to play hand raise sound", ex);
});
}
}
if (event.getType() === EventType.RoomRedaction && event.getSender()) {
// TODO: check target of redaction event
removeRaisedHand(sender);
}
};
client.on(MatrixRoomEvent.Timeline, handleReactionEvent);
client.on(MatrixRoomEvent.Redaction, handleReactionEvent);
return (): void => {
client.on(MatrixRoomEvent.Timeline, handleReactionEvent);
client.off(MatrixRoomEvent.Redaction, handleReactionEvent);
};
}, [client, raisedHands, addRaisedHand, removeRaisedHand]);
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
@@ -573,52 +502,6 @@ export const InCallView: FC<InCallViewProps> = ({
.catch(logger.error);
}, [localParticipant, isScreenShareEnabled]);
const toggleRaisedHand = useCallback(() => {
if (isHandRaised) {
if (reactionId) {
client
.redactEvent(rtcSession.room.roomId, reactionId)
.then(() => {
setReactionId(null);
removeRaisedHand(userId);
logger.debug("Redacted reaction event");
})
.catch((e) => {
logger.error("Failed to redact reaction event", e);
});
}
} else {
const m = memberships.filter((m) => m.sender === userId);
const eventId = m[0].eventId!;
client
.sendEvent(rtcSession.room.roomId, EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: eventId,
key: "🖐️",
},
})
.then((reaction) => {
setReactionId(reaction.event_id);
addRaisedHand(userId, new Date());
logger.debug("Sent reaction event", reaction.event_id);
})
.catch((e) => {
logger.error("Failed to send reaction event", e);
});
}
}, [
client,
isHandRaised,
memberships,
reactionId,
rtcSession.room.roomId,
addRaisedHand,
removeRaisedHand,
setReactionId,
userId,
]);
let footer: JSX.Element | null;
if (noControls) {
@@ -655,10 +538,10 @@ export const InCallView: FC<InCallViewProps> = ({
}
if (supportsReactions) {
buttons.push(
<RaiseHandButton
<RaiseHandToggleButton
client={client}
rtcSession={rtcSession}
key="4"
onClick={toggleRaisedHand}
raised={isHandRaised}
/>,
);
}

View File

@@ -9,6 +9,7 @@ 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";
@@ -26,8 +27,17 @@ 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(
<ReactionsProvider>
<ReactionsProvider rtcSession={fakeRtcSession}>
<GridTile
vm={vm}
onOpenProfile={() => {}}

View File

@@ -5,20 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import React, {
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;
setSupportsReactions: React.Dispatch<React.SetStateAction<boolean>>;
myReactionId: string | null;
setMyReactionId: (id: string | null) => void;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
@@ -35,11 +49,19 @@ export const useReactions = (): ReactionsContextType => {
export const ReactionsProvider = ({
children,
rtcSession,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
}): JSX.Element => {
const [raisedHands, setRaisedHands] = useState<Record<string, Date>>({});
const [supportsReactions, setSupportsReactions] = useState<boolean>(true);
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) => {
@@ -47,6 +69,7 @@ export const ReactionsProvider = ({
...raisedHands,
[userId]: time,
});
setRaisedHandCount(Object.keys(raisedHands).length + 1);
},
[raisedHands],
);
@@ -55,18 +78,83 @@ export const ReactionsProvider = ({
(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;
};
const fetchReactions = (): void => {
for (const m of memberships) {
if (!m.sender) {
continue;
}
const reaction = getLastReactionEvent(m.eventId!);
if (reaction && reaction.getType() === EventType.Reaction) {
const content = reaction.getContent() as ReactionEventContent;
if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(m.sender, new Date(m.createdTs()));
if (m.sender === room.client.getUserId()) {
setMyReactionId(m.eventId!);
}
}
}
}
};
void fetchReactions();
}, [room, addRaisedHand, 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));
}
}
if (event.getType() === EventType.RoomRedaction && event.getSender()) {
// 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, raisedHands, addRaisedHand, removeRaisedHand]);
return (
<ReactionsContext.Provider
value={{
raisedHands,
raisedHandCount,
addRaisedHand,
removeRaisedHand,
supportsReactions,
setSupportsReactions,
myReactionId,
setMyReactionId,
}}
>
{children}