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 { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
import { Modal } from "../Modal";
import { CallViewModel } from "../state/CallViewModel";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -158,22 +161,27 @@ export function ReactionPopupMenu({
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
userId: string;
identifier: string;
vm: CallViewModel;
}
export function ReactionToggleButton({
userId,
identifier,
vm,
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
useReactions();
const { toggleRaisedHand, sendReaction } = useReactions();
const [busy, setBusy] = useState(false);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!raisedHands[userId];
const canReact = !reactions[userId];
const isHandRaised = useObservableState(
vm.handsRaised.pipe(map((v) => !!v[identifier])),
);
const canReact = useObservableState(
vm.reactions.pipe(map((v) => !!v[identifier])),
);
useEffect(() => {
// Clear whenever the reactions menu state changes.
@@ -219,7 +227,7 @@ export function ReactionToggleButton({
<InnerButton
disabled={busy}
onClick={() => setShowReactionsMenu((show) => !show)}
raised={isHandRaised}
raised={!!isHandRaised}
open={showReactionsMenu}
{...props}
/>
@@ -233,8 +241,8 @@ export function ReactionToggleButton({
>
<ReactionPopupMenu
errorText={errorText}
isHandRaised={isHandRaised}
canReact={!busy && canReact}
isHandRaised={!!isHandRaised}
canReact={!busy && !!canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
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.
*/
import { ReactNode, useDeferredValue, useEffect, useMemo } from "react";
import { filter, interval, map, scan, throttle } from "rxjs";
import { ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { CallViewModel } from "../state/CallViewModel";
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 { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useReactions } from "../useReactions";
import { useLatest } from "../useLatest";
// 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) {
buttons.push(
<ReactionToggleButton
vm={vm}
key="raise_hand"
className={styles.raiseHand}
userId={client.getUserId()!}
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
onTouchEnd={onControlsTouchEnd}
/>,
);

View File

@@ -1118,7 +1118,7 @@ export class CallViewModel extends ViewModel {
);
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>) {
this.handsRaised.next(data.raisedHands);

View File

@@ -37,8 +37,14 @@ import {
import { useLatest } from "./useLatest";
interface ReactionsContextType {
/**
* identifier (userId:deviceId => Date)
*/
raisedHands: Record<string, Date>;
supportsReactions: boolean;
/**
* reactions (userId:deviceId => Date)
*/
reactions: Record<string, ReactionOption>;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
@@ -92,6 +98,24 @@ export const ReactionsProvider = ({
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
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>>(
{},
@@ -177,21 +201,8 @@ export const ReactionsProvider = ({
// 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]);
}, [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.
useEffect(() => {
const reactionTimeouts = new Set<number>();
@@ -215,18 +226,18 @@ export const ReactionsProvider = ({
const content: ECallReactionEventContent = event.getContent();
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
// sender of the reaction matches the membership)
if (
!latestMemberships.current.some(
(e) => e.eventId === membershipEventId && e.sender === sender,
)
) {
if (!membershipEvent) {
logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`,
);
return;
}
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`;
if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
@@ -256,19 +267,21 @@ export const ReactionsProvider = ({
};
setReactions((reactions) => {
if (reactions[sender]) {
if (reactions[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
return reactions;
}
const timeout = window.setTimeout(() => {
// Clear the reaction after some time.
setReactions(({ [sender]: _unused, ...remaining }) => remaining);
setReactions(
({ [identifier]: _unused, ...remaining }) => remaining,
);
reactionTimeouts.delete(timeout);
}, REACTION_ACTIVE_TIME_MS);
reactionTimeouts.add(timeout);
return {
...reactions,
[sender]: reaction,
[identifier]: reaction,
};
});
} else if (event.getType() === EventType.Reaction) {
@@ -380,7 +393,7 @@ export const ReactionsProvider = ({
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myUserId || reactions[myUserId]) {
if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) {
// We're still reacting
return;
}
@@ -400,7 +413,7 @@ export const ReactionsProvider = ({
},
);
},
[myMembershipEvent, reactions, room, myUserId, rtcSession],
[myMembershipEvent, reactions, room, myMembershipIdentifier, rtcSession],
);
return (