Hand raise feature (#2542)
* Initial support for Hand Raise feature Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Refactored to use reaction and redaction events Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Replacing button svg with raised hand emoji Signed-off-by: Milton Moura <miltonmoura@gmail.com> * SpotlightTile should not duplicate the raised hand Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Update src/room/useRaisedHands.tsx Element Call recently changed to AGPL-3.0 * Use relations to load existing reactions when joining the call Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Links to sha commit of matrix-js-sdk that exposes the call membership event id and refactors some async code Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Removing RaiseHand.svg * Check for reaction & redaction capabilities in widget mode Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Fix failing GridTile test Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Center align hand raise. * Add support for displaying the duration of a raised hand. * Add a sound for when a hand is raised. * Refactor raised hand indicator and add tests. * lint * Refactor into own files. * Redact the right thing. * Tidy up useEffect * Lint tests * Remove extra layer * Add better sound. (woosh) * Add a small mode for spotlight * Fix timestamp calculation on relaod. * Fix call border resizing video * lint * Fix and update tests * Allow timer to be configurable. * Add preferences tab for choosing to enable timer. * Drop border from raised hand icon * Handle cases when a new member event happens. * Prevent infinite loop * Major refactor to support various state problems. * Tidy up and finish test rewrites * Add some explanation comments. * Even more comments. * Use proper duration formatter * Remove rerender * Fix redactions not working because they pick up events in transit. * More tidying * Use deferred value * linting * Add tests for cases where we got a reaction from someone else. * Be even less brittle. * Transpose border to GridTile. * lint --------- Signed-off-by: Milton Moura <miltonmoura@gmail.com> Co-authored-by: fkwp <fkwp@users.noreply.github.com> Co-authored-by: Half-Shot <will@half-shot.uk> Co-authored-by: Will Hunt <github@half-shot.uk>
This commit is contained in:
249
src/useReactions.tsx
Normal file
249
src/useReactions.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
Copyright 2024 Milton Moura <miltonmoura@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { ReactionEventContent } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "./ClientContext";
|
||||
|
||||
interface ReactionsContextType {
|
||||
raisedHands: Record<string, Date>;
|
||||
supportsReactions: boolean;
|
||||
myReactionId: string | null;
|
||||
}
|
||||
|
||||
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface RaisedHandInfo {
|
||||
/**
|
||||
* Call membership event that was reacted to.
|
||||
*/
|
||||
membershipEventId: string;
|
||||
/**
|
||||
* Event ID of the reaction itself.
|
||||
*/
|
||||
reactionEventId: string;
|
||||
/**
|
||||
* The time when the reaction was raised.
|
||||
*/
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export const useReactions = (): ReactionsContextType => {
|
||||
const context = useContext(ReactionsContext);
|
||||
if (!context) {
|
||||
throw new Error("useReactions must be used within a ReactionsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider that handles raised hand reactions for a given `rtcSession`.
|
||||
*/
|
||||
export const ReactionsProvider = ({
|
||||
children,
|
||||
rtcSession,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
rtcSession: MatrixRTCSession;
|
||||
}): JSX.Element => {
|
||||
const [raisedHands, setRaisedHands] = useState<
|
||||
Record<string, RaisedHandInfo>
|
||||
>({});
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const clientState = useClientState();
|
||||
const supportsReactions =
|
||||
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
|
||||
const room = rtcSession.room;
|
||||
const myUserId = room.client.getUserId();
|
||||
|
||||
// Calculate our own reaction event.
|
||||
const myReactionId = useMemo(
|
||||
(): string | null =>
|
||||
(myUserId && raisedHands[myUserId]?.reactionEventId) ?? null,
|
||||
[raisedHands, myUserId],
|
||||
);
|
||||
|
||||
// Reduce the data down for the consumers.
|
||||
const resultRaisedHands = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
|
||||
),
|
||||
[raisedHands],
|
||||
);
|
||||
|
||||
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
||||
setRaisedHands((prevRaisedHands) => ({
|
||||
...prevRaisedHands,
|
||||
[userId]: info,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeRaisedHand = useCallback((userId: string) => {
|
||||
setRaisedHands(
|
||||
({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// This effect will check the state whenever the membership of the session changes.
|
||||
useEffect(() => {
|
||||
// Fetches the first reaction for a given event.
|
||||
const getLastReactionEvent = (
|
||||
eventId: string,
|
||||
expectedSender: string,
|
||||
): MatrixEvent | undefined => {
|
||||
const relations = room.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
);
|
||||
const allEvents = relations?.getRelations() ?? [];
|
||||
return allEvents.find(
|
||||
(reaction) =>
|
||||
reaction.event.sender === expectedSender &&
|
||||
reaction.getType() === EventType.Reaction &&
|
||||
reaction.getContent()?.["m.relates_to"]?.key === "🖐️",
|
||||
);
|
||||
};
|
||||
|
||||
// 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 each member in the call, check to see if a reaction has
|
||||
// been raised and adjust.
|
||||
for (const m of memberships) {
|
||||
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,
|
||||
reactionEventId: eventId,
|
||||
time: new Date(reaction.localTimestamp),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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]);
|
||||
|
||||
// This effect handles any *live* reaction/redactions in the room.
|
||||
useEffect(() => {
|
||||
const handleReactionEvent = (event: MatrixEvent): void => {
|
||||
if (event.isSending()) {
|
||||
// Skip any events that are still sending.
|
||||
return;
|
||||
}
|
||||
|
||||
const sender = event.getSender();
|
||||
const reactionEventId = event.getId();
|
||||
if (!sender || !reactionEventId) {
|
||||
// Skip any event without a sender or event ID.
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getType() === EventType.Reaction) {
|
||||
const content = event.getContent() as ReactionEventContent;
|
||||
const membershipEventId = content["m.relates_to"].event_id;
|
||||
|
||||
// Check to see if this reaction was made to a membership event (and the
|
||||
// sender of the reaction matches the membership)
|
||||
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) {
|
||||
const targetEvent = event.event.redacts;
|
||||
const targetUser = Object.entries(raisedHands).find(
|
||||
([_u, r]) => r.reactionEventId === targetEvent,
|
||||
)?.[0];
|
||||
if (!targetUser) {
|
||||
// Reaction target was not for us, ignoring
|
||||
return;
|
||||
}
|
||||
removeRaisedHand(targetUser);
|
||||
}
|
||||
};
|
||||
|
||||
room.on(MatrixRoomEvent.Timeline, handleReactionEvent);
|
||||
room.on(MatrixRoomEvent.Redaction, handleReactionEvent);
|
||||
|
||||
// We listen for a local echo to get the real event ID, as timeline events
|
||||
// may still be sending.
|
||||
room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
||||
|
||||
return (): void => {
|
||||
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
|
||||
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
|
||||
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
||||
};
|
||||
}, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]);
|
||||
|
||||
return (
|
||||
<ReactionsContext.Provider
|
||||
value={{
|
||||
raisedHands: resultRaisedHands,
|
||||
supportsReactions,
|
||||
myReactionId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactionsContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user