diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 583a1cd2..9dbcc617 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -224,26 +224,34 @@ export function ReactionToggleButton({ const { raisedHands, lowerHand, reactions } = useReactions(); const [busy, setBusy] = useState(false); const userId = client.getUserId()!; - const isHandRaised = !!raisedHands[userId]; const memberships = useMatrixRTCSessionMemberships(rtcSession); const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); + const myMembershipEventId = useMemo( + () => + memberships.find( + (m) => + m.sender === client.getUserId() && + m.deviceId === client.getDeviceId(), + ), + [memberships], + )?.eventId; + useEffect(() => { // Clear whenever the reactions menu state changes. setErrorText(undefined); }, [showReactionsMenu]); - const canReact = !reactions[userId]; + const canReact = !reactions[myMembershipEventId ?? ""]; + const isHandRaised = !!raisedHands[myMembershipEventId ?? ""]; const sendRelation = useCallback( async (reaction: ReactionOption) => { try { - const myMembership = memberships.find((m) => m.sender === userId); - if (!myMembership?.eventId) { - throw new Error("Cannot find own membership event"); + if (!myMembershipEventId) { + throw Error("Could not find own membership event"); } - const parentEventId = myMembership.eventId; setBusy(true); await client.sendEvent( rtcSession.room.roomId, @@ -251,7 +259,7 @@ export function ReactionToggleButton({ { "m.relates_to": { rel_type: RelationType.Reference, - event_id: parentEventId, + event_id: myMembershipEventId, }, emoji: reaction.emoji, name: reaction.name, @@ -266,7 +274,7 @@ export function ReactionToggleButton({ setBusy(false); } }, - [memberships, client, userId, rtcSession], + [myMembershipEventId, client, rtcSession], ); const toggleRaisedHand = useCallback(() => { @@ -281,11 +289,9 @@ export function ReactionToggleButton({ } } else { try { - const myMembership = memberships.find((m) => m.sender === userId); - if (!myMembership?.eventId) { - throw new Error("Cannot find own membership event"); + if (!myMembershipEventId) { + throw Error("Could not find own membership event"); } - const parentEventId = myMembership.eventId; setBusy(true); const reaction = await client.sendEvent( rtcSession.room.roomId, @@ -293,7 +299,7 @@ export function ReactionToggleButton({ { "m.relates_to": { rel_type: RelationType.Annotation, - event_id: parentEventId, + event_id: myMembershipEventId, key: "🖐️", }, }, @@ -314,10 +320,9 @@ export function ReactionToggleButton({ }, [ client, isHandRaised, - memberships, + myMembershipEventId, lowerHand, rtcSession.room.roomId, - userId, ]); return ( diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index 6140793f..fd7c38c1 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -21,6 +21,7 @@ import { const memberUserIdAlice = "@alice:example.org"; const memberEventAlice = "$membership-alice:example.org"; +const memberEventAlice2 = "$membership-alice2:example.org"; const memberUserIdBob = "@bob:example.org"; const memberEventBob = "$membership-bob:example.org"; @@ -28,6 +29,8 @@ const membership: Record = { [memberEventAlice]: memberUserIdAlice, [memberEventBob]: memberUserIdBob, "$membership-charlie:example.org": "@charlie:example.org", + // Second device + [memberEventAlice2]: memberEventAlice, }; /** @@ -42,9 +45,9 @@ const TestComponent: FC = () => { return (
    - {Object.entries(raisedHands).map(([userId, date]) => ( -
  • - {userId} + {Object.entries(raisedHands).map(([membershipEventId, date]) => ( +
  • + {membershipEventId}
  • ))} @@ -170,4 +173,17 @@ describe("useReactions", () => { await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob)); expect(queryByRole("list")?.children).to.have.lengthOf(0); }); + test("handles multiple membership event reactions for the same sender", () => { + const room = new MockRoom(memberUserIdAlice, [ + createHandRaisedReaction(memberEventAlice, membership), + createHandRaisedReaction(memberEventAlice2, membership), + ]); + const rtcSession = new MockRTCSession(room, membership); + const { queryByRole } = render( + + + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(2); + }); }); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 9111d5a9..201ba9ef 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -48,9 +48,9 @@ const ReactionsContext = createContext( interface RaisedHandInfo { /** - * Call membership event that was reacted to. + * The sender who sent the raised hand. */ - membershipEventId: string; + sender: string; /** * Event ID of the reaction itself. */ @@ -71,6 +71,8 @@ export const useReactions = (): ReactionsContextType => { return context; }; +type MembershipEventId = string; + /** * Provider that handles raised hand reactions for a given `rtcSession`. */ @@ -82,7 +84,7 @@ export const ReactionsProvider = ({ rtcSession: MatrixRTCSession; }): JSX.Element => { const [raisedHands, setRaisedHands] = useState< - Record + Record >({}); const memberships = useMatrixRTCSessionMemberships(rtcSession); const clientState = useClientState(); @@ -91,29 +93,36 @@ export const ReactionsProvider = ({ const room = rtcSession.room; const myUserId = room.client.getUserId(); - const [reactions, setReactions] = useState>( - {}, - ); + const [reactions, setReactions] = useState< + Record + >({}); // Reduce the data down for the consumers. const resultRaisedHands = useMemo( () => Object.fromEntries( - Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), + Object.entries(raisedHands).map(([membershipEventId, data]) => [ + membershipEventId, + data.time, + ]), ), [raisedHands], ); - const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { - setRaisedHands((prevRaisedHands) => ({ - ...prevRaisedHands, - [userId]: info, - })); - }, []); + const addRaisedHand = useCallback( + (membershipEventId: string, info: RaisedHandInfo) => { + setRaisedHands((prevRaisedHands) => ({ + ...prevRaisedHands, + [membershipEventId]: info, + })); + }, + [], + ); - const removeRaisedHand = useCallback((userId: string) => { + const removeRaisedHand = useCallback((membershipEventId: string) => { setRaisedHands( - ({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands, + ({ [membershipEventId]: _removed, ...remainingRaisedHands }) => + remainingRaisedHands, ); }, []); @@ -138,11 +147,10 @@ export const ReactionsProvider = ({ ); }; - // 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), + for (const previousMemberEventId of Object.keys(raisedHands).filter( + (eventId) => !memberships.some((m) => m.eventId === eventId), )) { - removeRaisedHand(userId); + removeRaisedHand(previousMemberEventId); } // For each member in the call, check to see if a reaction has @@ -151,22 +159,14 @@ export const ReactionsProvider = ({ 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, + addRaisedHand(m.eventId, { + sender: m.sender, reactionEventId: eventId, time: new Date(reaction.localTimestamp), }); @@ -241,19 +241,21 @@ export const ReactionsProvider = ({ }; setReactions((reactions) => { - if (reactions[sender]) { + if (reactions[membershipEventId]) { // We've still got a reaction from this user, ignore it to prevent spamming return reactions; } const timeout = setTimeout(() => { // Clear the reaction after some time. - setReactions(({ [sender]: _unused, ...remaining }) => remaining); + setReactions( + ({ [membershipEventId]: _unused, ...remaining }) => remaining, + ); reactionTimeouts.delete(timeout); }, REACTION_ACTIVE_TIME_MS); reactionTimeouts.add(timeout); return { ...reactions, - [sender]: reaction, + [membershipEventId]: reaction, }; }); } else if (event.getType() === EventType.Reaction) { @@ -274,9 +276,9 @@ export const ReactionsProvider = ({ } if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(sender, { + addRaisedHand(membershipEventId, { reactionEventId, - membershipEventId, + sender, time: new Date(event.localTimestamp), }); } @@ -316,11 +318,22 @@ export const ReactionsProvider = ({ latestRaisedHands, ]); + const myMembershipEventId = useMemo( + () => + memberships.find( + (m) => + clientState?.state === "valid" && + m.sender === clientState.authenticated?.client.getUserId() && + m.deviceId === clientState.authenticated?.client.getDeviceId(), + ), + [memberships], + )?.eventId; + const lowerHand = useCallback(async () => { - if (!myUserId || !raisedHands[myUserId]) { + if (!myMembershipEventId || !raisedHands[myMembershipEventId]) { return; } - const myReactionId = raisedHands[myUserId].reactionEventId; + const myReactionId = raisedHands[myMembershipEventId].reactionEventId; if (!myReactionId) { logger.warn(`Hand raised but no reaction event to redact!`); return; @@ -331,7 +344,7 @@ export const ReactionsProvider = ({ } catch (ex) { logger.error("Failed to redact reaction event", myReactionId, ex); } - }, [myUserId, raisedHands, rtcSession, room]); + }, [myMembershipEventId, raisedHands, rtcSession, room]); return ( Date; }[]; @@ -56,6 +57,7 @@ export class MockRTCSession extends EventEmitter { this.memberships = Object.entries(membership).map(([eventId, sender]) => ({ sender, eventId, + deviceId: randomUUID(), createdTs: (): Date => new Date(), })); } @@ -69,6 +71,7 @@ export class MockRTCSession extends EventEmitter { this.memberships.push({ sender, eventId: `!fake-${randomUUID()}:event`, + deviceId: randomUUID(), createdTs: (): Date => new Date(), }); this.emit(MatrixRTCSessionEvent.MembershipsChanged); @@ -118,6 +121,7 @@ export class MockRoom extends EventEmitter { public constructor( private readonly ownUserId: string, + private readonly ownDeviceId: string, private readonly existingRelations: MatrixEvent[] = [], ) { super(); @@ -126,6 +130,7 @@ export class MockRoom extends EventEmitter { public get client(): MatrixClient { return { getUserId: (): string => this.ownUserId, + getDeviceId: (): string => this.ownDeviceId, sendEvent: async ( ...props: Parameters ): ReturnType => {