Tie up last bits of useReactions

This commit is contained in:
Half-Shot
2024-12-09 16:24:17 +00:00
parent b10863d582
commit de19565c4f
5 changed files with 59 additions and 38 deletions

View File

@@ -29,6 +29,9 @@ import { useReactions } from "../useReactions";
import styles from "./ReactionToggleButton.module.css"; import styles from "./ReactionToggleButton.module.css";
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions"; import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { CallViewModel } from "../state/CallViewModel";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean; raised: boolean;
@@ -158,22 +161,27 @@ export function ReactionPopupMenu({
} }
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
userId: string; identifier: string;
vm: CallViewModel;
} }
export function ReactionToggleButton({ export function ReactionToggleButton({
userId, identifier,
vm,
...props ...props
}: ReactionToggleButtonProps): ReactNode { }: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation(); const { t } = useTranslation();
const { raisedHands, toggleRaisedHand, sendReaction, reactions } = const { toggleRaisedHand, sendReaction } = useReactions();
useReactions();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!raisedHands[userId]; const isHandRaised = useObservableState(
const canReact = !reactions[userId]; vm.handsRaised.pipe(map((v) => !!v[identifier])),
);
const canReact = useObservableState(
vm.reactions.pipe(map((v) => !!v[identifier])),
);
useEffect(() => { useEffect(() => {
// Clear whenever the reactions menu state changes. // Clear whenever the reactions menu state changes.
@@ -219,7 +227,7 @@ export function ReactionToggleButton({
<InnerButton <InnerButton
disabled={busy} disabled={busy}
onClick={() => setShowReactionsMenu((show) => !show)} onClick={() => setShowReactionsMenu((show) => !show)}
raised={isHandRaised} raised={!!isHandRaised}
open={showReactionsMenu} open={showReactionsMenu}
{...props} {...props}
/> />
@@ -233,8 +241,8 @@ export function ReactionToggleButton({
> >
<ReactionPopupMenu <ReactionPopupMenu
errorText={errorText} errorText={errorText}
isHandRaised={isHandRaised} isHandRaised={!!isHandRaised}
canReact={!busy && canReact} canReact={!busy && !!canReact}
sendReaction={(reaction) => void sendRelation(reaction)} sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={wrappedToggleRaisedHand} toggleRaisedHand={wrappedToggleRaisedHand}
/> />

View File

@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { ReactNode, useDeferredValue, useEffect, useMemo } from "react"; import { ReactNode, useEffect } from "react";
import { filter, interval, map, scan, throttle } from "rxjs"; import { filter, interval, throttle } from "rxjs";
import { CallViewModel } from "../state/CallViewModel"; import { CallViewModel } from "../state/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3"; import joinCallSoundMp3 from "../sound/join_call.mp3";
@@ -17,7 +17,6 @@ import handSoundOgg from "../sound/raise_hand.ogg?url";
import handSoundMp3 from "../sound/raise_hand.mp3?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useReactions } from "../useReactions";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
// Do not play any sounds if the participant count has exceeded this // Do not play any sounds if the participant count has exceeded this

View File

@@ -544,9 +544,10 @@ export const InCallView: FC<InCallViewProps> = ({
if (supportsReactions) { if (supportsReactions) {
buttons.push( buttons.push(
<ReactionToggleButton <ReactionToggleButton
vm={vm}
key="raise_hand" key="raise_hand"
className={styles.raiseHand} className={styles.raiseHand}
userId={client.getUserId()!} identifier={`${client.getUserId()}:${client.getDeviceId()}`}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
/>, />,
); );

View File

@@ -1118,7 +1118,7 @@ export class CallViewModel extends ViewModel {
); );
public readonly handsRaised = new Subject<Record<string, Date>>(); public readonly handsRaised = new Subject<Record<string, Date>>();
private readonly reactions = new Subject<Record<string, ReactionOption>>(); public readonly reactions = new Subject<Record<string, ReactionOption>>();
public updateReactions(data: ReturnType<typeof useReactions>) { public updateReactions(data: ReturnType<typeof useReactions>) {
this.handsRaised.next(data.raisedHands); this.handsRaised.next(data.raisedHands);

View File

@@ -37,8 +37,14 @@ import {
import { useLatest } from "./useLatest"; import { useLatest } from "./useLatest";
interface ReactionsContextType { interface ReactionsContextType {
/**
* identifier (userId:deviceId => Date)
*/
raisedHands: Record<string, Date>; raisedHands: Record<string, Date>;
supportsReactions: boolean; supportsReactions: boolean;
/**
* reactions (userId:deviceId => Date)
*/
reactions: Record<string, ReactionOption>; reactions: Record<string, ReactionOption>;
toggleRaisedHand: () => Promise<void>; toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>; sendReaction: (reaction: ReactionOption) => Promise<void>;
@@ -92,6 +98,24 @@ export const ReactionsProvider = ({
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 myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.sender === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId],
);
const myMembershipIdentifier = useMemo(() => {
const membership = memberships.find((m) => m.sender === myUserId);
return membership
? `${membership.sender}:${membership.deviceId}`
: undefined;
}, [memberships, myUserId]);
const [reactions, setReactions] = useState<Record<string, ReactionOption>>( const [reactions, setReactions] = useState<Record<string, ReactionOption>>(
{}, {},
@@ -177,21 +201,8 @@ export const ReactionsProvider = ({
// Ignoring raisedHands here because we don't want to trigger each time the raised // Ignoring raisedHands here because we don't want to trigger each time the raised
// hands set is updated. // hands set is updated.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, memberships, myUserId, addRaisedHand, removeRaisedHand]); }, [room, memberships, addRaisedHand, removeRaisedHand]);
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
const myMembershipEvent = useMemo(
() => memberships.find((m) => m.sender === myUserId)?.eventId,
[memberships, myUserId],
);
const myMembershipIdentifier = useMemo(() => {
const membership = memberships.find((m) => m.sender === myUserId);
return membership
? `${membership.sender}:${membership.deviceId}`
: undefined;
}, [memberships, myUserId]);
// This effect handles any *live* reaction/redactions in the room. // This effect handles any *live* reaction/redactions in the room.
useEffect(() => { useEffect(() => {
const reactionTimeouts = new Set<number>(); const reactionTimeouts = new Set<number>();
@@ -215,18 +226,18 @@ export const ReactionsProvider = ({
const content: ECallReactionEventContent = event.getContent(); const content: ECallReactionEventContent = event.getContent();
const membershipEventId = content?.["m.relates_to"]?.event_id; const membershipEventId = content?.["m.relates_to"]?.event_id;
const membershipEvent = latestMemberships.current.find(
(e) => e.eventId === membershipEventId && e.sender === sender,
);
// Check to see if this reaction was made to a membership event (and the // Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership) // sender of the reaction matches the membership)
if ( if (!membershipEvent) {
!latestMemberships.current.some(
(e) => e.eventId === membershipEventId && e.sender === sender,
)
) {
logger.warn( logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`, `Reaction target was not a membership event for ${sender}, ignoring`,
); );
return; return;
} }
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`;
if (!content.emoji) { if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`); logger.warn(`Reaction had no emoji from ${reactionEventId}`);
@@ -256,19 +267,21 @@ export const ReactionsProvider = ({
}; };
setReactions((reactions) => { setReactions((reactions) => {
if (reactions[sender]) { if (reactions[identifier]) {
// 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 = window.setTimeout(() => { const timeout = window.setTimeout(() => {
// Clear the reaction after some time. // Clear the reaction after some time.
setReactions(({ [sender]: _unused, ...remaining }) => remaining); setReactions(
({ [identifier]: _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, [identifier]: reaction,
}; };
}); });
} else if (event.getType() === EventType.Reaction) { } else if (event.getType() === EventType.Reaction) {
@@ -380,7 +393,7 @@ export const ReactionsProvider = ({
const sendReaction = useCallback( const sendReaction = useCallback(
async (reaction: ReactionOption) => { async (reaction: ReactionOption) => {
if (!myUserId || reactions[myUserId]) { if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) {
// We're still reacting // We're still reacting
return; return;
} }
@@ -400,7 +413,7 @@ export const ReactionsProvider = ({
}, },
); );
}, },
[myMembershipEvent, reactions, room, myUserId, rtcSession], [myMembershipEvent, reactions, room, myMembershipIdentifier, rtcSession],
); );
return ( return (