More refactors

This commit is contained in:
Half-Shot
2024-12-16 11:29:03 +00:00
parent 1e56443f2f
commit 0122f85542
10 changed files with 216 additions and 202 deletions

View File

@@ -79,7 +79,7 @@ test("Can raise hand", async () => {
expect(container).toMatchSnapshot();
});
test.only("Can lower hand", async () => {
test("Can lower hand", async () => {
const user = userEvent.setup();
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { getByLabelText, container } = render(
@@ -95,6 +95,7 @@ test.only("Can lower hand", async () => {
reactions: {},
});
});
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.lower_hand"));
expect(rtcSession.room.client.redactEvent).toHaveBeenCalledWith(
undefined,
@@ -118,63 +119,55 @@ test.only("Can lower hand", async () => {
test("Can react with emoji", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} />,
<TestComponent vm={vm} rtcSession={rtcSession} />,
);
await user.click(getByLabelText("common.reactions"));
await user.click(getByText("🐶"));
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "dog",
emoji: "🐶",
expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith(
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: localRtcMember.eventId,
rel_type: "m.reference",
},
],
]);
name: "dog",
emoji: "🐶",
},
);
});
test("Can fully expand emoji picker", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByText, container, getByLabelText } = render(
<TestComponent rtcSession={rtcSession} />,
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { getByLabelText, container, getByText } = render(
<TestComponent vm={vm} rtcSession={rtcSession} />,
);
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));
expect(container).toMatchSnapshot();
await user.click(getByText("🦗"));
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith(
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: localRtcMember.eventId,
rel_type: "m.reference",
},
],
]);
name: "crickets",
emoji: "🦗",
},
);
});
test("Can close reaction dialog", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} />,
<TestComponent vm={vm} rtcSession={rtcSession} />,
);
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more"));

View File

@@ -184,7 +184,7 @@ export function ReactionToggleButton({
vm.handsRaised.pipe(map((v) => !!v[identifier])),
);
const canReact = useObservableState(
vm.reactions.pipe(map((v) => !!v[identifier])),
vm.reactions.pipe(map((v) => !v[identifier])),
);
useEffect(() => {

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r9l:"
aria-labelledby=":rav:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r6c:"
aria-labelledby=":r7m:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
@@ -68,35 +68,6 @@ exports[`Can fully expand emoji picker 1`] = `
</div>
`;
exports[`Can lower hand 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r36:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
/>
</svg>
</button>
</div>
`;
exports[`Can open menu 1`] = `
<div
aria-hidden="true"
@@ -137,7 +108,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r0:"
aria-labelledby=":r1j:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"

View File

@@ -29,6 +29,7 @@ import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
vitest.mock("../soundUtils");
vitest.mock("../useAudioContext");
@@ -85,6 +86,12 @@ function createGroupCallView(widget: WidgetHelpers | null): {
getRoom: (rId) => (rId === roomId ? room : null),
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vitest.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
client,
roomId,
getMember: (userId) => roomMembers.get(userId) ?? null,

View File

@@ -115,7 +115,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
[connState],
);
const [vm, setVm] = useState<CallViewModel | null>(null);
const reactions = useReactions();
useEffect(() => {
return (): void => {
@@ -126,10 +125,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
vm?.updateReactions(reactions);
}, [vm, reactions]);
useEffect(() => {
if (livekitRoom !== undefined) {
const vm = new CallViewModel(
@@ -147,7 +142,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (
<RoomContext.Provider value={livekitRoom}>
<ReactionsProvider rtcSession={props.rtcSession}>
<ReactionsProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}

View File

@@ -689,63 +689,117 @@ it("should show at least one tile per MatrixRTCSession", () => {
});
});
// TODO: Add presenters and speakers?
it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "a";
it("Should correctly handle a raised hand event", () => {
withTestScheduler(({ expectObservable }) => {
const date = new Date();
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of([]),
of([]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule("ah", {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
console.log(layout);
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i <= 1);
}
});
},
h: () => {
vm.updateReactions({
reactions: {},
raisedHands: {
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]: new Date(),
},
});
vm.addHandRaised(alice.userId, date);
expectObservable(vm.handsRaised).toBe("a", {
a: {
[alice.userId]: date,
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
h: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
expectObservable(vm.handRaised).toBe("a", {
a: {
value: 1,
playSounds: true,
},
);
});
},
);
});
});
it.only("Should correctly handle a lowered hand event", () => {
const date = new Date();
withTestScheduler(({ expectObservable }) => {
withCallViewModel(
of([]),
of([]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.addHandRaised(alice.userId, date);
vm.addHandRaised(bob.userId, date);
expectObservable(vm.handsRaised).toBe("a", {
a: {
[bob.userId]: date,
},
b: {
[bob.userId]: date,
},
});
},
);
});
});
// TODO: Add presenters and speakers?
it.todo(
"should rank raised hands above video feeds and below speakers and presenters",
() => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "a";
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule("ah", {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
console.log(layout);
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i <= 1);
}
});
},
h: () => {
vm.updateReactions({
reactions: {},
raisedHands: {
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]:
new Date(),
},
});
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
h: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
},
);
},
);
});
},
);

View File

@@ -1146,23 +1146,61 @@ export class CallViewModel extends ViewModel {
);
private readonly handsRaisedSubject = new BehaviorSubject<
Record<string, Date>
>({});
{ userId: string; date: Date | null }[]
>([]);
private readonly reactionsSubject = new BehaviorSubject<
Record<string, ReactionOption>
>({});
{ userId: string; reaction: ReactionOption; ttl: number }[]
>([]);
public readonly handsRaised = this.handsRaisedSubject.asObservable();
public readonly reactions = this.reactionsSubject.asObservable();
public updateReactions(data: {
raisedHands: Record<string, Date>;
reactions: Record<string, ReactionOption>;
}): void {
this.handsRaisedSubject.next(data.raisedHands);
this.reactionsSubject.next(data.reactions);
public addHandRaised(userId: string, date: Date) {
this.handsRaisedSubject.next([{ userId, date }]);
}
public removeHandRaised(userId: string, date: Date | null) {
this.handsRaisedSubject.next([{ userId, date }]);
}
public addReaction(userId: string, reaction: ReactionOption, ttl: number) {
this.reactionsSubject.next([{ userId, reaction, ttl }]);
}
public readonly reactions = this.reactionsSubject
.pipe(
scan<
{ userId: string; reaction: ReactionOption; ttl: number }[],
Record<string, { reaction: ReactionOption; ttl: number }>
>((acc, value) => {
for (const { userId, reaction, ttl } of value) {
acc[userId] = { reaction, ttl };
}
return acc;
}, {}),
)
.pipe(
map((v) =>
Object.fromEntries(
Object.entries(v).map(([a, { reaction }]) => [a, reaction]),
),
),
);
public readonly handsRaised = this.handsRaisedSubject.pipe(
scan<{ userId: string; date: Date | null }[], Record<string, Date>>(
(acc, value) => {
for (const { userId, date } of value) {
if (date) {
acc[userId] = date;
} else {
delete acc[userId];
}
}
console.log("handsRaised", acc);
return acc;
},
{},
),
);
/**
* Emits an array of reactions that should be visible on the screen.
*/

View File

@@ -15,8 +15,6 @@ import {
createHandRaisedReaction,
createRedaction,
MockRoom,
ReactionsMockRTCSession,
TestReactionsWrapper,
} from "./utils/testReactions";
const memberUserIdAlice = "@alice:example.org";

View File

@@ -35,17 +35,10 @@ import {
ReactionSet,
} from "./reactions";
import { useLatest } from "./useLatest";
import { CallViewModel } from "./state/CallViewModel";
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>;
}
@@ -79,15 +72,23 @@ export const useReactions = (): ReactionsContextType => {
return context;
};
/**
* HS plan:
* Provider should publish new hand raised, reaction events to CallViewModel
* Provider should listen for new events from CVM
*/
/**
* Provider that handles raised hand reactions for a given `rtcSession`.
*/
export const ReactionsProvider = ({
children,
rtcSession,
vm,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
}): JSX.Element => {
const [raisedHands, setRaisedHands] = useState<
Record<string, RaisedHandInfo>
@@ -103,6 +104,15 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
useEffect(() => {
vm.updateReactions({
raisedHands: Object.fromEntries(
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
),
reactions,
});
}, [memberships, raisedHands]);
const myMembershipEvent = useMemo(
() =>
memberships.find(
@@ -121,14 +131,6 @@ export const ReactionsProvider = ({
{},
);
// Reduce the data down for the consumers.
const resultRaisedHands = useMemo(
() =>
Object.fromEntries(
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
),
[raisedHands],
);
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
@@ -394,7 +396,7 @@ export const ReactionsProvider = ({
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) {
if (!myMembershipIdentifier || reactions[myMembershipIdentifier]) {
// We're still reacting
return;
}
@@ -420,9 +422,7 @@ export const ReactionsProvider = ({
return (
<ReactionsContext.Provider
value={{
raisedHands: resultRaisedHands,
supportsReactions,
reactions,
toggleRaisedHand,
sendReaction,
}}

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { type PropsWithChildren, type ReactNode } from "react";
import { randomUUID } from "crypto";
import EventEmitter from "events";
import { type MatrixClient } from "matrix-js-sdk/src/client";
@@ -16,52 +15,11 @@ import {
EventTimelineSet,
type Room,
} from "matrix-js-sdk/src/matrix";
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ReactionsProvider } from "../useReactions";
import {
type ECallReactionEventContent,
ElementCallReactionEventType,
type ReactionOption,
} from "../reactions";
import { MockRTCSession } from "./test";
// export class ReactionsMockRTCSession extends EventEmitter {
// public memberships: {
// sender: string;
// eventId: string;
// createdTs: () => Date;
// }[];
// public constructor(
// public readonly room: MockRoom,
// membership: Record<string, string>,
// ) {
// super();
// this.memberships = Object.entries(membership).map(([eventId, sender]) => ({
// sender,
// eventId,
// createdTs: (): Date => new Date(),
// }));
// }
// public testRemoveMember(userId: string): void {
// this.memberships = this.memberships.filter((u) => u.sender !== userId);
// this.emit(MatrixRTCSessionEvent.MembershipsChanged);
// }
// public testAddMember(sender: string): void {
// this.memberships.push({
// sender,
// eventId: `!fake-${randomUUID()}:event`,
// createdTs: (): Date => new Date(),
// });
// this.emit(MatrixRTCSessionEvent.MembershipsChanged);
// }
// }
export function createHandRaisedReaction(
parentMemberEvent: string,