mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Tie up last bits of useReactions
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user