Begin moving over to membershipEvents

This commit is contained in:
Half-Shot
2024-11-08 17:04:10 +00:00
parent 1a95c576e0
commit dbad2b3902
4 changed files with 94 additions and 55 deletions

View File

@@ -224,26 +224,34 @@ export function ReactionToggleButton({
const { raisedHands, lowerHand, reactions } = useReactions(); const { raisedHands, lowerHand, reactions } = useReactions();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const userId = client.getUserId()!; const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const myMembershipEventId = useMemo(
() =>
memberships.find(
(m) =>
m.sender === client.getUserId() &&
m.deviceId === client.getDeviceId(),
),
[memberships],
)?.eventId;
useEffect(() => { useEffect(() => {
// Clear whenever the reactions menu state changes. // Clear whenever the reactions menu state changes.
setErrorText(undefined); setErrorText(undefined);
}, [showReactionsMenu]); }, [showReactionsMenu]);
const canReact = !reactions[userId]; const canReact = !reactions[myMembershipEventId ?? ""];
const isHandRaised = !!raisedHands[myMembershipEventId ?? ""];
const sendRelation = useCallback( const sendRelation = useCallback(
async (reaction: ReactionOption) => { async (reaction: ReactionOption) => {
try { try {
const myMembership = memberships.find((m) => m.sender === userId); if (!myMembershipEventId) {
if (!myMembership?.eventId) { throw Error("Could not find own membership event");
throw new Error("Cannot find own membership event");
} }
const parentEventId = myMembership.eventId;
setBusy(true); setBusy(true);
await client.sendEvent( await client.sendEvent(
rtcSession.room.roomId, rtcSession.room.roomId,
@@ -251,7 +259,7 @@ export function ReactionToggleButton({
{ {
"m.relates_to": { "m.relates_to": {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
event_id: parentEventId, event_id: myMembershipEventId,
}, },
emoji: reaction.emoji, emoji: reaction.emoji,
name: reaction.name, name: reaction.name,
@@ -266,7 +274,7 @@ export function ReactionToggleButton({
setBusy(false); setBusy(false);
} }
}, },
[memberships, client, userId, rtcSession], [myMembershipEventId, client, rtcSession],
); );
const toggleRaisedHand = useCallback(() => { const toggleRaisedHand = useCallback(() => {
@@ -281,11 +289,9 @@ export function ReactionToggleButton({
} }
} else { } else {
try { try {
const myMembership = memberships.find((m) => m.sender === userId); if (!myMembershipEventId) {
if (!myMembership?.eventId) { throw Error("Could not find own membership event");
throw new Error("Cannot find own membership event");
} }
const parentEventId = myMembership.eventId;
setBusy(true); setBusy(true);
const reaction = await client.sendEvent( const reaction = await client.sendEvent(
rtcSession.room.roomId, rtcSession.room.roomId,
@@ -293,7 +299,7 @@ export function ReactionToggleButton({
{ {
"m.relates_to": { "m.relates_to": {
rel_type: RelationType.Annotation, rel_type: RelationType.Annotation,
event_id: parentEventId, event_id: myMembershipEventId,
key: "🖐️", key: "🖐️",
}, },
}, },
@@ -314,10 +320,9 @@ export function ReactionToggleButton({
}, [ }, [
client, client,
isHandRaised, isHandRaised,
memberships, myMembershipEventId,
lowerHand, lowerHand,
rtcSession.room.roomId, rtcSession.room.roomId,
userId,
]); ]);
return ( return (

View File

@@ -21,6 +21,7 @@ import {
const memberUserIdAlice = "@alice:example.org"; const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org"; const memberEventAlice = "$membership-alice:example.org";
const memberEventAlice2 = "$membership-alice2:example.org";
const memberUserIdBob = "@bob:example.org"; const memberUserIdBob = "@bob:example.org";
const memberEventBob = "$membership-bob:example.org"; const memberEventBob = "$membership-bob:example.org";
@@ -28,6 +29,8 @@ const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice, [memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob, [memberEventBob]: memberUserIdBob,
"$membership-charlie:example.org": "@charlie:example.org", "$membership-charlie:example.org": "@charlie:example.org",
// Second device
[memberEventAlice2]: memberEventAlice,
}; };
/** /**
@@ -42,9 +45,9 @@ const TestComponent: FC = () => {
return ( return (
<div> <div>
<ul> <ul>
{Object.entries(raisedHands).map(([userId, date]) => ( {Object.entries(raisedHands).map(([membershipEventId, date]) => (
<li key={userId}> <li key={membershipEventId}>
<span>{userId}</span> <span>{membershipEventId}</span>
<time>{date.getTime()}</time> <time>{date.getTime()}</time>
</li> </li>
))} ))}
@@ -170,4 +173,17 @@ describe("useReactions", () => {
await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob)); await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob));
expect(queryByRole("list")?.children).to.have.lengthOf(0); 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(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
}); });

View File

@@ -48,9 +48,9 @@ const ReactionsContext = createContext<ReactionsContextType | undefined>(
interface RaisedHandInfo { 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. * Event ID of the reaction itself.
*/ */
@@ -71,6 +71,8 @@ export const useReactions = (): ReactionsContextType => {
return context; return context;
}; };
type MembershipEventId = string;
/** /**
* Provider that handles raised hand reactions for a given `rtcSession`. * Provider that handles raised hand reactions for a given `rtcSession`.
*/ */
@@ -82,7 +84,7 @@ export const ReactionsProvider = ({
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
}): JSX.Element => { }): JSX.Element => {
const [raisedHands, setRaisedHands] = useState< const [raisedHands, setRaisedHands] = useState<
Record<string, RaisedHandInfo> Record<MembershipEventId, RaisedHandInfo>
>({}); >({});
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState(); const clientState = useClientState();
@@ -91,29 +93,36 @@ export const ReactionsProvider = ({
const room = rtcSession.room; const room = rtcSession.room;
const myUserId = room.client.getUserId(); const myUserId = room.client.getUserId();
const [reactions, setReactions] = useState<Record<string, ReactionOption>>( const [reactions, setReactions] = useState<
{}, Record<MembershipEventId, ReactionOption>
); >({});
// Reduce the data down for the consumers. // 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(([membershipEventId, data]) => [
membershipEventId,
data.time,
]),
), ),
[raisedHands], [raisedHands],
); );
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { const addRaisedHand = useCallback(
setRaisedHands((prevRaisedHands) => ({ (membershipEventId: string, info: RaisedHandInfo) => {
...prevRaisedHands, setRaisedHands((prevRaisedHands) => ({
[userId]: info, ...prevRaisedHands,
})); [membershipEventId]: info,
}, []); }));
},
[],
);
const removeRaisedHand = useCallback((userId: string) => { const removeRaisedHand = useCallback((membershipEventId: string) => {
setRaisedHands( 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 previousMemberEventId of Object.keys(raisedHands).filter(
for (const userId of Object.keys(raisedHands).filter( (eventId) => !memberships.some((m) => m.eventId === eventId),
(rhId) => !memberships.find((u) => u.sender == rhId),
)) { )) {
removeRaisedHand(userId); removeRaisedHand(previousMemberEventId);
} }
// For each member in the call, check to see if a reaction has // 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) { if (!m.sender || !m.eventId) {
continue; 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); const reaction = getLastReactionEvent(m.eventId, m.sender);
if (reaction) { if (reaction) {
const eventId = reaction?.getId(); const eventId = reaction?.getId();
if (!eventId) { if (!eventId) {
continue; continue;
} }
addRaisedHand(m.sender, { addRaisedHand(m.eventId, {
membershipEventId: m.eventId, sender: m.sender,
reactionEventId: eventId, reactionEventId: eventId,
time: new Date(reaction.localTimestamp), time: new Date(reaction.localTimestamp),
}); });
@@ -241,19 +241,21 @@ export const ReactionsProvider = ({
}; };
setReactions((reactions) => { setReactions((reactions) => {
if (reactions[sender]) { if (reactions[membershipEventId]) {
// We've still got a reaction from this user, ignore it to prevent spamming // We've still got a reaction from this user, ignore it to prevent spamming
return reactions; return reactions;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
// Clear the reaction after some time. // Clear the reaction after some time.
setReactions(({ [sender]: _unused, ...remaining }) => remaining); setReactions(
({ [membershipEventId]: _unused, ...remaining }) => remaining,
);
reactionTimeouts.delete(timeout); reactionTimeouts.delete(timeout);
}, REACTION_ACTIVE_TIME_MS); }, REACTION_ACTIVE_TIME_MS);
reactionTimeouts.add(timeout); reactionTimeouts.add(timeout);
return { return {
...reactions, ...reactions,
[sender]: reaction, [membershipEventId]: reaction,
}; };
}); });
} else if (event.getType() === EventType.Reaction) { } else if (event.getType() === EventType.Reaction) {
@@ -274,9 +276,9 @@ export const ReactionsProvider = ({
} }
if (content?.["m.relates_to"].key === "🖐️") { if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(sender, { addRaisedHand(membershipEventId, {
reactionEventId, reactionEventId,
membershipEventId, sender,
time: new Date(event.localTimestamp), time: new Date(event.localTimestamp),
}); });
} }
@@ -316,11 +318,22 @@ export const ReactionsProvider = ({
latestRaisedHands, 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 () => { const lowerHand = useCallback(async () => {
if (!myUserId || !raisedHands[myUserId]) { if (!myMembershipEventId || !raisedHands[myMembershipEventId]) {
return; return;
} }
const myReactionId = raisedHands[myUserId].reactionEventId; const myReactionId = raisedHands[myMembershipEventId].reactionEventId;
if (!myReactionId) { if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`); logger.warn(`Hand raised but no reaction event to redact!`);
return; return;
@@ -331,7 +344,7 @@ export const ReactionsProvider = ({
} catch (ex) { } catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex); logger.error("Failed to redact reaction event", myReactionId, ex);
} }
}, [myUserId, raisedHands, rtcSession, room]); }, [myMembershipEventId, raisedHands, rtcSession, room]);
return ( return (
<ReactionsContext.Provider <ReactionsContext.Provider

View File

@@ -45,6 +45,7 @@ export class MockRTCSession extends EventEmitter {
public memberships: { public memberships: {
sender: string; sender: string;
eventId: string; eventId: string;
deviceId: string;
createdTs: () => Date; createdTs: () => Date;
}[]; }[];
@@ -56,6 +57,7 @@ export class MockRTCSession extends EventEmitter {
this.memberships = Object.entries(membership).map(([eventId, sender]) => ({ this.memberships = Object.entries(membership).map(([eventId, sender]) => ({
sender, sender,
eventId, eventId,
deviceId: randomUUID(),
createdTs: (): Date => new Date(), createdTs: (): Date => new Date(),
})); }));
} }
@@ -69,6 +71,7 @@ export class MockRTCSession extends EventEmitter {
this.memberships.push({ this.memberships.push({
sender, sender,
eventId: `!fake-${randomUUID()}:event`, eventId: `!fake-${randomUUID()}:event`,
deviceId: randomUUID(),
createdTs: (): Date => new Date(), createdTs: (): Date => new Date(),
}); });
this.emit(MatrixRTCSessionEvent.MembershipsChanged); this.emit(MatrixRTCSessionEvent.MembershipsChanged);
@@ -118,6 +121,7 @@ export class MockRoom extends EventEmitter {
public constructor( public constructor(
private readonly ownUserId: string, private readonly ownUserId: string,
private readonly ownDeviceId: string,
private readonly existingRelations: MatrixEvent[] = [], private readonly existingRelations: MatrixEvent[] = [],
) { ) {
super(); super();
@@ -126,6 +130,7 @@ export class MockRoom extends EventEmitter {
public get client(): MatrixClient { public get client(): MatrixClient {
return { return {
getUserId: (): string => this.ownUserId, getUserId: (): string => this.ownUserId,
getDeviceId: (): string => this.ownDeviceId,
sendEvent: async ( sendEvent: async (
...props: Parameters<MatrixClient["sendEvent"]> ...props: Parameters<MatrixClient["sendEvent"]>
): ReturnType<MatrixClient["sendEvent"]> => { ): ReturnType<MatrixClient["sendEvent"]> => {