diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index b1d6ec3e..0d98173e 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -29,6 +29,9 @@ import { useReactions } from "../useReactions"; import styles from "./ReactionToggleButton.module.css"; import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions"; import { Modal } from "../Modal"; +import { CallViewModel } from "../state/CallViewModel"; +import { useObservableState } from "observable-hooks"; +import { map } from "rxjs"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -158,22 +161,27 @@ export function ReactionPopupMenu({ } interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { - userId: string; + identifier: string; + vm: CallViewModel; } export function ReactionToggleButton({ - userId, + identifier, + vm, ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); - const { raisedHands, toggleRaisedHand, sendReaction, reactions } = - useReactions(); + const { toggleRaisedHand, sendReaction } = useReactions(); const [busy, setBusy] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = !!raisedHands[userId]; - const canReact = !reactions[userId]; + const isHandRaised = useObservableState( + vm.handsRaised.pipe(map((v) => !!v[identifier])), + ); + const canReact = useObservableState( + vm.reactions.pipe(map((v) => !!v[identifier])), + ); useEffect(() => { // Clear whenever the reactions menu state changes. @@ -219,7 +227,7 @@ export function ReactionToggleButton({ setShowReactionsMenu((show) => !show)} - raised={isHandRaised} + raised={!!isHandRaised} open={showReactionsMenu} {...props} /> @@ -233,8 +241,8 @@ export function ReactionToggleButton({ > void sendRelation(reaction)} toggleRaisedHand={wrappedToggleRaisedHand} /> diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index 6412d23e..42ddae81 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { ReactNode, useDeferredValue, useEffect, useMemo } from "react"; -import { filter, interval, map, scan, throttle } from "rxjs"; +import { ReactNode, useEffect } from "react"; +import { filter, interval, throttle } from "rxjs"; import { CallViewModel } from "../state/CallViewModel"; import joinCallSoundMp3 from "../sound/join_call.mp3"; @@ -17,7 +17,6 @@ import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; -import { useReactions } from "../useReactions"; import { useLatest } from "../useLatest"; // Do not play any sounds if the participant count has exceeded this diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7dbc6aec..ef1a65de 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -544,9 +544,10 @@ export const InCallView: FC = ({ if (supportsReactions) { buttons.push( , ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index ec2c9c4b..9a638ee3 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -1118,7 +1118,7 @@ export class CallViewModel extends ViewModel { ); public readonly handsRaised = new Subject>(); - private readonly reactions = new Subject>(); + public readonly reactions = new Subject>(); public updateReactions(data: ReturnType) { this.handsRaised.next(data.raisedHands); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 79898b93..a250fbf3 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -37,8 +37,14 @@ import { import { useLatest } from "./useLatest"; interface ReactionsContextType { + /** + * identifier (userId:deviceId => Date) + */ raisedHands: Record; supportsReactions: boolean; + /** + * reactions (userId:deviceId => Date) + */ reactions: Record; toggleRaisedHand: () => Promise; sendReaction: (reaction: ReactionOption) => Promise; @@ -92,6 +98,24 @@ export const ReactionsProvider = ({ clientState?.state === "valid" && clientState.supportedFeatures.reactions; const room = rtcSession.room; const myUserId = room.client.getUserId(); + const myDeviceId = room.client.getDeviceId(); + + const latestMemberships = useLatest(memberships); + const latestRaisedHands = useLatest(raisedHands); + + const myMembershipEvent = useMemo( + () => + memberships.find( + (m) => m.sender === myUserId && m.deviceId === myDeviceId, + )?.eventId, + [memberships, myUserId], + ); + const myMembershipIdentifier = useMemo(() => { + const membership = memberships.find((m) => m.sender === myUserId); + return membership + ? `${membership.sender}:${membership.deviceId}` + : undefined; + }, [memberships, myUserId]); const [reactions, setReactions] = useState>( {}, @@ -177,21 +201,8 @@ export const ReactionsProvider = ({ // 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]); + }, [room, memberships, addRaisedHand, removeRaisedHand]); - const latestMemberships = useLatest(memberships); - const latestRaisedHands = useLatest(raisedHands); - - const myMembershipEvent = useMemo( - () => memberships.find((m) => m.sender === myUserId)?.eventId, - [memberships, myUserId], - ); - const myMembershipIdentifier = useMemo(() => { - const membership = memberships.find((m) => m.sender === myUserId); - return membership - ? `${membership.sender}:${membership.deviceId}` - : undefined; - }, [memberships, myUserId]); // This effect handles any *live* reaction/redactions in the room. useEffect(() => { const reactionTimeouts = new Set(); @@ -215,18 +226,18 @@ export const ReactionsProvider = ({ const content: ECallReactionEventContent = event.getContent(); const membershipEventId = content?.["m.relates_to"]?.event_id; + const membershipEvent = latestMemberships.current.find( + (e) => e.eventId === membershipEventId && e.sender === sender, + ); // Check to see if this reaction was made to a membership event (and the // sender of the reaction matches the membership) - if ( - !latestMemberships.current.some( - (e) => e.eventId === membershipEventId && e.sender === sender, - ) - ) { + if (!membershipEvent) { logger.warn( `Reaction target was not a membership event for ${sender}, ignoring`, ); return; } + const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`; if (!content.emoji) { logger.warn(`Reaction had no emoji from ${reactionEventId}`); @@ -256,19 +267,21 @@ export const ReactionsProvider = ({ }; setReactions((reactions) => { - if (reactions[sender]) { + if (reactions[identifier]) { // We've still got a reaction from this user, ignore it to prevent spamming return reactions; } const timeout = window.setTimeout(() => { // Clear the reaction after some time. - setReactions(({ [sender]: _unused, ...remaining }) => remaining); + setReactions( + ({ [identifier]: _unused, ...remaining }) => remaining, + ); reactionTimeouts.delete(timeout); }, REACTION_ACTIVE_TIME_MS); reactionTimeouts.add(timeout); return { ...reactions, - [sender]: reaction, + [identifier]: reaction, }; }); } else if (event.getType() === EventType.Reaction) { @@ -380,7 +393,7 @@ export const ReactionsProvider = ({ const sendReaction = useCallback( async (reaction: ReactionOption) => { - if (!myUserId || reactions[myUserId]) { + if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) { // We're still reacting return; } @@ -400,7 +413,7 @@ export const ReactionsProvider = ({ }, ); }, - [myMembershipEvent, reactions, room, myUserId, rtcSession], + [myMembershipEvent, reactions, room, myMembershipIdentifier, rtcSession], ); return (