From 1897210a609f266976d763e5a016c8dd939e1030 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Mon, 4 Nov 2024 08:54:13 -0100 Subject: [PATCH] Hand raise feature (#2542) * Initial support for Hand Raise feature Signed-off-by: Milton Moura * Refactored to use reaction and redaction events Signed-off-by: Milton Moura * Replacing button svg with raised hand emoji Signed-off-by: Milton Moura * SpotlightTile should not duplicate the raised hand Signed-off-by: Milton Moura * Update src/room/useRaisedHands.tsx Element Call recently changed to AGPL-3.0 * Use relations to load existing reactions when joining the call Signed-off-by: Milton Moura * 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 * Removing RaiseHand.svg * Check for reaction & redaction capabilities in widget mode Signed-off-by: Milton Moura * Fix failing GridTile test Signed-off-by: Milton Moura * Center align hand raise. * Add support for displaying the duration of a raised hand. * Add a sound for when a hand is raised. * Refactor raised hand indicator and add tests. * lint * Refactor into own files. * Redact the right thing. * Tidy up useEffect * Lint tests * Remove extra layer * Add better sound. (woosh) * Add a small mode for spotlight * Fix timestamp calculation on relaod. * Fix call border resizing video * lint * Fix and update tests * Allow timer to be configurable. * Add preferences tab for choosing to enable timer. * Drop border from raised hand icon * Handle cases when a new member event happens. * Prevent infinite loop * Major refactor to support various state problems. * Tidy up and finish test rewrites * Add some explanation comments. * Even more comments. * Use proper duration formatter * Remove rerender * Fix redactions not working because they pick up events in transit. * More tidying * Use deferred value * linting * Add tests for cases where we got a reaction from someone else. * Be even less brittle. * Transpose border to GridTile. * lint --------- Signed-off-by: Milton Moura Co-authored-by: fkwp Co-authored-by: Half-Shot Co-authored-by: Will Hunt --- package.json | 1 + public/locales/en-GB/app.json | 6 + src/ClientContext.tsx | 41 ++- src/Modal.module.css | 2 +- src/button/RaisedHandToggleButton.tsx | 133 +++++++++ src/button/index.ts | 1 + src/reactions/RaisedHandIndicator.module.css | 52 ++++ src/reactions/RaisedHandIndicator.test.tsx | 43 +++ src/reactions/RaisedHandIndicator.tsx | 77 +++++ .../RaisedHandIndicator.test.tsx.snap | 61 ++++ src/room/InCallView.tsx | 52 +++- src/settings/PreferencesSettingsTab.tsx | 49 ++++ src/settings/SettingsModal.tsx | 10 +- src/settings/settings.ts | 5 + src/sound/raise_hand.mp3 | Bin 0 -> 12960 bytes src/sound/raise_hand.ogg | Bin 0 -> 6497 bytes src/tile/GridTile.module.css | 29 +- src/tile/GridTile.test.tsx | 32 ++- src/tile/GridTile.tsx | 10 +- src/tile/MediaView.tsx | 14 +- src/useReactions.test.tsx | 268 ++++++++++++++++++ src/useReactions.tsx | 249 ++++++++++++++++ src/widget.ts | 2 + yarn.lock | 42 ++- 24 files changed, 1149 insertions(+), 30 deletions(-) create mode 100644 src/button/RaisedHandToggleButton.tsx create mode 100644 src/reactions/RaisedHandIndicator.module.css create mode 100644 src/reactions/RaisedHandIndicator.test.tsx create mode 100644 src/reactions/RaisedHandIndicator.tsx create mode 100644 src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap create mode 100644 src/settings/PreferencesSettingsTab.tsx create mode 100644 src/sound/raise_hand.mp3 create mode 100644 src/sound/raise_hand.ogg create mode 100644 src/useReactions.test.tsx create mode 100644 src/useReactions.tsx diff --git a/package.json b/package.json index 85ab2ec83..860d64a6f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index fa4066ad0..02dd77401 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -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", @@ -145,6 +147,10 @@ "feedback_tab_title": "Feedback", "more_tab_title": "More", "opt_in_description": "<0><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", diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 805b23137..5a531c2a8 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -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 = ({ 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 = ({ children }) => { if (clientParams) { saveSession(clientParams.session); setInitClientState({ + widgetApi: null, client: clientParams.client, passwordlessUser: clientParams.session.passwordlessUser, }); @@ -254,6 +259,7 @@ export const ClientProvider: FC = ({ 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 = ({ children }) => { authenticated, setClient, disconnected: isDisconnected, + supportedFeatures: { + reactions: supportsReactions, + }, }; }, [ alreadyOpenedErr, @@ -285,6 +294,7 @@ export const ClientProvider: FC = ({ children }) => { logout, setClient, isDisconnected, + supportsReactions, ]); const onSync = useCallback( @@ -309,6 +319,30 @@ export const ClientProvider: FC = ({ 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 = ({ children }) => { }; type InitResult = { + widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -336,6 +371,7 @@ async function loadClient(): Promise { 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 { try { const client = await initClient(initClientParams, true); return { + widgetApi: null, client, passwordlessUser, }; diff --git a/src/Modal.module.css b/src/Modal.module.css index b69a10712..fae3a6fbf 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -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; } diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx new file mode 100644 index 000000000..277817def --- /dev/null +++ b/src/button/RaisedHandToggleButton.tsx @@ -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 = ({ raised, ...props }) => { + const { t } = useTranslation(); + + return ( + + +

+ ✋ +

+
+
+ ); +}; + +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 => { + 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 ( + + ); +} diff --git a/src/button/index.ts b/src/button/index.ts index 178b58c0f..e4e7cfad2 100644 --- a/src/button/index.ts +++ b/src/button/index.ts @@ -7,3 +7,4 @@ Please see LICENSE in the repository root for full details. export * from "./Button"; export * from "./LinkButton"; +export * from "./RaisedHandToggleButton"; diff --git a/src/reactions/RaisedHandIndicator.module.css b/src/reactions/RaisedHandIndicator.module.css new file mode 100644 index 000000000..4c274374f --- /dev/null +++ b/src/reactions/RaisedHandIndicator.module.css @@ -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; +} diff --git a/src/reactions/RaisedHandIndicator.test.tsx b/src/reactions/RaisedHandIndicator.test.tsx new file mode 100644 index 000000000..22a665a72 --- /dev/null +++ b/src/reactions/RaisedHandIndicator.test.tsx @@ -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(); + expect(container.firstChild).toBeNull(); + }); + test("renders an indicator when a hand has been raised", () => { + const dateTime = new Date(); + const { container } = render( + , + ); + 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( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); + test("renders a smaller indicator when minature is specified", () => { + const dateTime = new Date(); + const { container } = render( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx new file mode 100644 index 000000000..19ddaf46b --- /dev/null +++ b/src/reactions/RaisedHandIndicator.tsx @@ -0,0 +1,77 @@ +/* +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 "@formatjs/intl-durationformat/polyfill"; +import { DurationFormat } from "@formatjs/intl-durationformat"; + +import styles from "./RaisedHandIndicator.module.css"; + +const durationFormatter = new DurationFormat(undefined, { + minutesDisplay: "always", + secondsDisplay: "always", + hoursDisplay: "auto", + style: "digital", +}); + +export function RaisedHandIndicator({ + raisedHandTime, + minature, + showTimer, +}: { + raisedHandTime?: Date; + minature?: boolean; + showTimer?: boolean; +}): ReactNode { + const [raisedHandDuration, setRaisedHandDuration] = useState(""); + + // This effect creates a simple timer effect. + useEffect(() => { + if (!raisedHandTime || !showTimer) { + return; + } + + const calculateTime = (): void => { + const totalSeconds = Math.ceil( + (new Date().getTime() - raisedHandTime.getTime()) / 1000, + ); + setRaisedHandDuration( + durationFormatter.format({ + seconds: totalSeconds % 60, + minutes: Math.floor(totalSeconds / 60), + }), + ); + }; + calculateTime(); + const to = setInterval(calculateTime, 1000); + return (): void => clearInterval(to); + }, [setRaisedHandDuration, raisedHandTime, showTimer]); + + if (raisedHandTime) { + return ( +
+
+ + ✋ + +
+ {showTimer &&

{raisedHandDuration}

} +
+ ); + } + + return null; +} diff --git a/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap new file mode 100644 index 000000000..503631dc0 --- /dev/null +++ b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RaisedHandIndicator > renders a smaller indicator when minature is specified 1`] = ` +
+
+ + ✋ + +
+

+ 00:01 +

+
+`; + +exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = ` +
+
+ + ✋ + +
+

+ 00:01 +

+
+`; + +exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = ` +
+
+ + ✋ + +
+

+ 01:01 +

+
+`; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b754c6964..9492b2f01 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -19,6 +19,7 @@ import { TouchEvent, forwardRef, useCallback, + useDeferredValue, useEffect, useMemo, useRef, @@ -40,6 +41,7 @@ import { VideoButton, ShareScreenButton, SettingsButton, + RaiseHandToggleButton, SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; @@ -79,6 +81,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"; import { useSwitchCamera } from "./useSwitchCamera"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -132,12 +137,14 @@ export const ActiveCall: FC = (props) => { return ( - + + + ); }; @@ -170,6 +177,13 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { + const { supportsReactions, raisedHands } = useReactions(); + const raisedHandCount = useMemo( + () => Object.keys(raisedHands).length, + [raisedHands], + ); + const previousRaisedHandCount = useDeferredValue(raisedHandCount); + useWakeLock(); useEffect(() => { @@ -308,6 +322,19 @@ export const InCallView: FC = ({ [vm], ); + // Play a sound when the raised hand count increases. + const handRaisePlayer = useRef(null); + useEffect(() => { + if (!handRaisePlayer.current) { + return; + } + if (previousRaisedHandCount < raisedHandCount) { + handRaisePlayer.current.play().catch((ex) => { + logger.warn("Failed to play raise hand sound", ex); + }); + } + }, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]); + useEffect(() => { widget?.api.transport .send( @@ -527,6 +554,15 @@ export const InCallView: FC = ({ />, ); } + if (supportsReactions) { + buttons.push( + , + ); + } buttons.push(); } @@ -608,6 +644,10 @@ export const InCallView: FC = ({ ))} {renderContent()} + {footer} {!noControls && } { + const { t } = useTranslation(); + const [showHandRaisedTimer, setShowHandRaisedTimer] = useSetting( + showHandRaisedTimerSetting, + ); + + const onChangeSetting = useCallback( + (e: ChangeEvent) => { + setShowHandRaisedTimer(e.target.checked); + }, + [setShowHandRaisedTimer], + ); + + return ( +
+

{t("settings.preferences_tab_h4")}

+ {t("settings.preferences_tab_body")} + + + +
+ ); +}; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index c4ba24d16..db702ef8f 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -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 = ({ content: generateDeviceSelection(devices.videoInput, t("common.camera")), }; + const preferencesTab: Tab = { + key: "preferences", + name: t("common.preferences"), + content: , + }; + const profileTab: Tab = { key: "profile", name: t("common.profile"), @@ -234,7 +242,7 @@ export const SettingsModal: FC = ({ 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 ( diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 61471bff2..109a882b2 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -85,4 +85,9 @@ export const videoInput = new Setting( undefined, ); +export const showHandRaisedTimer = new Setting( + "hand-raised-show-timer", + false, +); + export const alwaysShowSelf = new Setting("always-show-self", true); diff --git a/src/sound/raise_hand.mp3 b/src/sound/raise_hand.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..345df594854599bceb076d99f0680abfe03be412 GIT binary patch literal 12960 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 z zTd(c4g48}6?eE$9N%(T$S*ZsxYH`)c#b=<(3??6YpGVX1=QTFx5C0L-|nzHsN3LcmbrfSX{(Cb+n$C2 zX+foV<{xen26eNuh6-sfZhzb88tGSSZnoOh?rtgQVBM*pQ+f38ZoQSu=?P&=4!Vbn z)dMa3YP@^&tWtWgnLn8yO?=i?Yi8le_s+fGmuTa#_)Na~%-S?J#e4Ipw{B7&nvn;b zPGKL9$T}^vsmU^Ojdqh}=?2g_A5uNwfeM@GEM3DWi+_XfzX<8SiMrh?1GJ^_v;m=W&h4(M8x!Mf4LB5r3!z&4VJ%Z>N`8!3q+a9vInrn=gMw+4}r^{9NKCs~| zk&}1Z|E-_XtThR%*9&sLvGy8C_j%vIO0H8oNVmCXC1d#pra>Ar)r5pzqS1uT86`W zbeDRkdQ|RZWiMH^{T`QfJaVi`KOeRF^$WCU$x6yiT?3dH>Jovr^y8+2gV+==?Iz*x pUX54s{>Jw|vqS!$N29_QoCu;SROf`~Nw<{|n>e1yTS2 literal 0 HcmV?d00001 diff --git a/src/sound/raise_hand.ogg b/src/sound/raise_hand.ogg new file mode 100644 index 0000000000000000000000000000000000000000..9c367506821704d8786c1490aab66f1d5b4393c9 GIT binary patch literal 6497 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 T5Y)rZhvVx=J|H+)LgK#wK^j3l literal 0 HcmV?d00001 diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index 7416e2e4f..bb0685120 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -21,6 +21,15 @@ borders don't support gradients */ transition: opacity ease 0.15s; inset: calc(-1 * var(--cpd-border-width-4)); border-radius: var(--cpd-space-5x); + background-blend-mode: overlay, normal; +} + +.tile.speaking { + /* !important because speaking border should take priority over hover */ + outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; +} + +.tile.speaking::before { background: linear-gradient( 119deg, rgba(13, 92, 189, 0.7) 0%, @@ -31,15 +40,25 @@ borders don't support gradients */ rgba(13, 92, 189, 0.9) 0%, rgba(13, 189, 168, 0.9) 100% ); - background-blend-mode: overlay, normal; + opacity: 1; } -.tile.speaking { - /* !important because speaking border should take priority over hover */ - outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; +.tile.handRaised { + /* !important because hand raised border should take priority over hover */ + outline: var(--cpd-border-width-2) solid var(--cpd-color-bg-canvas-default) !important; } -.tile.speaking::before { +.tile.handRaised::before { + background: linear-gradient( + 119deg, + var(--cpd-color-yellow-1200) 0%, + var(--cpd-color-yellow-900) 100% + ), + linear-gradient( + 180deg, + var(--cpd-color-yellow-1200) 0%, + var(--cpd-color-yellow-900) 100% + ); opacity: 1; } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 4d518df45..0bf6cab82 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -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,29 @@ test("GridTile is accessible", async () => { ({}) as Partial as RemoteTrackPublication, }, async (vm) => { + const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + client: { + getUserId: () => null, + }, + }, + memberships: [], + } as unknown as MatrixRTCSession; const { container } = render( - {}} - targetWidth={300} - targetHeight={200} - showVideo - showSpeakingIndicators - />, + + {}} + targetWidth={300} + targetHeight={200} + showVideo + showSpeakingIndicators + /> + , ); expect(await axe(container)).toHaveNoViolations(); // Name should be visible diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 1eb0b9331..3675e9a7c 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -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( }, [vm], ); + const { raisedHands } = useReactions(); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -107,6 +109,10 @@ const UserMediaTile = forwardRef( ); + const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; + + const showSpeaking = showSpeakingIndicators && speaking; + const tile = ( ( videoEnabled={videoEnabled && showVideo} videoFit={cropVideo ? "cover" : "contain"} className={classNames(className, styles.tile, { - [styles.speaking]: showSpeakingIndicators && speaking, + [styles.speaking]: showSpeaking, + [styles.handRaised]: !showSpeaking && !!handRaised, })} nameTagLeadingIcon={ ( {menu} } + raisedHandTime={handRaised} {...props} /> ); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 42a056035..d8b03dc98 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -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 { className?: string; @@ -32,6 +34,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; + raisedHandTime?: Date; } export const MediaView = forwardRef( @@ -50,11 +53,15 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, + raisedHandTime, ...props }, ref, ) => { const { t } = useTranslation(); + const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); + + const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); return ( ( @@ -86,6 +93,11 @@ export const MediaView = forwardRef( )}
+
{nameTagLeadingIcon} diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx new file mode 100644 index 000000000..79caeb0af --- /dev/null +++ b/src/useReactions.test.tsx @@ -0,0 +1,268 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { act, render } from "@testing-library/react"; +import { FC, ReactNode } from "react"; +import { describe, expect, test } from "vitest"; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} 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"; + +/** + * Test explanation. + * This test suite checks that the useReactions hook appropriately reacts + * to new reactions, redactions and membership changesin the room. There is + * a large amount of test structure used to construct a mock environment. + */ + +const memberUserIdAlice = "@alice:example.org"; +const memberEventAlice = "$membership-alice:example.org"; +const memberUserIdBob = "@bob:example.org"; +const memberEventBob = "$membership-bob:example.org"; + +const membership: Record = { + [memberEventAlice]: memberUserIdAlice, + [memberEventBob]: memberUserIdBob, + "$membership-charlie:example.org": "@charlie:example.org", +}; + +const TestComponent: FC = () => { + const { raisedHands, myReactionId } = useReactions(); + return ( +
+
    + {Object.entries(raisedHands).map(([userId, date]) => ( +
  • + {userId} + +
  • + ))} +
+

{myReactionId ? "Local reaction" : "No local reaction"}

+
+ ); +}; + +const TestComponentWrapper = ({ + rtcSession, +}: { + rtcSession: MockRTCSession; +}): ReactNode => { + return ( + + + + ); +}; + +export class MockRTCSession extends EventEmitter { + public memberships = Object.entries(membership).map(([eventId, sender]) => ({ + sender, + eventId, + createdTs: (): Date => new Date(), + })); + + public constructor(public readonly room: MockRoom) { + super(); + } + + public testRemoveMember(userId: string): void { + this.memberships = this.memberships.filter((u) => u.sender !== userId); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } + + public testAddMember(sender: string): void { + this.memberships.push({ + sender, + eventId: `!fake-${randomUUID()}:event`, + createdTs: (): Date => new Date(), + }); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } +} + +function createReaction( + parentMemberEvent: string, + overridenSender?: string, +): MatrixEvent { + return new MatrixEvent({ + sender: overridenSender ?? membership[parentMemberEvent], + type: EventType.Reaction, + origin_server_ts: new Date().getTime(), + content: { + "m.relates_to": { + key: "🖐️", + event_id: parentMemberEvent, + }, + }, + event_id: randomUUID(), + }); +} + +function createRedaction(sender: string, reactionEventId: string): MatrixEvent { + return new MatrixEvent({ + sender, + type: EventType.RoomRedaction, + origin_server_ts: new Date().getTime(), + redacts: reactionEventId, + content: {}, + event_id: randomUUID(), + }); +} + +export class MockRoom extends EventEmitter { + public constructor(private readonly existingRelations: MatrixEvent[] = []) { + super(); + } + + public get client(): MatrixClient { + return { + getUserId: (): string => memberUserIdAlice, + } as unknown as MatrixClient; + } + + public get relations(): Room["relations"] { + return { + getChildEventsForEvent: (membershipEventId: string) => ({ + getRelations: (): MatrixEvent[] => { + return this.existingRelations.filter( + (r) => + r.getContent()["m.relates_to"]?.event_id === membershipEventId, + ); + }, + }), + } as unknown as Room["relations"]; + } + + public testSendReaction( + parentMemberEvent: string, + overridenSender?: string, + ): string { + const evt = createReaction(parentMemberEvent, overridenSender); + this.emit(RoomEvent.Timeline, evt, this, undefined, false, { + timeline: new EventTimeline(new EventTimelineSet(undefined)), + }); + return evt.getId()!; + } +} + +describe("useReactions", () => { + test("starts with an empty list", () => { + const rtcSession = new MockRTCSession(new MockRoom()); + const { queryByRole } = render( + , + ); + 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( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByText("Local reaction")).toBeTruthy(); + }); + test("handles incoming raised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => room.testSendReaction(memberEventBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(2); + }); + test("handles incoming unraised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + const reactionEventId = await act(() => + room.testSendReaction(memberEventAlice), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => + room.emit( + RoomEvent.Redaction, + createRedaction(memberUserIdAlice, reactionEventId), + room, + undefined, + ), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles loading prior raised hand events", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + }); + // If the membership event changes for a user, we want to remove + // the raised hand event. + test("will remove reaction when a member leaves the call", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + act(() => rtcSession.testRemoveMember(memberUserIdAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("will remove reaction when a member joins via a new event", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + // Simulate leaving and rejoining + act(() => { + rtcSession.testRemoveMember(memberUserIdAlice); + rtcSession.testAddMember(memberUserIdAlice); + }); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for historic event", () => { + const room = new MockRoom([ + createReaction(memberEventAlice, memberUserIdBob), + ]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for new event", async () => { + const room = new MockRoom([]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); +}); diff --git a/src/useReactions.tsx b/src/useReactions.tsx new file mode 100644 index 000000000..330318478 --- /dev/null +++ b/src/useReactions.tsx @@ -0,0 +1,249 @@ +/* +Copyright 2024 Milton Moura + +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, + useMemo, +} from "react"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; + +interface ReactionsContextType { + raisedHands: Record; + supportsReactions: boolean; + myReactionId: string | null; +} + +const ReactionsContext = createContext( + undefined, +); + +interface RaisedHandInfo { + /** + * Call membership event that was reacted to. + */ + membershipEventId: string; + /** + * Event ID of the reaction itself. + */ + reactionEventId: string; + /** + * The time when the reaction was raised. + */ + time: Date; +} + +export const useReactions = (): ReactionsContextType => { + const context = useContext(ReactionsContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +/** + * Provider that handles raised hand reactions for a given `rtcSession`. + */ +export const ReactionsProvider = ({ + children, + rtcSession, +}: { + children: ReactNode; + rtcSession: MatrixRTCSession; +}): JSX.Element => { + const [raisedHands, setRaisedHands] = useState< + Record + >({}); + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const clientState = useClientState(); + const supportsReactions = + clientState?.state === "valid" && clientState.supportedFeatures.reactions; + const room = rtcSession.room; + 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. + const resultRaisedHands = useMemo( + () => + Object.fromEntries( + Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), + ), + [raisedHands], + ); + + const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { + setRaisedHands((prevRaisedHands) => ({ + ...prevRaisedHands, + [userId]: info, + })); + }, []); + + const removeRaisedHand = useCallback((userId: string) => { + setRaisedHands( + ({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands, + ); + }, []); + + // This effect will check the state whenever the membership of the session changes. + useEffect(() => { + // Fetches the first reaction for a given event. + const getLastReactionEvent = ( + eventId: string, + expectedSender: string, + ): MatrixEvent | undefined => { + const relations = room.relations.getChildEventsForEvent( + eventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.find( + (reaction) => + reaction.event.sender === expectedSender && + reaction.getType() === EventType.Reaction && + reaction.getContent()?.["m.relates_to"]?.key === "🖐️", + ); + }; + + // Remove any raised hands for users no longer joined to the call. + for (const userId of Object.keys(raisedHands).filter( + (rhId) => !memberships.find((u) => u.sender == rhId), + )) { + removeRaisedHand(userId); + } + + // For each member in the call, check to see if a reaction has + // been raised and adjust. + for (const m of memberships) { + if (!m.sender || !m.eventId) { + continue; + } + if ( + raisedHands[m.sender] && + raisedHands[m.sender].membershipEventId !== m.eventId + ) { + // Membership event for sender has changed since the hand + // was raised, reset. + removeRaisedHand(m.sender); + } + const reaction = getLastReactionEvent(m.eventId, m.sender); + if (reaction) { + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + addRaisedHand(m.sender, { + membershipEventId: m.eventId, + reactionEventId: eventId, + time: new Date(reaction.localTimestamp), + }); + } + } + // Ignoring raisedHands here because we don't want to trigger each time the raised + // hands set is updated. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room, memberships, myUserId, addRaisedHand, removeRaisedHand]); + + // This effect handles any *live* reaction/redactions in the room. + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + if (event.isSending()) { + // Skip any events that are still sending. + return; + } + + const sender = event.getSender(); + const reactionEventId = event.getId(); + if (!sender || !reactionEventId) { + // Skip any event without a sender or event ID. + return; + } + + if (event.getType() === EventType.Reaction) { + const content = event.getContent() as ReactionEventContent; + const membershipEventId = content["m.relates_to"].event_id; + + // Check to see if this reaction was made to a membership event (and the + // sender of the reaction matches the membership) + if ( + !memberships.some( + (e) => e.eventId === membershipEventId && e.sender === sender, + ) + ) { + logger.warn( + `Reaction target was not a membership event for ${sender}, ignoring`, + ); + return; + } + + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(sender, { + reactionEventId, + membershipEventId, + time: new Date(event.localTimestamp), + }); + } + } else if (event.getType() === EventType.RoomRedaction) { + const targetEvent = event.event.redacts; + const targetUser = Object.entries(raisedHands).find( + ([_u, r]) => r.reactionEventId === targetEvent, + )?.[0]; + if (!targetUser) { + // Reaction target was not for us, ignoring + return; + } + removeRaisedHand(targetUser); + } + }; + + room.on(MatrixRoomEvent.Timeline, handleReactionEvent); + room.on(MatrixRoomEvent.Redaction, handleReactionEvent); + + // We listen for a local echo to get the real event ID, as timeline events + // may still be sending. + room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); + + return (): void => { + room.off(MatrixRoomEvent.Timeline, handleReactionEvent); + room.off(MatrixRoomEvent.Redaction, handleReactionEvent); + room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); + }; + }, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]); + + return ( + + {children} + + ); +}; diff --git a/src/widget.ts b/src/widget.ts index f08968b65..9d3da4792 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -103,6 +103,8 @@ export const widget = ((): WidgetHelpers | null => { const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, + EventType.Reaction, + EventType.RoomRedaction, ]; const sendState = [ diff --git a/yarn.lock b/yarn.lock index b91892cec..bf4b75368 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1601,6 +1601,38 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== +"@formatjs/ecma402-abstract@2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.1.tgz#2e62bc5c22b0e6a5e13bfec6aac15d3d403e1065" + integrity sha512-O4ywpkdJybrjFc9zyL8qK5aklleIAi5O4nYhBVJaOFtCkNrnU+lKFeJOFC48zpsZQmR8Aok2V79hGpHnzbmFpg== + dependencies: + "@formatjs/fast-memoize" "2.2.2" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/fast-memoize@2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.2.tgz#2409ec10f5f7d6c65f4c04e6c2d6cc56fa1e4cef" + integrity sha512-mzxZcS0g1pOzwZTslJOBTmLzDXseMLLvnh25ymRilCm8QLMObsQ7x/rj9GNrH0iUhZMlFisVOD6J1n6WQqpKPQ== + dependencies: + tslib "2" + +"@formatjs/intl-durationformat@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-durationformat/-/intl-durationformat-0.6.1.tgz#ea376202b1dc70683a3f3e125bb07f4fab1135a5" + integrity sha512-tPSX/D/wjO5ZKnRtwLlUYtjLUBILLX1w6+arU97NpPCpZ8SRWQePu+kDAxDwFKJ/w09idqvSFkJjYGTs6hMd1A== + dependencies: + "@formatjs/ecma402-abstract" "2.2.1" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/intl-localematcher@0.5.6": + version "0.5.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.6.tgz#cd0cd99483673d3196a15b4e2c924cfda7f002f8" + integrity sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA== + dependencies: + tslib "2" + "@gulpjs/to-absolute-glob@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz#1fc2460d3953e1d9b9f2dfdb4bcc99da4710c021" @@ -7631,16 +7663,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2, tslib@^2.0.0, tslib@^2.1.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" + integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== + tslib@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== -tslib@^2.0.0, tslib@^2.1.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" - integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== - tslib@^2.0.3: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"