* 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. * First PoC for reactions * hide menu by default * Add lightbulb. * Add reaction indicator. * Add sounds. * Tidy up + add support for floating emoji. * Linting and general stability improvements. * Subscribe to the ecall reaction event type. * fix import * Center emoji picker * Overflow buttons when screen is too narrow * lint * Add settings for disabling animations / sounds. * Make vertical divider more visually distinct. * Make event listener more resillient. * lint * Fix some tests. * Remove old raised hand component * Add new icon * Update text * Update compound hand raised icon. * Add deer. * Fix case where you could send larger strings as emoji * Const the active time. * Document time in css. * Add rock emoji * Add licence file. * Add type def for custom reaction type. * better reaction description * Factor out reactions test structure to utils file. * Add tests for ReactionToggleButton * Add keyboard shortcuts for reaction sending. * type tidyups * lint * Add tests for ReactionAudioRenderer * lint * prettier * i18n sort * final lint? * Preload reaction sounds to prevent delays. * Update rock sounds * add onclick back * Fix test * lint * simplify * Tweak line height * modal impl * Modal refactor attempts. * Remove closed menu test since we're using Modal. * Swap icon, make mobile view better. * Fix mobile view for emoji picker. * Use Intl.Segmenter * Clear timeouts on component close. * Remove useless useCallback * Use prefers-reduced-motion * Add toggle for raise hand. * Add lower hand text * Add lower motion mode. * Decomplicate className system for Modal * Add error for failured to send reaction. * i18n * Spacing for emoji buttons search * Remove unrequired media query * Fix generic sound not playing. * Clear reactions if we're clearing timeouts. * Fix tests * Relabel lower hand * More translations * Add comments on reaction interface * Move polyfill. * lint * Replace deer sound * Another attempt to fix the sizing of the reactions * cleanup * fix button * fix --------- Signed-off-by: Milton Moura <miltonmoura@gmail.com> Co-authored-by: Milton Moura <miltonmoura@gmail.com> Co-authored-by: fkwp <fkwp@users.noreply.github.com>
349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
/*
|
|
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";
|
|
import {
|
|
ECallReactionEventContent,
|
|
ElementCallReactionEventType,
|
|
GenericReaction,
|
|
ReactionOption,
|
|
ReactionSet,
|
|
} from "./reactions";
|
|
import { useLatest } from "./useLatest";
|
|
|
|
interface ReactionsContextType {
|
|
raisedHands: Record<string, Date>;
|
|
supportsReactions: boolean;
|
|
reactions: Record<string, ReactionOption>;
|
|
lowerHand: () => Promise<void>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const REACTION_ACTIVE_TIME_MS = 3000;
|
|
|
|
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();
|
|
|
|
const [reactions, setReactions] = useState<Record<string, ReactionOption>>(
|
|
{},
|
|
);
|
|
|
|
// 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]);
|
|
|
|
const latestMemberships = useLatest(memberships);
|
|
const latestRaisedHands = useLatest(raisedHands);
|
|
|
|
// This effect handles any *live* reaction/redactions in the room.
|
|
useEffect(() => {
|
|
const reactionTimeouts = new Set<NodeJS.Timeout>();
|
|
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() === ElementCallReactionEventType) {
|
|
const content: ECallReactionEventContent = event.getContent();
|
|
|
|
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 (
|
|
!latestMemberships.current.some(
|
|
(e) => e.eventId === membershipEventId && e.sender === sender,
|
|
)
|
|
) {
|
|
logger.warn(
|
|
`Reaction target was not a membership event for ${sender}, ignoring`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!content.emoji) {
|
|
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
|
|
return;
|
|
}
|
|
|
|
const segment = new Intl.Segmenter(undefined, {
|
|
granularity: "grapheme",
|
|
})
|
|
.segment(content.emoji)
|
|
[Symbol.iterator]();
|
|
const emoji = segment.next().value?.segment;
|
|
|
|
if (!emoji) {
|
|
logger.warn(
|
|
`Reaction had no emoji from ${reactionEventId} after splitting`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// One of our custom reactions
|
|
const reaction = {
|
|
...GenericReaction,
|
|
emoji,
|
|
// If we don't find a reaction, we can fallback to the generic sound.
|
|
...ReactionSet.find((r) => r.name === content.name),
|
|
};
|
|
|
|
setReactions((reactions) => {
|
|
if (reactions[sender]) {
|
|
// 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);
|
|
reactionTimeouts.delete(timeout);
|
|
}, REACTION_ACTIVE_TIME_MS);
|
|
reactionTimeouts.add(timeout);
|
|
return {
|
|
...reactions,
|
|
[sender]: reaction,
|
|
};
|
|
});
|
|
} else 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 (
|
|
!latestMemberships.current.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(latestRaisedHands.current).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);
|
|
reactionTimeouts.forEach((t) => clearTimeout(t));
|
|
// If we're clearing timeouts, we also clear all reactions.
|
|
setReactions({});
|
|
};
|
|
}, [
|
|
room,
|
|
addRaisedHand,
|
|
removeRaisedHand,
|
|
latestMemberships,
|
|
latestRaisedHands,
|
|
]);
|
|
|
|
const lowerHand = useCallback(async () => {
|
|
if (!myUserId || !raisedHands[myUserId]) {
|
|
return;
|
|
}
|
|
const myReactionId = raisedHands[myUserId].reactionEventId;
|
|
if (!myReactionId) {
|
|
logger.warn(`Hand raised but no reaction event to redact!`);
|
|
return;
|
|
}
|
|
try {
|
|
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
|
logger.debug("Redacted raise hand event");
|
|
} catch (ex) {
|
|
logger.error("Failed to redact reaction event", myReactionId, ex);
|
|
}
|
|
}, [myUserId, raisedHands, rtcSession, room]);
|
|
|
|
return (
|
|
<ReactionsContext.Provider
|
|
value={{
|
|
raisedHands: resultRaisedHands,
|
|
supportsReactions,
|
|
reactions,
|
|
lowerHand,
|
|
}}
|
|
>
|
|
{children}
|
|
</ReactionsContext.Provider>
|
|
);
|
|
};
|