Major refactor to support various state problems.

This commit is contained in:
Half-Shot
2024-10-29 16:19:01 +00:00
parent cd73ad8794
commit 5a5c1bedfe
5 changed files with 150 additions and 58 deletions

View File

@@ -110,7 +110,11 @@ export function RaiseHandToggleButton({
.then((reaction) => { .then((reaction) => {
logger.debug("Sent raise hand event", reaction.event_id); logger.debug("Sent raise hand event", reaction.event_id);
setMyReactionId(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) => { .catch((e) => {
logger.error("Failed to send reaction event", e); logger.error("Failed to send reaction event", e);

View File

@@ -174,7 +174,8 @@ export const InCallView: FC<InCallViewProps> = ({
connState, connState,
onShareClick, onShareClick,
}) => { }) => {
const { supportsReactions, raisedHandCount } = useReactions(); const { supportsReactions, raisedHands } = useReactions();
const raisedHandCount = Object.keys(raisedHands).length;
const [previousRaisedHandCount, setPreviousRaisedHandCount] = const [previousRaisedHandCount, setPreviousRaisedHandCount] =
useState(raisedHandCount); useState(raisedHandCount);

View File

@@ -33,6 +33,9 @@ test("GridTile is accessible", async () => {
room: { room: {
on: () => {}, on: () => {},
off: () => {}, off: () => {},
client: {
getUserId: () => null,
},
}, },
memberships: [], memberships: [],
} as unknown as MatrixRTCSession; } as unknown as MatrixRTCSession;

View File

@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { describe, expect, test } from "vitest"; 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 { import {
EventTimeline, EventTimeline,
EventTimelineSet, EventTimelineSet,
@@ -43,25 +46,44 @@ const TestComponent: FC = () => {
); );
}; };
const TestComponentWrapper = ({ room }: { room: MockRoom }): ReactNode => { const TestComponentWrapper = ({
const fakeRtcSession = { rtcSession,
on: () => {}, }: {
off: () => {}, rtcSession: MockRTCSession;
room, }): ReactNode => {
memberships: membership.map((sender) => ({
sender,
eventId: "!fake:event",
createdTs: (): Date => new Date(),
})),
} as unknown as MatrixRTCSession;
return ( return (
<ReactionsProvider rtcSession={fakeRtcSession}> <ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>
<TestComponent /> <TestComponent />
</ReactionsProvider> </ReactionsProvider>
); );
}; };
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 { function createReaction(sender: string): MatrixEvent {
return new MatrixEvent({ return new MatrixEvent({
sender, sender,
@@ -121,29 +143,33 @@ export class MockRoom extends EventEmitter {
describe("useReactions", () => { describe("useReactions", () => {
test("starts with an empty list", () => { test("starts with an empty list", () => {
const room = new MockRoom(); const rtcSession = new MockRTCSession(new MockRoom());
const { queryByRole } = render(<TestComponentWrapper room={room} />); const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
);
expect(queryByRole("list")?.children).to.have.lengthOf(0); expect(queryByRole("list")?.children).to.have.lengthOf(0);
}); });
test("handles incoming raised hand", () => { test("handles incoming raised hand", () => {
const room = new MockRoom(); const room = new MockRoom();
const rtcSession = new MockRTCSession(room);
const { queryByRole, rerender } = render( const { queryByRole, rerender } = render(
<TestComponentWrapper room={room} />, <TestComponentWrapper rtcSession={rtcSession} />,
); );
room.testSendReaction("@foo:bar"); room.testSendReaction("@foo:bar");
rerender(<TestComponentWrapper room={room} />); rerender(<TestComponentWrapper rtcSession={rtcSession} />);
expect(queryByRole("list")?.children).to.have.lengthOf(1); expect(queryByRole("list")?.children).to.have.lengthOf(1);
room.testSendReaction("@baz:bar"); room.testSendReaction("@baz:bar");
rerender(<TestComponentWrapper room={room} />); rerender(<TestComponentWrapper rtcSession={rtcSession} />);
expect(queryByRole("list")?.children).to.have.lengthOf(2); expect(queryByRole("list")?.children).to.have.lengthOf(2);
}); });
test("handles incoming unraised hand", () => { test("handles incoming unraised hand", () => {
const room = new MockRoom(); const room = new MockRoom();
const rtcSession = new MockRTCSession(room);
const { queryByRole, rerender } = render( const { queryByRole, rerender } = render(
<TestComponentWrapper room={room} />, <TestComponentWrapper rtcSession={rtcSession} />,
); );
room.testSendReaction("@foo:bar"); room.testSendReaction("@foo:bar");
rerender(<TestComponentWrapper room={room} />); rerender(<TestComponentWrapper rtcSession={rtcSession} />);
expect(queryByRole("list")?.children).to.have.lengthOf(1); expect(queryByRole("list")?.children).to.have.lengthOf(1);
room.emit( room.emit(
RoomEvent.Redaction, RoomEvent.Redaction,
@@ -151,12 +177,38 @@ describe("useReactions", () => {
room, room,
undefined, undefined,
); );
rerender(<TestComponentWrapper room={room} />); rerender(<TestComponentWrapper rtcSession={rtcSession} />);
expect(queryByRole("list")?.children).to.have.lengthOf(0); expect(queryByRole("list")?.children).to.have.lengthOf(0);
}); });
test("handles loading events from cold", () => { test("handles loading events from cold", () => {
const room = new MockRoom([createReaction(membership[0])]); const room = new MockRoom([createReaction(membership[0])]);
const { queryByRole } = render(<TestComponentWrapper room={room} />); const rtcSession = new MockRTCSession(room);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1); 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(
<TestComponentWrapper rtcSession={rtcSession} />,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
rtcSession.testRemoveMember(membership[0]);
rerender(<TestComponentWrapper rtcSession={rtcSession} />);
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(
<TestComponentWrapper rtcSession={rtcSession} />,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
rtcSession.testRemoveMember(membership[0]);
rtcSession.testAddMember(membership[0]);
rerender(<TestComponentWrapper rtcSession={rtcSession} />);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
}); });

View File

@@ -25,11 +25,11 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext"; import { useClientState } from "./ClientContext";
import { logger } from "matrix-js-sdk/src/logger";
interface ReactionsContextType { interface ReactionsContextType {
raisedHands: Record<string, Date>; raisedHands: Record<string, Date>;
raisedHandCount: number; addRaisedHand: (userId: string, info: RaisedHandInfo) => void;
addRaisedHand: (userId: string, parentEventId: string, date: Date) => void;
removeRaisedHand: (userId: string) => void; removeRaisedHand: (userId: string) => void;
supportsReactions: boolean; supportsReactions: boolean;
myReactionId: string | null; myReactionId: string | null;
@@ -40,6 +40,12 @@ const ReactionsContext = createContext<ReactionsContextType | undefined>(
undefined, undefined,
); );
interface RaisedHandInfo {
membershipEventId: string;
reactionEventId: string;
time: Date;
}
export const useReactions = (): ReactionsContextType => { export const useReactions = (): ReactionsContextType => {
const context = useContext(ReactionsContext); const context = useContext(ReactionsContext);
if (!context) { if (!context) {
@@ -56,32 +62,23 @@ export const ReactionsProvider = ({
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
}): JSX.Element => { }): JSX.Element => {
const [raisedHands, setRaisedHands] = useState< const [raisedHands, setRaisedHands] = useState<
Record< Record<string, RaisedHandInfo>
string,
{
time: Date;
parentEventId: string;
}
>
>({}); >({});
const [myReactionId, setMyReactionId] = useState<string | null>(null); const [myReactionId, setMyReactionId] = useState<string | null>(null);
const [raisedHandCount, setRaisedHandCount] = useState(0);
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState(); const clientState = useClientState();
const supportsReactions = const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions; clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room; const room = rtcSession.room;
const myUserId = room.client.getUserId();
const addRaisedHand = useCallback( const addRaisedHand = useCallback(
(userId: string, parentEventId: string, time: Date) => { (userId: string, info: RaisedHandInfo) => {
setRaisedHands({ setRaisedHands({
...raisedHands, ...raisedHands,
[userId]: { [userId]: info,
time,
parentEventId,
},
}); });
setRaisedHandCount(Object.keys(raisedHands).length + 1);
}, },
[raisedHands], [raisedHands],
); );
@@ -89,11 +86,10 @@ export const ReactionsProvider = ({
const removeRaisedHand = useCallback( const removeRaisedHand = useCallback(
(userId: string) => { (userId: string) => {
delete raisedHands[userId]; delete raisedHands[userId];
if (userId) { if (userId === myUserId) {
setMyReactionId(null); setMyReactionId(null);
} }
setRaisedHands(raisedHands); setRaisedHands({ ...raisedHands });
setRaisedHandCount(Object.keys(raisedHands).length);
}, },
[raisedHands], [raisedHands],
); );
@@ -110,13 +106,21 @@ export const ReactionsProvider = ({
return allEvents.length > 0 ? allEvents[0] : undefined; 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) { for (const m of memberships) {
if (!m.sender || !m.eventId) { if (!m.sender || !m.eventId) {
continue; continue;
} }
if ( if (
raisedHands[m.sender] && raisedHands[m.sender] &&
raisedHands[m.sender].parentEventId !== m.eventId raisedHands[m.sender].membershipEventId !== m.eventId
) { ) {
// Membership event for sender has changed. // Membership event for sender has changed.
removeRaisedHand(m.sender); removeRaisedHand(m.sender);
@@ -129,13 +133,19 @@ export const ReactionsProvider = ({
if (reaction && reaction.getType() === EventType.Reaction) { if (reaction && reaction.getType() === EventType.Reaction) {
const content = reaction.getContent() as ReactionEventContent; const content = reaction.getContent() as ReactionEventContent;
if (content?.["m.relates_to"]?.key === "🖐️") { 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()) { if (m.sender === room.client.getUserId()) {
setMyReactionId(eventId); setMyReactionId(eventId);
} }
} }
} }
} }
console.log("After", raisedHands);
// Deliberately ignoring addRaisedHand, raisedHands which was causing looping. // Deliberately ignoring addRaisedHand, raisedHands which was causing looping.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, memberships]); }, [room, memberships]);
@@ -143,23 +153,45 @@ export const ReactionsProvider = ({
useEffect(() => { useEffect(() => {
const handleReactionEvent = (event: MatrixEvent): void => { const handleReactionEvent = (event: MatrixEvent): void => {
const sender = event.getSender(); const sender = event.getSender();
if (!sender) { const reactionEventId = event.getId();
// Skip any event without a sender. if (!sender || !reactionEventId) {
// Skip any event without a sender or event ID.
return; return;
} }
if (event.getType() === EventType.Reaction) { if (event.getType() === EventType.Reaction) {
// TODO: check if target of reaction is a call membership event
const content = event.getContent() as ReactionEventContent; const content = event.getContent() as ReactionEventContent;
if (content?.["m.relates_to"].key === "🖐️") { const membershipEventId = content["m.relates_to"].event_id;
addRaisedHand(
sender, if (
content["m.relates_to"].event_id, !memberships.some(
new Date(event.localTimestamp), (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) { } else if (event.getType() === EventType.RoomRedaction) {
// TODO: check target of redaction event const targetEvent = event.event.redacts;
removeRaisedHand(sender); 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]); }, [room, addRaisedHand, removeRaisedHand]);
// Reduce the data down for the consumers.
const resultRaisedHands = useMemo( const resultRaisedHands = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
), ),
[raisedHands, raisedHandCount], [raisedHands],
); );
return ( return (
<ReactionsContext.Provider <ReactionsContext.Provider
value={{ value={{
raisedHands: resultRaisedHands, raisedHands: resultRaisedHands,
raisedHandCount,
addRaisedHand, addRaisedHand,
removeRaisedHand, removeRaisedHand,
supportsReactions, supportsReactions,