Support raising reactions for matrix rtc members.

This commit is contained in:
Half-Shot
2024-12-09 16:09:41 +00:00
parent 73ee088605
commit b10863d582
5 changed files with 93 additions and 39 deletions

View File

@@ -5,9 +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, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { useReactions } from "../useReactions";
import { playReactionsSound, useSetting } from "../settings/settings"; import { playReactionsSound, useSetting } from "../settings/settings";
import { GenericReaction, ReactionSet } from "../reactions"; import { GenericReaction, ReactionSet } from "../reactions";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";

View File

@@ -206,6 +206,10 @@ enum SortingBin {
* Participants that have been speaking recently. * Participants that have been speaking recently.
*/ */
Speakers, Speakers,
/**
* Participants that have their hand raised.
*/
HandRaised,
/** /**
* Participants with video. * Participants with video.
*/ */
@@ -241,6 +245,8 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant | undefined, participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
) { ) {
this.participant = new BehaviorSubject(participant); this.participant = new BehaviorSubject(participant);
@@ -251,6 +257,8 @@ class UserMedia {
this.participant.asObservable() as Observable<LocalParticipant>, this.participant.asObservable() as Observable<LocalParticipant>,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
handRaised,
reactions,
); );
} else { } else {
this.vm = new RemoteUserMediaViewModel( this.vm = new RemoteUserMediaViewModel(
@@ -261,6 +269,8 @@ class UserMedia {
>, >,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
handRaised,
reactions,
); );
} }
@@ -468,6 +478,8 @@ export class CallViewModel extends ViewModel {
let livekitParticipantId = let livekitParticipantId =
rtcMember.sender + ":" + rtcMember.deviceId; rtcMember.sender + ":" + rtcMember.deviceId;
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
let participant: let participant:
| LocalParticipant | LocalParticipant
| RemoteParticipant | RemoteParticipant
@@ -509,6 +521,12 @@ export class CallViewModel extends ViewModel {
participant, participant,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
this.handsRaised.pipe(
map((v) => v[matrixIdentifier] ?? undefined),
),
this.reactions.pipe(
map((v) => v[matrixIdentifier] ?? undefined),
),
), ),
]; ];
@@ -618,12 +636,13 @@ export class CallViewModel extends ViewModel {
[ [
m.speaker, m.speaker,
m.presenter, m.presenter,
m.vm.handRaised,
m.vm.videoEnabled, m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow ? m.vm.alwaysShow
: of(false), : of(false),
], ],
(speaker, presenter, video, alwaysShow) => { (speaker, presenter, handRaised, video, alwaysShow) => {
let bin: SortingBin; let bin: SortingBin;
if (m.vm.local) if (m.vm.local)
bin = alwaysShow bin = alwaysShow
@@ -631,6 +650,7 @@ export class CallViewModel extends ViewModel {
: SortingBin.SelfNotAlwaysShown; : SortingBin.SelfNotAlwaysShown;
else if (presenter) bin = SortingBin.Presenters; else if (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers; else if (speaker) bin = SortingBin.Speakers;
else if (handRaised) bin = SortingBin.HandRaised;
else if (video) bin = SortingBin.Video; else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo; else bin = SortingBin.NoVideo;

View File

@@ -51,6 +51,7 @@ import { alwaysShowSelf } from "../settings/settings";
import { accumulate } from "../utils/observable"; import { accumulate } from "../utils/observable";
import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { ReactionOption } from "../reactions";
// TODO: Move this naming logic into the view model // TODO: Move this naming logic into the view model
export function useDisplayName(vm: MediaViewModel): string { export function useDisplayName(vm: MediaViewModel): string {
@@ -371,6 +372,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
participant: Observable<LocalParticipant | RemoteParticipant | undefined>, participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly handRaised: Observable<Date | undefined>,
public readonly reactions: Observable<ReactionOption | undefined>,
) { ) {
super( super(
id, id,
@@ -437,8 +440,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant: Observable<LocalParticipant | undefined>, participant: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(
id,
member,
participant,
encryptionSystem,
livekitRoom,
handRaised,
reactions,
);
} }
} }
@@ -498,8 +511,18 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
participant: Observable<RemoteParticipant | undefined>, participant: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(
id,
member,
participant,
encryptionSystem,
livekitRoom,
handRaised,
reactions,
);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
combineLatest([ combineLatest([

View File

@@ -34,7 +34,7 @@ import {
ToggleMenuItem, ToggleMenuItem,
Menu, Menu,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState, useObservableState } from "observable-hooks";
import styles from "./GridTile.module.css"; import styles from "./GridTile.module.css";
import { import {
@@ -49,7 +49,6 @@ import { useLatest } from "../useLatest";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { useReactions } from "../useReactions"; import { useReactions } from "../useReactions";
import { ReactionOption } from "../reactions";
interface TileProps { interface TileProps {
className?: string; className?: string;
@@ -82,6 +81,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
}, },
ref, ref,
) => { ) => {
const { toggleRaisedHand } = useReactions();
const { t } = useTranslation(); const { t } = useTranslation();
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
@@ -97,7 +97,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
}, },
[vm], [vm],
); );
const { raisedHands, toggleRaisedHand, reactions } = useReactions(); const handRaised = useObservableState(vm.handRaised);
const reaction = useObservableState(vm.reactions);
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
@@ -124,9 +125,6 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
</> </>
); );
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
const currentReaction: ReactionOption | undefined =
reactions[vm.member?.userId ?? ""];
const raisedHandOnClick = vm.local const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand() ? (): void => void toggleRaisedHand()
: undefined; : undefined;
@@ -144,7 +142,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
videoFit={cropVideo ? "cover" : "contain"} videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, { className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking, [styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && !!handRaised, [styles.handRaised]: !showSpeaking && handRaised,
})} })}
nameTagLeadingIcon={ nameTagLeadingIcon={
<AudioIcon <AudioIcon
@@ -173,7 +171,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
</Menu> </Menu>
} }
raisedHandTime={handRaised} raisedHandTime={handRaised}
currentReaction={currentReaction} currentReaction={reaction}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local} localParticipant={vm.local}
{...props} {...props}

View File

@@ -140,10 +140,10 @@ export const ReactionsProvider = ({
}; };
// Remove any raised hands for users no longer joined to the call. // Remove any raised hands for users no longer joined to the call.
for (const userId of Object.keys(raisedHands).filter( for (const identifier of Object.keys(raisedHands).filter(
(rhId) => !memberships.find((u) => u.sender == rhId), (rhId) => !memberships.find((u) => u.sender == rhId),
)) { )) {
removeRaisedHand(userId); removeRaisedHand(identifier);
} }
// For each member in the call, check to see if a reaction has // For each member in the call, check to see if a reaction has
@@ -152,13 +152,14 @@ export const ReactionsProvider = ({
if (!m.sender || !m.eventId) { if (!m.sender || !m.eventId) {
continue; continue;
} }
const identifier = `${m.sender}:${m.deviceId}`;
if ( if (
raisedHands[m.sender] && raisedHands[identifier] &&
raisedHands[m.sender].membershipEventId !== m.eventId raisedHands[identifier].membershipEventId !== m.eventId
) { ) {
// Membership event for sender has changed since the hand // Membership event for sender has changed since the hand
// was raised, reset. // was raised, reset.
removeRaisedHand(m.sender); removeRaisedHand(identifier);
} }
const reaction = getLastReactionEvent(m.eventId, m.sender); const reaction = getLastReactionEvent(m.eventId, m.sender);
if (reaction) { if (reaction) {
@@ -166,7 +167,7 @@ export const ReactionsProvider = ({
if (!eventId) { if (!eventId) {
continue; continue;
} }
addRaisedHand(m.sender, { addRaisedHand(`${m.sender}:${m.deviceId}`, {
membershipEventId: m.eventId, membershipEventId: m.eventId,
reactionEventId: eventId, reactionEventId: eventId,
time: new Date(reaction.localTimestamp), time: new Date(reaction.localTimestamp),
@@ -181,11 +182,16 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships); const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands); const latestRaisedHands = useLatest(raisedHands);
const myMembership = useMemo( const myMembershipEvent = useMemo(
() => memberships.find((m) => m.sender === myUserId)?.eventId, () => memberships.find((m) => m.sender === myUserId)?.eventId,
[memberships, myUserId], [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>();
@@ -271,11 +277,10 @@ export const ReactionsProvider = ({
// 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 ( const membershipEvent = latestMemberships.current.find(
!latestMemberships.current.some( (e) => e.eventId === membershipEventId && e.sender === sender,
(e) => e.eventId === membershipEventId && e.sender === sender, );
) if (!membershipEvent) {
) {
logger.warn( logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`, `Reaction target was not a membership event for ${sender}, ignoring`,
); );
@@ -283,11 +288,14 @@ export const ReactionsProvider = ({
} }
if (content?.["m.relates_to"].key === "🖐️") { if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(sender, { addRaisedHand(
reactionEventId, `${membershipEvent.sender}:${membershipEvent.deviceId}`,
membershipEventId, {
time: new Date(event.localTimestamp), reactionEventId,
}); membershipEventId,
time: new Date(event.localTimestamp),
},
);
} }
} else if (event.getType() === EventType.RoomRedaction) { } else if (event.getType() === EventType.RoomRedaction) {
const targetEvent = event.event.redacts; const targetEvent = event.event.redacts;
@@ -328,14 +336,14 @@ export const ReactionsProvider = ({
]); ]);
const toggleRaisedHand = useCallback(async () => { const toggleRaisedHand = useCallback(async () => {
if (!myUserId) { if (!myMembershipIdentifier) {
return; return;
} }
const myReactionId = raisedHands[myUserId]?.reactionEventId; const myReactionId = raisedHands[myMembershipIdentifier]?.reactionEventId;
if (!myReactionId) { if (!myReactionId) {
try { try {
if (!myMembership) { if (!myMembershipEvent) {
throw new Error("Cannot find own membership event"); throw new Error("Cannot find own membership event");
} }
const reaction = await room.client.sendEvent( const reaction = await room.client.sendEvent(
@@ -344,7 +352,7 @@ export const ReactionsProvider = ({
{ {
"m.relates_to": { "m.relates_to": {
rel_type: RelationType.Annotation, rel_type: RelationType.Annotation,
event_id: myMembership, event_id: myMembershipEvent,
key: "🖐️", key: "🖐️",
}, },
}, },
@@ -362,7 +370,13 @@ export const ReactionsProvider = ({
throw ex; throw ex;
} }
} }
}, [myMembership, myUserId, raisedHands, rtcSession, room]); }, [
myMembershipEvent,
myMembershipIdentifier,
raisedHands,
rtcSession,
room,
]);
const sendReaction = useCallback( const sendReaction = useCallback(
async (reaction: ReactionOption) => { async (reaction: ReactionOption) => {
@@ -370,7 +384,7 @@ export const ReactionsProvider = ({
// We're still reacting // We're still reacting
return; return;
} }
if (!myMembership) { if (!myMembershipEvent) {
throw new Error("Cannot find own membership event"); throw new Error("Cannot find own membership event");
} }
await room.client.sendEvent( await room.client.sendEvent(
@@ -379,14 +393,14 @@ export const ReactionsProvider = ({
{ {
"m.relates_to": { "m.relates_to": {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
event_id: myMembership, event_id: myMembershipEvent,
}, },
emoji: reaction.emoji, emoji: reaction.emoji,
name: reaction.name, name: reaction.name,
}, },
); );
}, },
[myMembership, reactions, room, myUserId, rtcSession], [myMembershipEvent, reactions, room, myUserId, rtcSession],
); );
return ( return (