diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx index 1f5d292e..fe0c300b 100644 --- a/src/button/RaisedHandToggleButton.tsx +++ b/src/button/RaisedHandToggleButton.tsx @@ -110,7 +110,11 @@ export function RaiseHandToggleButton({ .then((reaction) => { logger.debug("Sent raise hand event", reaction.event_id); setMyReactionId(reaction.event_id); - addRaisedHand(userId, parentEventId, new Date()); + addRaisedHand(userId, { + membershipEventId: parentEventId, + reactionEventId: reaction.event_id, + time: new Date(), + }); }) .catch((e) => { logger.error("Failed to send reaction event", e); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 239c94e2..3051b80d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -174,7 +174,8 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { - const { supportsReactions, raisedHandCount } = useReactions(); + const { supportsReactions, raisedHands } = useReactions(); + const raisedHandCount = Object.keys(raisedHands).length; const [previousRaisedHandCount, setPreviousRaisedHandCount] = useState(raisedHandCount); diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 9ba92afb..0bf6cab8 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -33,6 +33,9 @@ test("GridTile is accessible", async () => { room: { on: () => {}, off: () => {}, + client: { + getUserId: () => null, + }, }, memberships: [], } as unknown as MatrixRTCSession; diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index 2d27cf6d..38df4e96 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details. import { render } from "@testing-library/react"; import { FC, ReactNode } from "react"; import { describe, expect, test } from "vitest"; -import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { EventTimeline, EventTimelineSet, @@ -43,25 +46,44 @@ const TestComponent: FC = () => { ); }; -const TestComponentWrapper = ({ room }: { room: MockRoom }): ReactNode => { - const fakeRtcSession = { - on: () => {}, - off: () => {}, - room, - memberships: membership.map((sender) => ({ - sender, - eventId: "!fake:event", - createdTs: (): Date => new Date(), - })), - } as unknown as MatrixRTCSession; - +const TestComponentWrapper = ({ + rtcSession, +}: { + rtcSession: MockRTCSession; +}): ReactNode => { return ( - + ); }; +export class MockRTCSession extends EventEmitter { + public memberships = membership.map((sender) => ({ + sender, + eventId: `!fake-${randomUUID()}:event`, + createdTs: (): Date => new Date(), + })); + + public constructor(public readonly room: MockRoom) { + super(); + } + + public testRemoveMember(userId: string) { + this.memberships = this.memberships.filter((u) => u.sender !== userId); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } + + public testAddMember(sender: string) { + this.memberships.push({ + sender, + eventId: `!fake-${randomUUID()}:event`, + createdTs: (): Date => new Date(), + }); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } +} + function createReaction(sender: string): MatrixEvent { return new MatrixEvent({ sender, @@ -121,29 +143,33 @@ export class MockRoom extends EventEmitter { describe("useReactions", () => { test("starts with an empty list", () => { - const room = new MockRoom(); - const { queryByRole } = render(); + const rtcSession = new MockRTCSession(new MockRoom()); + const { queryByRole } = render( + , + ); expect(queryByRole("list")?.children).to.have.lengthOf(0); }); test("handles incoming raised hand", () => { const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); const { queryByRole, rerender } = render( - , + , ); room.testSendReaction("@foo:bar"); - rerender(); + rerender(); expect(queryByRole("list")?.children).to.have.lengthOf(1); room.testSendReaction("@baz:bar"); - rerender(); + rerender(); expect(queryByRole("list")?.children).to.have.lengthOf(2); }); test("handles incoming unraised hand", () => { const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); const { queryByRole, rerender } = render( - , + , ); room.testSendReaction("@foo:bar"); - rerender(); + rerender(); expect(queryByRole("list")?.children).to.have.lengthOf(1); room.emit( RoomEvent.Redaction, @@ -151,12 +177,38 @@ describe("useReactions", () => { room, undefined, ); - rerender(); + rerender(); expect(queryByRole("list")?.children).to.have.lengthOf(0); }); test("handles loading events from cold", () => { const room = new MockRoom([createReaction(membership[0])]); - const { queryByRole } = render(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); expect(queryByRole("list")?.children).to.have.lengthOf(1); }); + test.only("will remove reaction when a member leaves the call", () => { + const room = new MockRoom([createReaction(membership[0])]); + const rtcSession = new MockRTCSession(room); + const { queryByRole, rerender } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + rtcSession.testRemoveMember(membership[0]); + rerender(); + 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(membership[0])]); + const rtcSession = new MockRTCSession(room); + const { queryByRole, rerender } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + rtcSession.testRemoveMember(membership[0]); + rtcSession.testAddMember(membership[0]); + rerender(); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); }); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 623d497a..d0680257 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -25,11 +25,11 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; import { useClientState } from "./ClientContext"; +import { logger } from "matrix-js-sdk/src/logger"; interface ReactionsContextType { raisedHands: Record; - raisedHandCount: number; - addRaisedHand: (userId: string, parentEventId: string, date: Date) => void; + addRaisedHand: (userId: string, info: RaisedHandInfo) => void; removeRaisedHand: (userId: string) => void; supportsReactions: boolean; myReactionId: string | null; @@ -40,6 +40,12 @@ const ReactionsContext = createContext( undefined, ); +interface RaisedHandInfo { + membershipEventId: string; + reactionEventId: string; + time: Date; +} + export const useReactions = (): ReactionsContextType => { const context = useContext(ReactionsContext); if (!context) { @@ -56,32 +62,23 @@ export const ReactionsProvider = ({ rtcSession: MatrixRTCSession; }): JSX.Element => { const [raisedHands, setRaisedHands] = useState< - Record< - string, - { - time: Date; - parentEventId: string; - } - > + Record >({}); const [myReactionId, setMyReactionId] = useState(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 myUserId = room.client.getUserId(); + const addRaisedHand = useCallback( - (userId: string, parentEventId: string, time: Date) => { + (userId: string, info: RaisedHandInfo) => { setRaisedHands({ ...raisedHands, - [userId]: { - time, - parentEventId, - }, + [userId]: info, }); - setRaisedHandCount(Object.keys(raisedHands).length + 1); }, [raisedHands], ); @@ -89,11 +86,10 @@ export const ReactionsProvider = ({ const removeRaisedHand = useCallback( (userId: string) => { delete raisedHands[userId]; - if (userId) { + if (userId === myUserId) { setMyReactionId(null); } - setRaisedHands(raisedHands); - setRaisedHandCount(Object.keys(raisedHands).length); + setRaisedHands({ ...raisedHands }); }, [raisedHands], ); @@ -110,13 +106,21 @@ export const ReactionsProvider = ({ return allEvents.length > 0 ? allEvents[0] : undefined; }; + console.log(memberships, raisedHands); + // 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 (const m of memberships) { if (!m.sender || !m.eventId) { continue; } if ( raisedHands[m.sender] && - raisedHands[m.sender].parentEventId !== m.eventId + raisedHands[m.sender].membershipEventId !== m.eventId ) { // Membership event for sender has changed. removeRaisedHand(m.sender); @@ -129,13 +133,19 @@ export const ReactionsProvider = ({ if (reaction && reaction.getType() === EventType.Reaction) { const content = reaction.getContent() as ReactionEventContent; if (content?.["m.relates_to"]?.key === "🖐️") { - addRaisedHand(m.sender, m.eventId, new Date(reaction.localTimestamp)); + console.log("found key, raising hand", m.sender); + addRaisedHand(m.sender, { + membershipEventId: m.eventId, + reactionEventId: eventId, + time: new Date(reaction.localTimestamp), + }); if (m.sender === room.client.getUserId()) { setMyReactionId(eventId); } } } } + console.log("After", raisedHands); // Deliberately ignoring addRaisedHand, raisedHands which was causing looping. // eslint-disable-next-line react-hooks/exhaustive-deps }, [room, memberships]); @@ -143,23 +153,45 @@ export const ReactionsProvider = ({ useEffect(() => { const handleReactionEvent = (event: MatrixEvent): void => { const sender = event.getSender(); - if (!sender) { - // Skip any event without a sender. + const reactionEventId = event.getId(); + if (!sender || !reactionEventId) { + // Skip any event without a sender or event ID. 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, - content["m.relates_to"].event_id, - new Date(event.localTimestamp), + const membershipEventId = content["m.relates_to"].event_id; + + 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) { - // TODO: check target of redaction event - removeRaisedHand(sender); + const targetEvent = event.event.redacts; + const targetUser = Object.entries(raisedHands).find( + ([u, r]) => r.reactionEventId === targetEvent, + )?.[0]; + console.log(targetEvent, raisedHands); + if (!targetUser) { + // Reaction target was not for us, ignoring + return; + } + removeRaisedHand(targetUser); } }; @@ -172,19 +204,19 @@ export const ReactionsProvider = ({ }; }, [room, addRaisedHand, removeRaisedHand]); + // Reduce the data down for the consumers. const resultRaisedHands = useMemo( () => Object.fromEntries( Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), ), - [raisedHands, raisedHandCount], + [raisedHands], ); return (