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
+31 -38
View File
@@ -79,7 +79,7 @@ test("Can raise hand", async () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
test.only("Can lower hand", async () => { test("Can lower hand", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
@@ -95,6 +95,7 @@ test.only("Can lower hand", async () => {
reactions: {}, reactions: {},
}); });
}); });
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.lower_hand")); await user.click(getByLabelText("action.lower_hand"));
expect(rtcSession.room.client.redactEvent).toHaveBeenCalledWith( expect(rtcSession.room.client.redactEvent).toHaveBeenCalledWith(
undefined, undefined,
@@ -118,63 +119,55 @@ test.only("Can lower hand", async () => {
test("Can react with emoji", async () => { test("Can react with emoji", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} />, <TestComponent vm={vm} rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByText("🐶")); await user.click(getByText("🐶"));
expect(room.testSentEvents).toEqual([ expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith(
[ undefined,
undefined, ElementCallReactionEventType,
ElementCallReactionEventType, {
{ "m.relates_to": {
"m.relates_to": { event_id: localRtcMember.eventId,
event_id: memberEventAlice, rel_type: "m.reference",
rel_type: "m.reference",
},
name: "dog",
emoji: "🐶",
}, },
], name: "dog",
]); emoji: "🐶",
},
);
}); });
test("Can fully expand emoji picker", async () => { test("Can fully expand emoji picker", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container, getByText } = render(
const { getByText, container, getByLabelText } = render( <TestComponent vm={vm} rtcSession={rtcSession} />,
<TestComponent rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more")); await user.click(getByLabelText("action.show_more"));
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
await user.click(getByText("🦗")); await user.click(getByText("🦗"));
expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith(
expect(room.testSentEvents).toEqual([ undefined,
[ ElementCallReactionEventType,
undefined, {
ElementCallReactionEventType, "m.relates_to": {
{ event_id: localRtcMember.eventId,
"m.relates_to": { rel_type: "m.reference",
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
}, },
], name: "crickets",
]); emoji: "🦗",
},
);
}); });
test("Can close reaction dialog", async () => { test("Can close reaction dialog", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} />, <TestComponent vm={vm} rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.show_more")); await user.click(getByLabelText("action.show_more"));
+1 -1
View File
@@ -184,7 +184,7 @@ export function ReactionToggleButton({
vm.handsRaised.pipe(map((v) => !!v[identifier])), vm.handsRaised.pipe(map((v) => !!v[identifier])),
); );
const canReact = useObservableState( const canReact = useObservableState(
vm.reactions.pipe(map((v) => !!v[identifier])), vm.reactions.pipe(map((v) => !v[identifier])),
); );
useEffect(() => { useEffect(() => {
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby=":r9l:" aria-labelledby=":rav:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby=":r6c:" aria-labelledby=":r7m:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -68,35 +68,6 @@ exports[`Can fully expand emoji picker 1`] = `
</div> </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`] = ` exports[`Can open menu 1`] = `
<div <div
aria-hidden="true" aria-hidden="true"
@@ -137,7 +108,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby=":r0:" aria-labelledby=":r1j:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59" class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
+7
View File
@@ -29,6 +29,7 @@ import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers"; import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget"; import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter"; import { LazyEventEmitter } from "../LazyEventEmitter";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
@@ -85,6 +86,12 @@ function createGroupCallView(widget: WidgetHelpers | null): {
getRoom: (rId) => (rId === roomId ? room : null), getRoom: (rId) => (rId === roomId ? room : null),
} as Partial<MatrixClient> as MatrixClient; } as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({ const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vitest.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
client, client,
roomId, roomId,
getMember: (userId) => roomMembers.get(userId) ?? null, getMember: (userId) => roomMembers.get(userId) ?? null,
+1 -6
View File
@@ -115,7 +115,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
[connState], [connState],
); );
const [vm, setVm] = useState<CallViewModel | null>(null); const [vm, setVm] = useState<CallViewModel | null>(null);
const reactions = useReactions();
useEffect(() => { useEffect(() => {
return (): void => { return (): void => {
@@ -126,10 +125,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
vm?.updateReactions(reactions);
}, [vm, reactions]);
useEffect(() => { useEffect(() => {
if (livekitRoom !== undefined) { if (livekitRoom !== undefined) {
const vm = new CallViewModel( const vm = new CallViewModel(
@@ -147,7 +142,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return ( return (
<RoomContext.Provider value={livekitRoom}> <RoomContext.Provider value={livekitRoom}>
<ReactionsProvider rtcSession={props.rtcSession}> <ReactionsProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView <InCallView
{...props} {...props}
vm={vm} vm={vm}
+103 -49
View File
@@ -689,63 +689,117 @@ it("should show at least one tile per MatrixRTCSession", () => {
}); });
}); });
// TODO: Add presenters and speakers? it("Should correctly handle a raised hand event", () => {
it("should rank raised hands above video feeds and below speakers and presenters", () => { withTestScheduler(({ expectObservable }) => {
withTestScheduler(({ schedule, expectObservable }) => { const date = new Date();
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([]),
of([aliceRtcMember, bobRtcMember]), of([]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
schedule("ah", { vm.addHandRaised(alice.userId, date);
a: () => { expectObservable(vm.handsRaised).toBe("a", {
// We imagine that only three tiles (the first three) will be visible a: {
// on screen at a time [alice.userId]: date,
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( expectObservable(vm.handRaised).toBe("a", {
expectedLayoutMarbles, a: {
{ value: 1,
a: { playSounds: true,
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",
],
},
}, },
); });
}, },
); );
}); });
}); });
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",
],
},
},
);
},
);
});
},
);
+51 -13
View File
@@ -1146,23 +1146,61 @@ export class CallViewModel extends ViewModel {
); );
private readonly handsRaisedSubject = new BehaviorSubject< private readonly handsRaisedSubject = new BehaviorSubject<
Record<string, Date> { userId: string; date: Date | null }[]
>({}); >([]);
private readonly reactionsSubject = new BehaviorSubject< private readonly reactionsSubject = new BehaviorSubject<
Record<string, ReactionOption> { userId: string; reaction: ReactionOption; ttl: number }[]
>({}); >([]);
public readonly handsRaised = this.handsRaisedSubject.asObservable(); public addHandRaised(userId: string, date: Date) {
public readonly reactions = this.reactionsSubject.asObservable(); this.handsRaisedSubject.next([{ userId, date }]);
public updateReactions(data: {
raisedHands: Record<string, Date>;
reactions: Record<string, ReactionOption>;
}): void {
this.handsRaisedSubject.next(data.raisedHands);
this.reactionsSubject.next(data.reactions);
} }
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. * Emits an array of reactions that should be visible on the screen.
*/ */
-2
View File
@@ -15,8 +15,6 @@ import {
createHandRaisedReaction, createHandRaisedReaction,
createRedaction, createRedaction,
MockRoom, MockRoom,
ReactionsMockRTCSession,
TestReactionsWrapper,
} from "./utils/testReactions"; } from "./utils/testReactions";
const memberUserIdAlice = "@alice:example.org"; const memberUserIdAlice = "@alice:example.org";
+19 -19
View File
@@ -35,17 +35,10 @@ import {
ReactionSet, ReactionSet,
} from "./reactions"; } from "./reactions";
import { useLatest } from "./useLatest"; import { useLatest } from "./useLatest";
import { CallViewModel } from "./state/CallViewModel";
interface ReactionsContextType { interface ReactionsContextType {
/**
* identifier (userId:deviceId => Date)
*/
raisedHands: Record<string, Date>;
supportsReactions: boolean; supportsReactions: boolean;
/**
* reactions (userId:deviceId => Date)
*/
reactions: Record<string, ReactionOption>;
toggleRaisedHand: () => Promise<void>; toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>; sendReaction: (reaction: ReactionOption) => Promise<void>;
} }
@@ -79,15 +72,23 @@ export const useReactions = (): ReactionsContextType => {
return context; 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`. * Provider that handles raised hand reactions for a given `rtcSession`.
*/ */
export const ReactionsProvider = ({ export const ReactionsProvider = ({
children, children,
rtcSession, rtcSession,
vm,
}: { }: {
children: ReactNode; children: ReactNode;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
vm: CallViewModel;
}): JSX.Element => { }): JSX.Element => {
const [raisedHands, setRaisedHands] = useState< const [raisedHands, setRaisedHands] = useState<
Record<string, RaisedHandInfo> Record<string, RaisedHandInfo>
@@ -103,6 +104,15 @@ export const ReactionsProvider = ({
const latestMemberships = useLatest(memberships); const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands); 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( const myMembershipEvent = useMemo(
() => () =>
memberships.find( 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) => { const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({ setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands, ...prevRaisedHands,
@@ -394,7 +396,7 @@ export const ReactionsProvider = ({
const sendReaction = useCallback( const sendReaction = useCallback(
async (reaction: ReactionOption) => { async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) { if (!myMembershipIdentifier || reactions[myMembershipIdentifier]) {
// We're still reacting // We're still reacting
return; return;
} }
@@ -420,9 +422,7 @@ export const ReactionsProvider = ({
return ( return (
<ReactionsContext.Provider <ReactionsContext.Provider
value={{ value={{
raisedHands: resultRaisedHands,
supportsReactions, supportsReactions,
reactions,
toggleRaisedHand, toggleRaisedHand,
sendReaction, sendReaction,
}} }}
-42
View File
@@ -5,7 +5,6 @@ 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 { type PropsWithChildren, type ReactNode } from "react";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import EventEmitter from "events"; import EventEmitter from "events";
import { type MatrixClient } from "matrix-js-sdk/src/client"; import { type MatrixClient } from "matrix-js-sdk/src/client";
@@ -16,52 +15,11 @@ import {
EventTimelineSet, EventTimelineSet,
type Room, type Room,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ReactionsProvider } from "../useReactions";
import { import {
type ECallReactionEventContent, type ECallReactionEventContent,
ElementCallReactionEventType, ElementCallReactionEventType,
type ReactionOption, type ReactionOption,
} from "../reactions"; } 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( export function createHandRaisedReaction(
parentMemberEvent: string, parentMemberEvent: string,