mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Support raising reactions for matrix rtc members.
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user