mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Merge upstream/livekit into branch
This commit is contained in:
@@ -1,10 +1,25 @@
|
||||
.bar {
|
||||
block-size: 64px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Pseudo-element for the gradient background */
|
||||
.bar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
/* Extend the gradient beyond the bottom of the header for readability */
|
||||
inset-block: -24px;
|
||||
z-index: var(--call-view-header-footer-layer);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bar > header {
|
||||
position: absolute;
|
||||
position: sticky;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 64px;
|
||||
|
||||
@@ -20,3 +20,21 @@
|
||||
color: var(--cpd-color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.technicalDetails {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.technicalDetailsSummary {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.technicalDetailsPre {
|
||||
margin-top: var(--cpd-space-2x);
|
||||
padding: var(--cpd-space-2x);
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
overflow: auto;
|
||||
font-size: var(--cpd-font-size-body-sm);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={open}
|
||||
// This autofocus is a custom vault property and not the
|
||||
// standard HTML autofocus attribute.
|
||||
// It makes the Drawer.Root behave like the `DialogRoot`
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
onOpenChange={onOpenChange}
|
||||
dismissible={onDismiss !== undefined}
|
||||
>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("audio")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={MicOnSolidIcon}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("video")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={VideoCallSolidIcon}
|
||||
>
|
||||
|
||||
@@ -14,8 +14,13 @@ import {
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import posthog, { type CaptureResult } from "posthog-js";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import {
|
||||
Anonymity,
|
||||
santizeSensitiveData,
|
||||
PosthogAnalytics,
|
||||
} from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
@@ -88,4 +93,154 @@ describe("PosthogAnalytics", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPrivacyFilters", () => {
|
||||
const makeEvent = (properties: Record<string, unknown>): CaptureResult =>
|
||||
({ event: "anyEvent", properties }) as unknown as CaptureResult;
|
||||
|
||||
it("drops $initial_person_info regardless of anonymity", () => {
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({
|
||||
$current_url: "https://call.example.com/some/private/path",
|
||||
$initial_person_info: {
|
||||
r: "https://example.com/referrer",
|
||||
u: "https://call.example.com/some/private/path",
|
||||
},
|
||||
}),
|
||||
Anonymity.Pseudonymous,
|
||||
);
|
||||
expect(out?.properties).not.toHaveProperty("$initial_person_info");
|
||||
});
|
||||
|
||||
it("strips hash from $current_url", () => {
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({ $current_url: "https://call.example.com/#/x/y/z" }),
|
||||
Anonymity.Pseudonymous,
|
||||
);
|
||||
expect(out?.properties["$current_url"]).not.toContain("/x/y/z");
|
||||
});
|
||||
|
||||
it("nulls referrer and device fields when anonymous", () => {
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({
|
||||
$current_url: "https://x/y",
|
||||
$referrer: "https://leaky",
|
||||
$initial_referrer: "https://leaky-too",
|
||||
$device_id: "uuid",
|
||||
}),
|
||||
Anonymity.Anonymous,
|
||||
);
|
||||
expect(out?.properties["$referrer"]).toBeUndefined();
|
||||
expect(out?.properties["$initial_referrer"]).toBeUndefined();
|
||||
expect(out?.properties["$device_id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes null events through unchanged", () => {
|
||||
expect(santizeSensitiveData(null, Anonymity.Pseudonymous)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips URL fields nested inside $set_once", () => {
|
||||
const secretUrl =
|
||||
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({
|
||||
$current_url: "https://call.example.com/x",
|
||||
$set_once: {
|
||||
$current_url: secretUrl,
|
||||
$initial_current_url: secretUrl,
|
||||
$session_entry_url: secretUrl,
|
||||
$initial_person_info: { r: "x", u: secretUrl },
|
||||
},
|
||||
}),
|
||||
Anonymity.Pseudonymous,
|
||||
);
|
||||
|
||||
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
|
||||
expect(setOnce["$current_url"]).not.toContain("password");
|
||||
expect(setOnce["$initial_current_url"]).not.toContain("password");
|
||||
expect(setOnce).not.toHaveProperty("$session_entry_url");
|
||||
expect(setOnce).not.toHaveProperty("$initial_person_info");
|
||||
});
|
||||
|
||||
it("strips URL fields nested inside $set", () => {
|
||||
const secretUrl =
|
||||
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({
|
||||
$current_url: "https://call.example.com/x",
|
||||
$set: {
|
||||
$current_url: secretUrl,
|
||||
$session_entry_url: secretUrl,
|
||||
},
|
||||
}),
|
||||
Anonymity.Pseudonymous,
|
||||
);
|
||||
|
||||
const set = out?.properties["$set"] as Record<string, unknown>;
|
||||
expect(set["$current_url"]).not.toContain("password");
|
||||
expect(set).not.toHaveProperty("$session_entry_url");
|
||||
});
|
||||
|
||||
it("nulls referrer fields inside $set_once when anonymous", () => {
|
||||
const out = santizeSensitiveData(
|
||||
makeEvent({
|
||||
$current_url: "https://x/y",
|
||||
$set_once: {
|
||||
$initial_referrer: "https://leaky",
|
||||
$initial_referring_domain: "leaky",
|
||||
},
|
||||
}),
|
||||
Anonymity.Anonymous,
|
||||
);
|
||||
|
||||
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
|
||||
expect(setOnce["$initial_referrer"]).toBeUndefined();
|
||||
expect(setOnce["$initial_referring_domain"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Verifies that applyPrivacyFilters is actually wired into posthog.init via
|
||||
// the before_send hook — guards against typos in the option name or future
|
||||
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||
// covered by the applyPrivacyFilters block above.
|
||||
describe("posthog.init wiring", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("passes events through the privacy filter via before_send", () => {
|
||||
const initSpy = vi.spyOn(posthog, "init");
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
|
||||
const beforeSend = initSpy.mock.calls[0][1]?.before_send;
|
||||
expect(beforeSend).toBeInstanceOf(Function);
|
||||
|
||||
const event = {
|
||||
event: "anyEvent",
|
||||
properties: {
|
||||
$current_url: "https://call.example.com/x/y",
|
||||
$initial_person_info: { r: "x" },
|
||||
},
|
||||
} as unknown as CaptureResult;
|
||||
|
||||
const out = (beforeSend as (e: CaptureResult) => CaptureResult | null)(
|
||||
event,
|
||||
);
|
||||
expect(out?.properties).not.toHaveProperty("$initial_person_info");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import posthog, {
|
||||
type CaptureOptions,
|
||||
type CaptureResult,
|
||||
type PostHog,
|
||||
type Properties,
|
||||
} from "posthog-js";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
QualitySurveyEventTracker,
|
||||
CallDisconnectedEventTracker,
|
||||
CallConnectDurationTracker,
|
||||
CallReconnectingTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
@@ -64,6 +66,73 @@ export enum RegistrationType {
|
||||
Registered,
|
||||
}
|
||||
|
||||
// Sanitize URL / referrer / device fields on a single posthog properties bag.
|
||||
// Applied to event.properties and to the person-profile bags ($set / $set_once),
|
||||
// since posthog mirrors the same URL fields into those.
|
||||
function stripSensitiveFields(
|
||||
obj: Properties | undefined,
|
||||
anonymity: Anonymity,
|
||||
): void {
|
||||
if (!obj) return;
|
||||
|
||||
if (anonymity === Anonymity.Anonymous) {
|
||||
// drop referrer information for anonymous users
|
||||
delete obj["$referrer"];
|
||||
delete obj["$referring_domain"];
|
||||
delete obj["$initial_referrer"];
|
||||
delete obj["$initial_referring_domain"];
|
||||
|
||||
// drop device ID, which is a UUID persisted in local storage
|
||||
delete obj["$device_id"];
|
||||
}
|
||||
|
||||
// the url leaks a lot of private data like the call name or the user
|
||||
// (room password / room ID can land in the hash/query). Strip down to
|
||||
// scheme + host so we still get host-level insights (develop / main / sfu).
|
||||
for (const key of ["$current_url", "$initial_current_url"]) {
|
||||
if (typeof obj[key] === "string") {
|
||||
try {
|
||||
const url = new URL(obj[key]);
|
||||
obj[key] = url.protocol + "//" + url.hostname + url.pathname;
|
||||
} catch {
|
||||
obj[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// $session_entry_url carries the full untrimmed URL; $initial_person_info
|
||||
// bundles initial referrer + URL into a nested object that bypasses the
|
||||
// per-key strips above. Drop both.
|
||||
delete obj["$session_entry_url"];
|
||||
delete obj["$initial_person_info"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip PII from posthog's built-in properties (URL, referrer fields,
|
||||
* device ID, $initial_person_info, $session_entry_url) before events leave
|
||||
* the client. Also applied to the person-profile bags ($set / $set_once),
|
||||
* which mirror the same URL fields.
|
||||
* See src/utils/event-utils.ts in posthog-js (getEventProperties, getPersonInfo)
|
||||
* for the list of properties posthog sets automatically.
|
||||
*/
|
||||
export function santizeSensitiveData(
|
||||
event: CaptureResult | null,
|
||||
anonymity: Anonymity,
|
||||
): CaptureResult | null {
|
||||
if (event === null) return null;
|
||||
|
||||
stripSensitiveFields(event.properties, anonymity);
|
||||
// posthog can stash person-profile updates either at the top level
|
||||
// of CaptureResult or nested inside properties depending on the pipeline
|
||||
// stage; clean both spots so nothing slips through.
|
||||
stripSensitiveFields(event.$set, anonymity);
|
||||
stripSensitiveFields(event.$set_once, anonymity);
|
||||
stripSensitiveFields(event.properties["$set"], anonymity);
|
||||
stripSensitiveFields(event.properties["$set_once"], anonymity);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
interface PlatformProperties {
|
||||
appVersion: string;
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
@@ -128,13 +197,16 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
if (apiKey && apiHost) {
|
||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
||||
santizeSensitiveData(event, this.anonymity);
|
||||
this.posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
autocapture: false,
|
||||
mask_all_text: true,
|
||||
mask_all_element_attributes: true,
|
||||
mask_personal_data_properties: true,
|
||||
capture_pageview: false,
|
||||
sanitize_properties: this.sanitizeProperties,
|
||||
before_send: beforeSend,
|
||||
respect_dnt: true,
|
||||
advanced_disable_decide: true,
|
||||
});
|
||||
@@ -147,34 +219,6 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeProperties = (
|
||||
properties: Properties,
|
||||
_eventName: string,
|
||||
): Properties => {
|
||||
// Callback from posthog to sanitize properties before sending them to the server.
|
||||
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
||||
// See utils.js _.info.properties in posthog-js.
|
||||
|
||||
if (this.anonymity == Anonymity.Anonymous) {
|
||||
// drop referrer information for anonymous users
|
||||
properties["$referrer"] = null;
|
||||
properties["$referring_domain"] = null;
|
||||
properties["$initial_referrer"] = null;
|
||||
properties["$initial_referring_domain"] = null;
|
||||
|
||||
// drop device ID, which is a UUID persisted in local storage
|
||||
properties["$device_id"] = null;
|
||||
}
|
||||
// the url leaks a lot of private data like the call name or the user.
|
||||
// Its stripped down to the bare minimum to only give insights about the host (develop, main or sfu)
|
||||
properties["$current_url"] = (properties["$current_url"] as string)
|
||||
.split("/")
|
||||
.slice(0, 3)
|
||||
.join("");
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
private registerSuperProperties(properties: Properties): void {
|
||||
if (this.enabled) {
|
||||
this.posthog.register(properties);
|
||||
@@ -421,4 +465,5 @@ export class PosthogAnalytics {
|
||||
public eventQualitySurvey = new QualitySurveyEventTracker();
|
||||
public eventCallDisconnected = new CallDisconnectedEventTracker();
|
||||
public eventCallConnectDuration = new CallConnectDurationTracker();
|
||||
public eventCallReconnecting = new CallReconnectingTracker();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import { CallEndedTracker } from "./PosthogEvents";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallReconnectingTracker,
|
||||
type CallReconnectingReason,
|
||||
} from "./PosthogEvents";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
const defaultCounters = {
|
||||
@@ -89,6 +93,11 @@ describe("CallEnded", () => {
|
||||
roomEventEncryptionKeysSent: 10,
|
||||
roomEventEncryptionKeysReceived: 5,
|
||||
roomEventEncryptionKeysReceivedAverageAge: 100,
|
||||
callReconnectingCount: 0,
|
||||
callReconnectingCountSync: 0,
|
||||
callReconnectingCountMembership: 0,
|
||||
callReconnectingCountProbablyLeft: 0,
|
||||
callReconnectingCountLivekit: 0,
|
||||
},
|
||||
{ send_instantly: true },
|
||||
);
|
||||
@@ -159,4 +168,70 @@ describe("CallEnded", () => {
|
||||
{ send_instantly: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("includes per-reason reconnecting counts in CallEnded", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.cacheReconnecting("sync");
|
||||
tracker.cacheReconnecting("sync");
|
||||
tracker.cacheReconnecting("livekit");
|
||||
tracker.cacheReconnecting("membership");
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
callReconnectingCount: 4,
|
||||
callReconnectingCountSync: 2,
|
||||
callReconnectingCountMembership: 1,
|
||||
callReconnectingCountProbablyLeft: 0,
|
||||
callReconnectingCountLivekit: 1,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CallReconnecting", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("tracks event with correct shape", () => {
|
||||
const tracker = new CallReconnectingTracker();
|
||||
tracker.track("!room:example.org", "sync", 3.5);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||
eventName: "CallReconnecting",
|
||||
callId: "!room:example.org",
|
||||
reason: "sync",
|
||||
reconnectDuration: 3.5,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"sync",
|
||||
"membership",
|
||||
"probablyLeft",
|
||||
"livekit",
|
||||
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
|
||||
const tracker = new CallReconnectingTracker();
|
||||
tracker.track("!room:example.org", reason, 1.0);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason, reconnectDuration: 1.0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
interface CallEnded extends IPosthogEvent {
|
||||
eventName: "CallEnded";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
callParticipantsOnLeave: number;
|
||||
callParticipantsMax: number;
|
||||
@@ -24,16 +25,43 @@ interface CallEnded extends IPosthogEvent {
|
||||
roomEventEncryptionKeysSent: number;
|
||||
roomEventEncryptionKeysReceived: number;
|
||||
roomEventEncryptionKeysReceivedAverageAge: number;
|
||||
callReconnectingCount: number;
|
||||
callReconnectingCountSync: number;
|
||||
callReconnectingCountMembership: number;
|
||||
callReconnectingCountProbablyLeft: number;
|
||||
callReconnectingCountLivekit: number;
|
||||
}
|
||||
|
||||
export class CallEndedTracker {
|
||||
private cache: { startTime?: Date; maxParticipantsCount: number } = {
|
||||
private cache: {
|
||||
startTime?: Date;
|
||||
maxParticipantsCount: number;
|
||||
reconnectingCount: number;
|
||||
reconnectingCountByReason: Record<CallReconnectingReason, number>;
|
||||
} = {
|
||||
startTime: undefined,
|
||||
maxParticipantsCount: 0,
|
||||
reconnectingCount: 0,
|
||||
reconnectingCountByReason: {
|
||||
sync: 0,
|
||||
membership: 0,
|
||||
probablyLeft: 0,
|
||||
livekit: 0,
|
||||
},
|
||||
};
|
||||
|
||||
public cacheStartCall(time: Date): void {
|
||||
this.cache.startTime = time;
|
||||
this.cache = {
|
||||
startTime: time,
|
||||
maxParticipantsCount: 0,
|
||||
reconnectingCount: 0,
|
||||
reconnectingCountByReason: {
|
||||
sync: 0,
|
||||
membership: 0,
|
||||
probablyLeft: 0,
|
||||
livekit: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public cacheParticipantCountChanged(count: number): void {
|
||||
@@ -43,6 +71,11 @@ export class CallEndedTracker {
|
||||
);
|
||||
}
|
||||
|
||||
public cacheReconnecting(reason: CallReconnectingReason): void {
|
||||
this.cache.reconnectingCount++;
|
||||
this.cache.reconnectingCountByReason[reason]++;
|
||||
}
|
||||
|
||||
public track(
|
||||
callId: string,
|
||||
callParticipantsNow: number,
|
||||
@@ -67,6 +100,14 @@ export class CallEndedTracker {
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
callReconnectingCount: this.cache.reconnectingCount,
|
||||
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
|
||||
callReconnectingCountMembership:
|
||||
this.cache.reconnectingCountByReason.membership,
|
||||
callReconnectingCountProbablyLeft:
|
||||
this.cache.reconnectingCountByReason.probablyLeft,
|
||||
callReconnectingCountLivekit:
|
||||
this.cache.reconnectingCountByReason.livekit,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
@@ -80,6 +121,7 @@ export class CallEndedTracker {
|
||||
|
||||
interface CallStarted extends IPosthogEvent {
|
||||
eventName: "CallStarted";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -140,6 +182,7 @@ export class LoginTracker {
|
||||
interface MuteMicrophone {
|
||||
eventName: "MuteMicrophone";
|
||||
targetMuteState: "mute" | "unmute";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -156,6 +199,7 @@ export class MuteMicrophoneTracker {
|
||||
interface MuteCamera {
|
||||
eventName: "MuteCamera";
|
||||
targetMuteState: "mute" | "unmute";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -171,6 +215,7 @@ export class MuteCameraTracker {
|
||||
|
||||
interface UndecryptableToDeviceEvent {
|
||||
eventName: "UndecryptableToDeviceEvent";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
}
|
||||
|
||||
@@ -185,6 +230,7 @@ export class UndecryptableToDeviceEventTracker {
|
||||
|
||||
interface QualitySurveyEvent {
|
||||
eventName: "QualitySurvey";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
feedbackText: string;
|
||||
stars: number;
|
||||
@@ -249,3 +295,32 @@ export class CallConnectDurationTracker {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type CallReconnectingReason =
|
||||
| "sync"
|
||||
| "membership"
|
||||
| "probablyLeft"
|
||||
| "livekit";
|
||||
|
||||
interface CallReconnecting extends IPosthogEvent {
|
||||
eventName: "CallReconnecting";
|
||||
// the callId posthog key is essentially a Matrix roomId
|
||||
callId: string;
|
||||
reason: CallReconnectingReason;
|
||||
reconnectDuration: number;
|
||||
}
|
||||
|
||||
export class CallReconnectingTracker {
|
||||
public track(
|
||||
callId: string,
|
||||
reason: CallReconnectingReason,
|
||||
reconnectDuration: number,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallReconnecting>({
|
||||
eventName: "CallReconnecting",
|
||||
callId,
|
||||
reason,
|
||||
reconnectDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,17 @@ Please see LICENSE in the repository root for full details.
|
||||
.endCall > svg {
|
||||
color: var(--stopgap-color-on-solid-accent);
|
||||
}
|
||||
|
||||
.rotate > svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
113
src/button/Button.test.tsx
Normal file
113
src/button/Button.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { MicButton, VideoButton } from "./Button";
|
||||
|
||||
describe("MicButton", () => {
|
||||
test("calls onClick when not busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MicButton enabled={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
await user.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call onClick when busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MicButton enabled={true} busy={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
await user.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call onClick when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MicButton enabled={true} disabled={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
await user.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoButton", () => {
|
||||
test("calls onClick when not busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<VideoButton enabled={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
await user.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call onClick when busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<VideoButton enabled={true} busy={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
await user.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call onClick when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<VideoButton enabled={true} disabled={true} onClick={onClick} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("switch");
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
await user.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
MicOnSolidIcon,
|
||||
MicOffSolidIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallSolidIcon,
|
||||
VideoCallOffSolidIcon,
|
||||
EndCallIcon,
|
||||
@@ -32,12 +33,13 @@ import { platform } from "../Platform";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
busy?: boolean;
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, busy, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const Icon = busy ? SpinnerIcon : enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("mute_microphone_button_label")
|
||||
: t("unmute_microphone_button_label");
|
||||
@@ -51,6 +53,11 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
aria-busy={busy}
|
||||
className={classNames(props.className, {
|
||||
[styles.rotate]: !!busy,
|
||||
})}
|
||||
disabled={props.disabled || busy}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -58,12 +65,21 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
busy?: boolean;
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
export const VideoButton: FC<VideoButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
||||
const Icon = busy
|
||||
? SpinnerIcon
|
||||
: enabled
|
||||
? VideoCallSolidIcon
|
||||
: VideoCallOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("stop_video_button_label")
|
||||
: t("start_video_button_label");
|
||||
@@ -77,6 +93,11 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
aria-busy={busy}
|
||||
className={classNames(props.className, {
|
||||
[styles.rotate]: !!busy,
|
||||
})}
|
||||
disabled={props.disabled || busy}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -84,7 +105,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
size: "md" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -111,7 +132,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
};
|
||||
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
@@ -134,7 +155,7 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
};
|
||||
|
||||
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
loudspeakerModeEnabled: boolean;
|
||||
}
|
||||
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
|
||||
@@ -195,7 +216,7 @@ export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
|
||||
};
|
||||
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
/** If this buttons should be setup to be used in the app bar */
|
||||
showForScreenWidth?: "wide" | "narrow";
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const InviteButton: FC<
|
||||
> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}>
|
||||
<Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
|
||||
{t("action.invite")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -173,7 +173,7 @@ export interface ReactionData {
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
reactionData: ReactionData;
|
||||
identifier: string;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
/** List of participants raising their hand */
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_7m_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_36_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_0_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_1j_"
|
||||
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
@@ -14,7 +14,11 @@ Please see LICENSE in the repository root for full details.
|
||||
grid-template-areas: ". buttons layout";
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
||||
/* Ensure that footer lies within the safe area */
|
||||
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
|
||||
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||
padding-block: var(--cpd-space-10x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
@@ -118,13 +122,15 @@ Once we exceed 500 we hide everything except the buttons.
|
||||
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
padding-block: var(--cpd-space-8x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-4x);
|
||||
padding-block: var(--cpd-space-4x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +146,9 @@ Once we exceed 500 we hide everything except the buttons.
|
||||
}
|
||||
.footer {
|
||||
padding-block-start: var(--cpd-space-3x);
|
||||
padding-block-end: var(--cpd-space-2x);
|
||||
padding-block-end: calc(
|
||||
env(safe-area-inset-bottom) + var(--cpd-space-2x)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,69 +5,117 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type ReactNode } from "react";
|
||||
import { type JSX, type ReactNode } from "react";
|
||||
import { Link } from "@vector-im/compound-web";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CallFooter, type FooterProps } from "./CallFooter";
|
||||
import { CallFooter, type FooterSnapshot } from "./CallFooter";
|
||||
import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
|
||||
function CallFooterWrapper(props: FooterProps): ReactNode {
|
||||
return (
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions: false,
|
||||
toggleRaisedHand: async () => Promise.resolve(),
|
||||
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<CallFooter {...props} />
|
||||
</ReactionsSenderContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterWrapper,
|
||||
} satisfies Meta<typeof CallFooterWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
const reactionData = {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
|
||||
* - Add additional react context
|
||||
* The paraeters are all params from the FooterSnapshot,
|
||||
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
|
||||
* `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really.
|
||||
* @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook.
|
||||
*/
|
||||
function CallFooterStoryWrapper({
|
||||
children,
|
||||
...vmSnapshot
|
||||
}: FooterSnapshot & {
|
||||
children?: false | JSX.Element | JSX.Element[] | undefined;
|
||||
}): ReactNode {
|
||||
const vm = useStaticViewModel(vmSnapshot);
|
||||
return (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions: false,
|
||||
toggleRaisedHand: async () => Promise.resolve(),
|
||||
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<CallFooter vm={vm} />
|
||||
</ReactionsSenderContext>
|
||||
</div>
|
||||
</MediaDevicesContext>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterStoryWrapper,
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const fnArgType = {
|
||||
control: { type: "select" as const },
|
||||
options: ["MockedCallback", "undefined"],
|
||||
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
layoutMode: "grid",
|
||||
audioEnabled: true,
|
||||
audioBusy: false,
|
||||
videoEnabled: true,
|
||||
videoBusy: false,
|
||||
setLayoutMode: fn(),
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
toggleBlur: fn(),
|
||||
videoBlurEnabled: true,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
reactionData: undefined,
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
|
||||
layoutMode: {
|
||||
control: "radio",
|
||||
options: ["grid", "spotlight"] satisfies GridMode[],
|
||||
},
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
@@ -88,11 +136,49 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: true,
|
||||
audioOptions: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
audioBusy: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
videoBusy: true,
|
||||
},
|
||||
};
|
||||
export const WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: false,
|
||||
showLogo: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,6 +189,51 @@ export const AudioVideoEnabled: Story = {
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
/** used to test switching to grid mode */
|
||||
export const SpotlightMode: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
layoutMode: "spotlight",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioOutputSpeaker: Story = {
|
||||
@@ -132,7 +263,37 @@ export const Pip: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
asPip: true,
|
||||
buttonSize: "md",
|
||||
layoutMode: undefined,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.queryByRole("radio", { name: "Spotlight" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
export const NoControlsWithLogo: Story = {
|
||||
@@ -140,7 +301,7 @@ export const NoControlsWithLogo: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
hideControls: true,
|
||||
hideLogo: false,
|
||||
showLogo: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -157,6 +318,8 @@ export const UnavailableMediaDevices: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
audioOutputSwitcher: undefined,
|
||||
@@ -167,7 +330,7 @@ export const MobileLayout: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
|
||||
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
@@ -183,7 +346,7 @@ export const Lobby: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
openSettings: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
@@ -197,7 +360,7 @@ export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
@@ -215,7 +378,7 @@ export const LobbyRecentButton: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
@@ -229,7 +392,7 @@ export const LobbyRecentButtonMobile: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
hideLogo: true,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -23,93 +28,132 @@ import {
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { LayoutToggle } from "../room/LayoutToggle";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
switch: () => void;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
/** Children will only be visible if the component is wider than 5*/
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
|
||||
audioEnabled: boolean;
|
||||
/**
|
||||
* The Snapshot combines all fields required to populate the view.
|
||||
*
|
||||
* It is a combination of Actions and State.
|
||||
* All Actions and State will be wrappen in behaviors.
|
||||
* This has the advantage, that actions can mutate.
|
||||
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
|
||||
* With it being reactive we can use the existance of the action to update the rendering without
|
||||
* requiring additional state.
|
||||
*
|
||||
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
|
||||
* just happens on the syntax level with the `type = ... & ...` notation.
|
||||
*/
|
||||
export type FooterSnapshot = FooterActions & FooterState;
|
||||
export interface FooterActions {
|
||||
/** Also controls if the audioMute button is disabled */
|
||||
toggleAudio: (() => void) | undefined;
|
||||
videoEnabled: boolean;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode: ((mode: GridMode) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup: (() => void) | undefined;
|
||||
}
|
||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||
export interface FooterState {
|
||||
audioEnabled: boolean;
|
||||
audioBusy: boolean;
|
||||
videoEnabled: boolean;
|
||||
videoBusy: boolean;
|
||||
videoBlurEnabled: boolean;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
hideControls?: boolean;
|
||||
/** hide the entire footer*/
|
||||
hidden?: boolean;
|
||||
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
|
||||
asPip?: boolean;
|
||||
hideControls: boolean;
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens.*/
|
||||
asOverlay?: boolean;
|
||||
* (Over the Call Grid) This saves spaces on small screens. */
|
||||
asOverlay: boolean;
|
||||
|
||||
layoutMode?: GridMode;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode?: (mode: GridMode) => void;
|
||||
buttonSize: "md" | "lg";
|
||||
showLogo: boolean;
|
||||
|
||||
sharingScreen?: boolean;
|
||||
toggleScreenSharing?: () => void;
|
||||
layoutMode: GridMode | undefined;
|
||||
|
||||
/** Also controls if the audio button is visible */
|
||||
audioOutputSwitcher?: AudioOutputSwitcher;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings?: () => void;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup?: () => void;
|
||||
sharingScreen: boolean;
|
||||
|
||||
reactionIdentifier?: string;
|
||||
reactionData?: ReactionData;
|
||||
/** Also controls if the audio output button is visible */
|
||||
audioOutputSwitcher: AudioOutputSwitcher | undefined;
|
||||
|
||||
reactionIdentifier: string | undefined;
|
||||
reactionData: ReactionData | undefined;
|
||||
|
||||
hideLogo?: boolean;
|
||||
// debug stuff
|
||||
debugTileLayout?: boolean;
|
||||
tileStoreGeneration?: number;
|
||||
debugTileLayout: boolean;
|
||||
tileStoreGeneration: number | undefined;
|
||||
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
audioOptions: MenuOptions[];
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
videoOptions: MenuOptions[];
|
||||
selectedAudio: string | undefined;
|
||||
selectedVideo: string | undefined;
|
||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
ref,
|
||||
children,
|
||||
asOverlay,
|
||||
hidden,
|
||||
hideControls,
|
||||
hideLogo,
|
||||
asPip,
|
||||
layoutMode,
|
||||
setLayoutMode,
|
||||
openSettings,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
sharingScreen,
|
||||
toggleScreenSharing,
|
||||
reactionIdentifier,
|
||||
reactionData,
|
||||
audioOutputSwitcher,
|
||||
hangup,
|
||||
debugTileLayout,
|
||||
tileStoreGeneration,
|
||||
}) => {
|
||||
export interface FooterProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const layoutMode = useBehavior(vm.layoutMode$);
|
||||
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const audioBusy = useBehavior(vm.audioBusy$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const videoBusy = useBehavior(vm.videoBusy$);
|
||||
const toggleAudio = useBehavior(vm.toggleAudio$);
|
||||
const toggleVideo = useBehavior(vm.toggleVideo$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
|
||||
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
|
||||
const reactionData = useBehavior(vm.reactionData$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const hangup = useBehavior(vm.hangup$);
|
||||
const debugTileLayout = useBehavior(vm.debugTileLayout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const videoOptions = useBehavior(vm.videoOptions$);
|
||||
const selectedVideo = useBehavior(vm.selectedVideo$);
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
const buttonSize = asPip ? "sm" : "lg";
|
||||
const showSettingsButton =
|
||||
openSettings !== undefined && !asPip && !hideControls;
|
||||
const showLayoutSwitcher = !asPip && !hideControls;
|
||||
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
|
||||
const showLogo = !hideLogo && !asPip;
|
||||
if (showSettingsButton) {
|
||||
// add the settings button to the center group of buttons, so it will be visible on small screens.
|
||||
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
|
||||
|
||||
if (openSettings !== undefined) {
|
||||
// Add the settings button to the center group so it's visible on small
|
||||
// screens. On larger screens the SettingsIconButton with
|
||||
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
|
||||
buttons.push(
|
||||
<SettingsButton
|
||||
key="settings"
|
||||
@@ -120,24 +164,64 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onMuteClick={toggleAudio}
|
||||
data-testid="incall_mute"
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={(audioBusy ?? false) || toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Camera Source"}
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onMuteClick={toggleVideo}
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
videoBlurToggleClick={toggleBlur}
|
||||
videoBlurEnabled={videoBlurEnabled}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={(videoBusy ?? false) || toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (toggleScreenSharing !== undefined) {
|
||||
buttons.push(
|
||||
@@ -156,12 +240,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
reactionData={
|
||||
reactionData ?? {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
}
|
||||
}
|
||||
reactionData={reactionData}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
identifier={reactionIdentifier}
|
||||
@@ -212,13 +291,14 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="footer-container"
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: hidden,
|
||||
[styles.hidden]: !showFooter,
|
||||
})}
|
||||
>
|
||||
<div className={styles.settingsLogoContainer}>
|
||||
{showSettingsButton && (
|
||||
{openSettings !== undefined && (
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
kind="secondary"
|
||||
@@ -228,14 +308,22 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{showLogoDebugContainer && logoDebugContainer}
|
||||
{(showLogo || debugTileLayout) && logoDebugContainer}
|
||||
</div>
|
||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
||||
<LayoutToggle
|
||||
{!hideControls && setLayoutMode && layoutMode && (
|
||||
<Switch<"spotlight", "grid">
|
||||
name="layoutMode"
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={styles.layout}
|
||||
layout={layoutMode}
|
||||
setLayout={setLayoutMode}
|
||||
value={layoutMode}
|
||||
onChange={setLayoutMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
157
src/components/CallFooterViewModel.test.ts
Normal file
157
src/components/CallFooterViewModel.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
get platform(): string {
|
||||
return platformMock();
|
||||
},
|
||||
}));
|
||||
|
||||
// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not
|
||||
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
* simple constant values.
|
||||
*/
|
||||
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
return {
|
||||
layout$: constant(layout),
|
||||
edgeToEdge$: constant(false),
|
||||
showHeader$: constant(false),
|
||||
hangup: (): void => {},
|
||||
gridMode$: constant("grid"),
|
||||
setGridMode: (): void => {},
|
||||
sharingScreen$: constant(false),
|
||||
toggleScreenSharing: null,
|
||||
audioOutputSwitcher$: constant(null),
|
||||
handsRaised$: constant({}),
|
||||
reactions$: constant({}),
|
||||
tileStoreGeneration$: constant(0),
|
||||
showFooter$: constant(true),
|
||||
settingsOpen$: constant(false),
|
||||
setSettingsOpen$: constant(() => {}),
|
||||
} as unknown as CallViewModel;
|
||||
}
|
||||
|
||||
/** A regular grid layout (not PiP). */
|
||||
const gridLayout: Layout = {
|
||||
type: "grid",
|
||||
grid: [],
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: (_: number) => {},
|
||||
};
|
||||
|
||||
/** A PiP layout – only the `type` matters for the tests. */
|
||||
const pipLayout: Layout = {
|
||||
type: "pip",
|
||||
spotlight: {} as SpotlightTileViewModel,
|
||||
};
|
||||
|
||||
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "number", number: 1 }],
|
||||
["mic2", { type: "name", name: "Microphone 2" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
videoInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["cam1", { type: "name", name: "Camera 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
expect(vm.videoOptions$.value).toEqual([]);
|
||||
}
|
||||
it("are both empty when the platform is iOS", () => {
|
||||
checkEmptyFor("ios", gridLayout);
|
||||
});
|
||||
it("are both empty when the layout is pip", () => {
|
||||
checkEmptyFor("desktop", pipLayout);
|
||||
});
|
||||
|
||||
it("are populated when the platform is desktop and the layout is not PiP", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
{
|
||||
id: "mic1",
|
||||
label: {
|
||||
number: 1,
|
||||
type: "number",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mic2",
|
||||
label: {
|
||||
name: "Microphone 2",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(vm.videoOptions$?.value).toEqual([
|
||||
{
|
||||
id: "cam1",
|
||||
label: {
|
||||
name: "Camera 1",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
281
src/components/CallFooterViewModel.tsx
Normal file
281
src/components/CallFooterViewModel.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { getUrlParams, HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
* needed by FooterSnapshot.
|
||||
*/
|
||||
function buildMuteBehaviors(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioEnabled$"
|
||||
| "audioBusy$"
|
||||
| "toggleAudio$"
|
||||
| "videoEnabled$"
|
||||
| "videoBusy$"
|
||||
| "toggleVideo$"
|
||||
> {
|
||||
return {
|
||||
audioEnabled$: muteStates.audio.enabled$,
|
||||
audioBusy$: muteStates.audio.syncing$,
|
||||
toggleAudio$: scope.behavior(
|
||||
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
videoEnabled$: muteStates.video.enabled$,
|
||||
videoBusy$: muteStates.video.syncing$,
|
||||
toggleVideo$: scope.behavior(
|
||||
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
> {
|
||||
return {
|
||||
audioOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.audioInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
selectedAudio$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||
videoOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.videoInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
toggleBlur$: scope.behavior(
|
||||
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
|
||||
map(([current, switcherDisabled]) => {
|
||||
return !switcherDisabled && supportsBackgroundProcessors()
|
||||
? (): void => {
|
||||
backgroundBlurSettings.setValue(!current);
|
||||
}
|
||||
: undefined;
|
||||
}),
|
||||
),
|
||||
),
|
||||
videoBlurEnabled$: backgroundBlurSettings.value$,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ViewModel for the CallFooter.
|
||||
*
|
||||
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
|
||||
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
*/
|
||||
export function createCallFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
reactionIdentifier: string | undefined,
|
||||
): ViewModel<FooterSnapshot> {
|
||||
const { showControls, header: headerStyle } = getUrlParams();
|
||||
const showLogo = headerStyle === HeaderStyle.Standard;
|
||||
|
||||
const isPip$ = scope.behavior(
|
||||
callModel.layout$.pipe(map((l) => l.type === "pip")),
|
||||
);
|
||||
const disableDeviceSwitcher$ = scope.behavior(
|
||||
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
asOverlay$: callModel.edgeToEdge$,
|
||||
buttonSize$: scope.behavior(
|
||||
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
|
||||
),
|
||||
|
||||
openSettings$: scope.behavior(
|
||||
combineLatest([
|
||||
isPip$,
|
||||
callModel.showHeader$,
|
||||
callModel.setSettingsOpen$,
|
||||
]).pipe(
|
||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||
!isPip &&
|
||||
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
|
||||
showControls
|
||||
? (): void => setSettingsOpen(true)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
|
||||
|
||||
layoutMode$: callModel.gridMode$,
|
||||
setLayoutMode$: scope.behavior(
|
||||
isPip$.pipe(
|
||||
map((isPip) =>
|
||||
!isPip && showControls ? callModel.setGridMode : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
sharingScreen$: callModel.sharingScreen$,
|
||||
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
|
||||
|
||||
audioOutputSwitcher$: scope.behavior(
|
||||
callModel.audioOutputSwitcher$.pipe(
|
||||
map((switcher) => switcher ?? undefined),
|
||||
),
|
||||
),
|
||||
|
||||
hangup$: constant(callModel.hangup),
|
||||
|
||||
reactionIdentifier$: constant(reactionIdentifier),
|
||||
reactionData$: constant(
|
||||
reactionIdentifier !== undefined
|
||||
? {
|
||||
handsRaised$: callModel.handsRaised$,
|
||||
reactions$: callModel.reactions$,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
|
||||
debugTileLayout$: debugTileLayoutSetting.value$,
|
||||
tileStoreGeneration$: callModel.tileStoreGeneration$,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simplified ViewModel for the CallFooter used in the lobby
|
||||
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
|
||||
* a CallViewModel — it only needs mute states, device lists, and callbacks.
|
||||
*
|
||||
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param openSettings - Callback to open the settings modal, or undefined.
|
||||
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
|
||||
* @param showLogo - Whether to show the Element Call logo.
|
||||
*/
|
||||
export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
): ViewModel<FooterSnapshot> {
|
||||
return {
|
||||
...createStaticViewModel({
|
||||
// we can safly skip any props that we do not need.
|
||||
// The view model will then have less keys.
|
||||
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
|
||||
// be correcty matching the snapshot type.
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
buttonSize: "lg",
|
||||
showLayoutSwitcher: false,
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
audioEnabled: undefined,
|
||||
audioBusy: false,
|
||||
videoEnabled: undefined,
|
||||
videoBusy: false,
|
||||
layoutMode: undefined,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: undefined,
|
||||
videoOptions: undefined,
|
||||
selectedAudio: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
};
|
||||
}
|
||||
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
border-radius: 32px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
.containerOpen {
|
||||
background-color: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
.chevronIconOpen > svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
.menuButton {
|
||||
width: 40px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.itemIcon {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.rotate {
|
||||
animation: spinner 1.5s linear infinite;
|
||||
}
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
113
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
113
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn, userEvent, within, expect } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<Story />
|
||||
</MediaDevicesContext>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "SomeMenu",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Option 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
||||
],
|
||||
selectedOption: "1",
|
||||
onMuteClick: fn(),
|
||||
onSelect: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioMute: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: false,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
|
||||
// (both are TODO placeholders in the component). The mute button is first in the DOM.
|
||||
const muteButton = canvas.getByTestId("incall_mute");
|
||||
await userEvent.click(muteButton);
|
||||
await expect(args.onMuteClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioUnmute: Story = {
|
||||
args: {
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoMute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: false,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
346
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
346
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type JSX, useState, type ReactNode } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
component: ReactNode,
|
||||
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext
|
||||
value={{ requestDeviceNames } as unknown as MediaDevices}
|
||||
>
|
||||
{component}
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaMuteAndSwitchButton", () => {
|
||||
test("renders", () => {
|
||||
const { container } = renderComponent(
|
||||
<TooltipProvider>
|
||||
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("renders correct audio and video labels", () => {
|
||||
const renderLabels = (
|
||||
type: "video" | "audio",
|
||||
enabled: boolean,
|
||||
): RenderResult => {
|
||||
return renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
iconsAndLabels={type}
|
||||
enabled={enabled}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
const renderAudioEndabled = renderLabels("audio", true);
|
||||
const renderAudioDisabled = renderLabels("audio", false);
|
||||
const renderVideoEnabled = renderLabels("video", true);
|
||||
const renderVideoDisabled = renderLabels("video", false);
|
||||
|
||||
expect(
|
||||
renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoEnabled.getByRole("switch", { name: "Start video" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
renderVideoDisabled.getByRole("switch", { name: "Stop video" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls mute on mute press", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("switch", { name: "Mute microphone" }));
|
||||
|
||||
expect(onMute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables mute button while busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
busy={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const muteButton = getByRole("switch", { name: "Mute microphone" });
|
||||
expect(muteButton).toHaveAttribute("aria-disabled", "true");
|
||||
expect(muteButton).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
await user.click(muteButton);
|
||||
expect(onMute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables video button while busy", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
busy={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const videoButton = getByRole("switch", { name: "Stop video" });
|
||||
expect(videoButton).toHaveAttribute("aria-disabled", "true");
|
||||
expect(videoButton).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
await user.click(videoButton);
|
||||
expect(onMute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requests device names when opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const requestDeviceNames = vi.fn();
|
||||
renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
/>,
|
||||
{ requestDeviceNames },
|
||||
);
|
||||
|
||||
expect(requestDeviceNames).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
expect(requestDeviceNames).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows numbered devices correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent(
|
||||
<>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
options={[
|
||||
{ label: { type: "number", number: 1 }, id: "mic1" },
|
||||
{ label: { type: "number", number: 2 }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
/>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled
|
||||
options={[
|
||||
{ label: { type: "number", number: 1 }, id: "cam1" },
|
||||
{ label: { type: "number", number: 2 }, id: "cam2" },
|
||||
]}
|
||||
selectedOption="cam1"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
await user.keyboard("[Escape]");
|
||||
await user.click(screen.getByRole("button", { name: "Camera" }));
|
||||
screen.getByRole("menuitem", { name: "Camera 1" });
|
||||
screen.getByRole("menuitem", { name: "Camera 2" });
|
||||
});
|
||||
|
||||
test("calls select callback on menu click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mic2");
|
||||
});
|
||||
test("does not call select callback on already selected menu click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders menu spinner until selection updates for the component", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
const onSelectPressed = vi.fn();
|
||||
const onOptionUpdated = vi.fn();
|
||||
function Wrapper(): JSX.Element {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
onSelectPressed();
|
||||
void promise.then(() => {
|
||||
setSelectedOption(id);
|
||||
onOptionUpdated();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = renderComponent(<Wrapper />);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
|
||||
expect(onSelectPressed).toHaveBeenCalled();
|
||||
expect(onOptionUpdated).not.toHaveBeenCalled();
|
||||
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
||||
// so a spinner should appear on the mic2 item
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
|
||||
|
||||
// The currently-selected mic1 item should not have a spinner
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic1Item.querySelector(".rotate")).toBeNull();
|
||||
await act(async () => {
|
||||
// resolve the promise that acutally updates the select option.
|
||||
resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(onOptionUpdated).toHaveBeenCalled();
|
||||
// Spinner should now be gone since the selection has caught up
|
||||
const mic2ItemAfter = screen.getByRole("menuitem", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Blur background",
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders check icon to mark the selected menu item", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic2"
|
||||
/>,
|
||||
);
|
||||
|
||||
// open menu
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||
});
|
||||
});
|
||||
227
src/components/MediaMuteAndSwitchButton.tsx
Normal file
227
src/components/MediaMuteAndSwitchButton.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentType, useState, type FC, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOnIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { type DeviceLabel } from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MediaMuteAndSwitchButtonProps {
|
||||
/** The title used in the Switcher modal. */
|
||||
title: string;
|
||||
/** If the Mute button is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback if the mute button is clicked */
|
||||
onMuteClick?: () => void;
|
||||
/** True while mute/unmute operation is syncing. */
|
||||
busy?: boolean;
|
||||
iconsAndLabels: "video" | "audio";
|
||||
/** The options available for the media device selector modal */
|
||||
options?: MenuOptions[];
|
||||
/** The option that will currently be rendered as the selected option */
|
||||
selectedOption?: string;
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/**
|
||||
* For any toggle and option this method will be called.
|
||||
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||
*/
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
title,
|
||||
enabled,
|
||||
busy,
|
||||
onMuteClick,
|
||||
iconsAndLabels,
|
||||
options,
|
||||
selectedOption,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const isBusy = busy ?? false;
|
||||
const { t } = useTranslation();
|
||||
const devices = useMediaDevices();
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
|
||||
let button;
|
||||
let toggles: { label: string; enabled: boolean; id: string }[] = [];
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
button = (
|
||||
<VideoButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>
|
||||
);
|
||||
if (videoBlurToggleClick !== undefined) {
|
||||
toggles = [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
];
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
button = (
|
||||
<MicButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
|
||||
let optionsButtonLabel: string;
|
||||
let numberedLabel: (number: number) => string;
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
IconOptions = VideoCallIcon;
|
||||
optionsButtonLabel = t("settings.devices.camera");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.camera_numbered", { n });
|
||||
break;
|
||||
case "audio":
|
||||
IconOptions = MicOnIcon;
|
||||
optionsButtonLabel = t("settings.devices.microphone");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.microphone_numbered", { n });
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
[styles.container]: true,
|
||||
[styles.containerOpen]: menuOpen,
|
||||
})}
|
||||
>
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
<Menu
|
||||
title={title}
|
||||
showTitle={true}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
side="top"
|
||||
trigger={
|
||||
<Button
|
||||
iconOnly
|
||||
className={classNames({
|
||||
[styles.menuButton]: true,
|
||||
[styles.chevronIconOpen]: menuOpen,
|
||||
})}
|
||||
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
|
||||
kind={"tertiary"}
|
||||
size="lg"
|
||||
aria-label={optionsButtonLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{options?.map(({ label, id }) => {
|
||||
let labelText: string;
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
labelText = label.name;
|
||||
break;
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
label={labelText}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selectedOption) return;
|
||||
setPlannedSelection(id);
|
||||
onSelect?.(id);
|
||||
}}
|
||||
key={id}
|
||||
>
|
||||
{selectedOption === id && <CheckIcon width={24} height={24} />}
|
||||
{selectedOption !== id && plannedSelection === id && (
|
||||
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled ?? false}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_0_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
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="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_5_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
54
src/config/Config.test.ts
Normal file
54
src/config/Config.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
Copyright 2026 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { validateConfig } from "./Config";
|
||||
import { MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
describe("validateConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("passes through a missing matrix_rtc_mode unchanged", () => {
|
||||
const result = validateConfig({});
|
||||
expect(result.matrix_rtc_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(Object.values(MatrixRTCMode))(
|
||||
"keeps a valid matrix_rtc_mode value (%s)",
|
||||
(mode) => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const result = validateConfig({ matrix_rtc_mode: mode });
|
||||
expect(result.matrix_rtc_mode).toBe(mode);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("drops an invalid matrix_rtc_mode value and warns", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const result = validateConfig({
|
||||
// Intentionally bypass the type to simulate bad JSON.
|
||||
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
|
||||
});
|
||||
expect(result.matrix_rtc_mode).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][0]).toContain("nonsense");
|
||||
});
|
||||
|
||||
it("does not touch unrelated fields when dropping an invalid mode", () => {
|
||||
vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
const result = validateConfig({
|
||||
matrix_rtc_mode: "nope" as unknown as MatrixRTCMode,
|
||||
ssla: "https://example.invalid/ssla",
|
||||
});
|
||||
expect(result.matrix_rtc_mode).toBeUndefined();
|
||||
expect(result.ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { merge } from "lodash-es";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import {
|
||||
@@ -14,6 +15,11 @@ import {
|
||||
type ResolvedConfigOptions,
|
||||
} from "./ConfigOptions";
|
||||
import { isFailure } from "../utils/fetch";
|
||||
import { MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
const VALID_MATRIX_RTC_MODES: ReadonlySet<string> = new Set(
|
||||
Object.values(MatrixRTCMode),
|
||||
);
|
||||
|
||||
export class Config {
|
||||
private static internalInstance: Config | undefined;
|
||||
@@ -44,7 +50,11 @@ export class Config {
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
internalInstance.config = merge(
|
||||
{},
|
||||
DEFAULT_CONFIG,
|
||||
validateConfig(config),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -84,6 +94,17 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
const mode = config.matrix_rtc_mode;
|
||||
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
||||
logger.warn(
|
||||
`Ignoring invalid matrix_rtc_mode in config.json: ${String(mode)}`,
|
||||
);
|
||||
delete config.matrix_rtc_mode;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
const response = await fetch(fetchTarget);
|
||||
|
||||
|
||||
@@ -6,6 +6,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MatrixRTC mode determines how Element Call interacts with the
|
||||
* MatrixRTC backend and other participants. Selectable via the Developer
|
||||
* Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
|
||||
*/
|
||||
export enum MatrixRTCMode {
|
||||
/** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */
|
||||
Legacy = "legacy",
|
||||
/** Multi-SFU transport, legacy JWT endpoint, no sticky events. */
|
||||
Compatibility = "compatibility",
|
||||
/**
|
||||
* Multi-SFU transport with:
|
||||
* - sticky events
|
||||
* - hashed RTC backend identity
|
||||
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
|
||||
* - use the hashed identity for the local membership
|
||||
*/
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
|
||||
export interface ConfigOptions {
|
||||
/**
|
||||
* The Posthog endpoint to which analytics data will be sent.
|
||||
@@ -152,6 +172,20 @@ export interface ConfigOptions {
|
||||
enable_video?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
|
||||
* This allows brief sync interruptions without triggering a reconnection message.
|
||||
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
|
||||
*/
|
||||
sync_disconnect_grace_period_ms?: number;
|
||||
|
||||
/**
|
||||
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
|
||||
* overriding any per-user choice from the Developer Settings. If unset,
|
||||
* the user's Developer Settings choice (or its default of `Legacy`) wins.
|
||||
*/
|
||||
matrix_rtc_mode?: MatrixRTCMode;
|
||||
|
||||
/**
|
||||
* These are low level options that are used to configure the MatrixRTC session.
|
||||
* Take care when changing these options.
|
||||
@@ -210,6 +244,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
sync_disconnect_grace_period_ms: number;
|
||||
ssla: string;
|
||||
media_quality: Required<
|
||||
Pick<NonNullable<ConfigOptions["media_quality"]>, "video_codec">
|
||||
@@ -229,6 +264,14 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
>
|
||||
>;
|
||||
};
|
||||
matrix_rtc_session: {
|
||||
wait_for_key_rotation_ms?: number;
|
||||
delayed_leave_event_delay_ms: number;
|
||||
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
network_error_retry_ms: number;
|
||||
membership_event_expiry_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
@@ -241,6 +284,7 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
features: {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
media_quality: {
|
||||
video_codec: "vp8",
|
||||
@@ -255,4 +299,8 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
max_framerate: 30,
|
||||
},
|
||||
},
|
||||
matrix_rtc_session: {
|
||||
delayed_leave_event_delay_ms: 10000,
|
||||
network_error_retry_ms: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject, type Observable } from "rxjs";
|
||||
import { type Observable } from "rxjs";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
import { type LayoutProps } from "./Grid";
|
||||
@@ -16,37 +16,18 @@ export interface Bounds {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export const defaultSpotlightAlignment: Alignment = {
|
||||
inline: "end",
|
||||
block: "end",
|
||||
};
|
||||
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
|
||||
|
||||
export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
minBounds$: Observable<Bounds>;
|
||||
/**
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
*/
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present.
|
||||
*/
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
/**
|
||||
* Whether the scrolling layer of the layout should appear on top.
|
||||
* Which layer should appear in the foreground.
|
||||
*/
|
||||
scrollingOnTop: boolean;
|
||||
foreground: "fixed" | "scrolling";
|
||||
/**
|
||||
* The visually fixed (non-scrolling) layer of the layout.
|
||||
*/
|
||||
|
||||
@@ -266,6 +266,20 @@ export function Grid<
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
const orientation = useSyncExternalStore(
|
||||
useCallback((onChange) => {
|
||||
// Support for the change event is experimental
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
|
||||
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
|
||||
return (): void =>
|
||||
(screen as unknown as EventTarget).removeEventListener?.(
|
||||
"change",
|
||||
onChange,
|
||||
);
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
@@ -336,10 +350,10 @@ export function Grid<
|
||||
}
|
||||
|
||||
return result;
|
||||
// The rects may change due to the grid resizing or updating to a new
|
||||
// generation, but eslint can't statically verify this
|
||||
// The rects may change due to the grid resizing, changing orientation, or
|
||||
// updating to a new generation, but eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
|
||||
|
||||
// The height of the portion of the grid visible at any given time
|
||||
const visibleHeight = useMemo(
|
||||
|
||||
@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset-block: 0;
|
||||
inset-inline: var(--cpd-space-3x);
|
||||
/* Ensure that spotlight tile lies within the safe area */
|
||||
inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
|
||||
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties {
|
||||
*/
|
||||
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
minBounds$,
|
||||
spotlightAlignment$,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
foreground: "fixed",
|
||||
|
||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||
// lives
|
||||
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
spotlightAlignment$.pipe(
|
||||
model.spotlightAlignment$.pipe(
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||
),
|
||||
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
spotlightAlignment$.next({
|
||||
model.spotlightAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.spotlightAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
}
|
||||
|
||||
.slot[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
@@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import styles from "./OneOnOneLandscapeLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||
* is shown at maximum size, overlaid by a small view of the local participant.
|
||||
* An implementation of the "one-on-one" layout for landscape screens, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
minBounds$,
|
||||
pipAlignment$,
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
OneOnOneLandscapeLayoutModel
|
||||
> = ({ minBounds$ }) => ({
|
||||
foreground: "fixed",
|
||||
|
||||
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
|
||||
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
},
|
||||
|
||||
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
|
||||
scrolling: function OneOnOneLandscapeLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
@@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
pipAlignment$.next({
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
</Slot>
|
||||
</div>
|
||||
46
src/grid/OneOnOnePortraitLayout.module.css
Normal file
46
src/grid/OneOnOnePortraitLayout.module.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.layer {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.pip {
|
||||
position: absolute;
|
||||
inset: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
block-size: 132px;
|
||||
}
|
||||
|
||||
.pip[data-size="lg"] {
|
||||
inline-size: 140px;
|
||||
block-size: 210px;
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="start"] {
|
||||
inset-block-end: unset;
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="end"] {
|
||||
inset-block-start: unset;
|
||||
}
|
||||
|
||||
.pip[data-inline-alignment="start"] {
|
||||
inset-inline-end: unset;
|
||||
}
|
||||
|
||||
.pip[data-inline-alignment="end"] {
|
||||
inset-inline-start: unset;
|
||||
}
|
||||
74
src/grid/OneOnOnePortraitLayout.tsx
Normal file
74
src/grid/OneOnOnePortraitLayout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOnePortraitLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for portrait screens, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
OneOnOnePortraitLayoutModel
|
||||
> = () => ({
|
||||
foreground: "scrolling",
|
||||
|
||||
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
className={styles.spotlight}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
scrolling: function OneOnOnePortraitLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipSize = useBehavior(model.pipSize$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={classNames(styles.pip)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-size={pipSize}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 135px;
|
||||
block-size: 160px;
|
||||
inset: var(--cpd-space-4x);
|
||||
/* Ensure that PiP lies within the safe area */
|
||||
inset: calc(env(safe-area-inset-top) + var(--cpd-space-4x))
|
||||
var(--content-inset-right)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x))
|
||||
var(--content-inset-left);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
|
||||
@@ -19,8 +19,8 @@ import { useBehavior } from "../useBehavior";
|
||||
*/
|
||||
export const makeSpotlightExpandedLayout: CallLayout<
|
||||
SpotlightExpandedLayoutModel
|
||||
> = ({ pipAlignment$ }) => ({
|
||||
scrollingOnTop: true,
|
||||
> = () => ({
|
||||
foreground: "scrolling",
|
||||
|
||||
fixed: function SpotlightExpandedLayoutFixed({
|
||||
ref,
|
||||
@@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
pipAlignment$.next({
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[],
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignmentValue.block}
|
||||
data-inline-alignment={pipAlignmentValue.inline}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
SpotlightLandscapeLayoutModel
|
||||
> = ({ minBounds$ }) => ({
|
||||
scrollingOnTop: false,
|
||||
foreground: "scrolling",
|
||||
|
||||
fixed: function SpotlightLandscapeLayoutFixed({
|
||||
ref,
|
||||
|
||||
@@ -29,7 +29,7 @@ interface GridCSSProperties extends CSSProperties {
|
||||
export const makeSpotlightPortraitLayout: CallLayout<
|
||||
SpotlightPortraitLayoutModel
|
||||
> = ({ minBounds$ }) => ({
|
||||
scrollingOnTop: false,
|
||||
foreground: "fixed",
|
||||
|
||||
fixed: function SpotlightPortraitLayoutFixed({
|
||||
ref,
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.tile.draggable {
|
||||
cursor: grab;
|
||||
box-shadow: var(--big-drop-shadow);
|
||||
--draggable-shadow: var(--big-drop-shadow);
|
||||
}
|
||||
|
||||
.tile.draggable:active {
|
||||
|
||||
@@ -37,10 +37,20 @@ layer(compound);
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows. */
|
||||
--inline-content-inset: min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
createRoutesFromChildren,
|
||||
matchRoutes,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
} from "livekit-client";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
@@ -30,6 +34,7 @@ import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -190,6 +195,18 @@ export class Initializer {
|
||||
|
||||
// Add the platform to the DOM, so CSS can query it
|
||||
document.body.setAttribute("data-platform", platform);
|
||||
|
||||
// livekit logging configuration
|
||||
setLKLogExtension((level, msg, context) => {
|
||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
||||
global.mx_rage_logger.log(level, "livekit", msg, context);
|
||||
});
|
||||
|
||||
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
||||
setLKLogLevel(enabled ? "trace" : "info");
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
}
|
||||
|
||||
public static init(): Promise<void> | null {
|
||||
|
||||
@@ -113,7 +113,7 @@ export const AvatarInputField: FC<Props> = ({
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
size="md"
|
||||
aria-label={t("action.edit")}
|
||||
/>
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const AvatarInputField: FC<Props> = ({
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
size="md"
|
||||
aria-label={t("action.edit")}
|
||||
onClick={onSelectUpload}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||
import { testJWTToken } from "../utils/test-fixtures";
|
||||
@@ -63,7 +64,10 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
@@ -75,26 +79,128 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
);
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toEqual(
|
||||
new Error("SFU Config fetch failed with status code 500"),
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
|
||||
void (await fetchMock.flush());
|
||||
return;
|
||||
}
|
||||
expect.fail("Expected test to throw;");
|
||||
});
|
||||
|
||||
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||
let callCount = 0;
|
||||
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
callCount++;
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// First call: check if it has delay parts and return 400
|
||||
if (callCount === 1) {
|
||||
expect(body).toHaveProperty("delay_id", "mock_delay_id");
|
||||
return {
|
||||
status: 400,
|
||||
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
|
||||
};
|
||||
}
|
||||
|
||||
// Second call: check if delay parts were stripped and return success
|
||||
expect(body).not.toHaveProperty("delay_id");
|
||||
expect(body).not.toHaveProperty("delay_timeout");
|
||||
expect(body).not.toHaveProperty("delay_cs_api_url");
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: sfuUrl, jwt: testJWTToken },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
// Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT
|
||||
const config = await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
{
|
||||
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||
delayId: "mock_delay_id",
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.jwt).toBe(testJWTToken);
|
||||
expect(callCount).toBe(2);
|
||||
void (await fetchMock.flush());
|
||||
});
|
||||
|
||||
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
|
||||
fetchMock.post(
|
||||
"https://sfu.example.org/sfu/get",
|
||||
(url, opts) => {
|
||||
const body = JSON.parse(opts.body as string);
|
||||
|
||||
// Verify, that the request contains the expected delay parameters
|
||||
if (
|
||||
body.delay_id === "mock_delay_id" &&
|
||||
body.delay_timeout === 10000 &&
|
||||
body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { url: sfuUrl, jwt: testJWTToken },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "Missing expected delay params" },
|
||||
};
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
const config = await getSFUConfigWithOpenID(
|
||||
matrixClient,
|
||||
ownMemberMock,
|
||||
"https://sfu.example.org",
|
||||
"!example_room_id",
|
||||
{
|
||||
delayEndpointBaseUrl: "https://homeserverserver.org/cs_api",
|
||||
delayId: "mock_delay_id",
|
||||
},
|
||||
);
|
||||
|
||||
// Prüfe das Ergebnis
|
||||
expect(config).toMatchObject({
|
||||
jwt: testJWTToken,
|
||||
url: sfuUrl,
|
||||
});
|
||||
|
||||
void (await fetchMock.flush());
|
||||
});
|
||||
|
||||
it("should try legacy and then new endpoint with delay delegation", async () => {
|
||||
fetchMock.post("https://sfu.example.org/get_token", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||
return {
|
||||
status: 500,
|
||||
body: { error: "Test failure" },
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
try {
|
||||
@@ -110,8 +216,10 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
);
|
||||
} catch (ex) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toEqual(
|
||||
new Error("SFU Config fetch failed with status code 500"),
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
void (await fetchMock.flush());
|
||||
}
|
||||
@@ -121,7 +229,7 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||
expect(calls[0][1]).toStrictEqual({
|
||||
// check if it uses correct delayID!
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -131,7 +239,7 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
|
||||
|
||||
expect(calls[1][1]).toStrictEqual({
|
||||
body: '{"room":"!example_room_id","device_id":"DEVICE"}',
|
||||
body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -176,7 +284,7 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||
expect(calls[0][1]).toStrictEqual({
|
||||
// check if it uses correct delayID!
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -5,7 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
type IOpenIDToken,
|
||||
type MatrixClient,
|
||||
parseErrorResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
@@ -155,6 +159,8 @@ export async function getSFUConfigWithOpenID(
|
||||
serviceUrl,
|
||||
roomId,
|
||||
openIdToken,
|
||||
opts?.delayEndpointBaseUrl,
|
||||
opts?.delayId,
|
||||
);
|
||||
logger?.info(`Got JWT from call's active focus URL.`);
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
@@ -187,24 +193,66 @@ async function getLiveKitJWT(
|
||||
livekitServiceURL: string,
|
||||
matrixRoomId: string,
|
||||
openIDToken: IOpenIDToken,
|
||||
delayEndpointBaseUrl?: string,
|
||||
delayId?: string,
|
||||
): Promise<{ url: string; jwt: string }> {
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
interface IDelayParams {
|
||||
delay_id?: string;
|
||||
delay_timeout?: number;
|
||||
delay_cs_api_url?: string;
|
||||
}
|
||||
let bodyDalayParts: IDelayParams = {};
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
const delayTimeoutMs =
|
||||
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout: delayTimeoutMs,
|
||||
delay_cs_api_url: delayEndpointBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
|
||||
return await fetch(livekitServiceURL + "/sfu/get", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// This is the actual livekit room alias. For the legacy jwt endpoint simply the room id was used.
|
||||
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
|
||||
// However, the livekit room alias is provided as part of the JWT payload.
|
||||
room: matrixRoomId,
|
||||
openid_token: openIDToken,
|
||||
device_id: deviceId,
|
||||
...delayParts,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const res = await doNetworkOperationWithRetry(async () => {
|
||||
let response = await makeRequest(bodyDalayParts);
|
||||
|
||||
// Old service compatibility check
|
||||
const oldServiceDoesNotSupportDelayParts =
|
||||
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
|
||||
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
|
||||
if (oldServiceDoesNotSupportDelayParts) {
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody.errcode === "M_BAD_JSON") {
|
||||
response = await makeRequest({});
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the error, treat as real error
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("SFU Config fetch failed with status code " + res.status);
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -241,7 +289,7 @@ export async function getLiveKitJWTWithDelayDelegation(
|
||||
// Also check for empty string
|
||||
if (delayId && delayEndpointBaseUrl) {
|
||||
const delayTimeoutMs =
|
||||
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms ?? 1000;
|
||||
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
|
||||
bodyDalayParts = {
|
||||
delay_id: delayId,
|
||||
delay_timeout: delayTimeoutMs,
|
||||
@@ -264,7 +312,7 @@ export async function getLiveKitJWTWithDelayDelegation(
|
||||
if (res.status === 404) {
|
||||
throw new NotSupportedError(msg);
|
||||
} else {
|
||||
throw new Error(msg);
|
||||
throw parseErrorResponse(res, await res.text());
|
||||
}
|
||||
}
|
||||
return await res.json();
|
||||
|
||||
11
src/main.tsx
11
src/main.tsx
@@ -15,10 +15,6 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
} from "livekit-client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
@@ -26,16 +22,9 @@ import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
logger.error("Failed to initialize rageshake", e);
|
||||
});
|
||||
setLKLogLevel("info");
|
||||
setLKLogExtension((level, msg, context) => {
|
||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
||||
global.mx_rage_logger.log(level, "livekit", msg, context);
|
||||
});
|
||||
|
||||
logger.info(`Element Call ${import.meta.env.VITE_APP_VERSION || "dev"}`);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.reactionIndicatorWidget {
|
||||
display: flex;
|
||||
background-color: #00000030;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
|
||||
background: "ffffff40";
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
background: var(--cpd-color-icon-secondary-alpha);
|
||||
backdrop-filter: blur(10px);
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-1));
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
.reaction {
|
||||
margin: var(--cpd-space-1x);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
|
||||
@@ -57,7 +57,8 @@ Please see LICENSE in the repository root for full details.
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
<Text>{t("handset.overlay_description")}</Text>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="sm"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onBackToVideoPressed?.();
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionError } from "livekit-client";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
type CallErrorRecoveryAction,
|
||||
@@ -25,8 +27,12 @@ import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
type ElementCallError,
|
||||
FailToGetOpenIdToken,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
MatrixRTCTransportMissingError,
|
||||
PeerConnectionTimeoutError,
|
||||
StickyEventsRequiredError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
@@ -54,6 +60,12 @@ test.each([
|
||||
expectedDescription:
|
||||
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
},
|
||||
{
|
||||
error: new StickyEventsRequiredError(),
|
||||
expectedTitle: "Homeserver does not support Matrix 2.0 calls",
|
||||
expectedDescription:
|
||||
"This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
|
||||
},
|
||||
])(
|
||||
"should report correct error for $expectedTitle",
|
||||
async ({ error, expectedTitle, expectedDescription }) => {
|
||||
@@ -252,3 +264,165 @@ test("should have a close button in widget mode", async () => {
|
||||
);
|
||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should show technical details when error has a matrixError cause", async () => {
|
||||
const underlyingError = new MatrixError(
|
||||
{
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
500,
|
||||
"https://matrix-rtc.m.localhost/livekit/jwt/sfu/get",
|
||||
);
|
||||
const error = new FailToGetOpenIdToken(underlyingError);
|
||||
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("Something went wrong");
|
||||
|
||||
// Technical details should be present
|
||||
const detailsElement = screen.getByText("Technical details");
|
||||
expect(detailsElement).toBeInTheDocument();
|
||||
|
||||
// Verify error details are shown
|
||||
expect(
|
||||
screen.getByText(/Failed to look up user info from homeserver/i, {
|
||||
selector: "pre",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should not show technical details when error has no matrix error cause", async () => {
|
||||
const error = new ConnectionLostError();
|
||||
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("Connection lost");
|
||||
|
||||
// Technical details should not be present (ConnectionLostError has no cause)
|
||||
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("LiveKit ConnectionError variants", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "notAllowed",
|
||||
error: ConnectionError.notAllowed("Permission denied by server", 403),
|
||||
expectedReason: "NotAllowed",
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
error: ConnectionError.timeout("Connection timed out"),
|
||||
expectedReason: "Timeout",
|
||||
},
|
||||
{
|
||||
name: "serverUnreachable",
|
||||
error: ConnectionError.serverUnreachable("Server is unreachable", 503),
|
||||
expectedReason: "ServerUnreachable",
|
||||
},
|
||||
{
|
||||
name: "serviceNotFound",
|
||||
error: ConnectionError.serviceNotFound(
|
||||
"RTC service not found",
|
||||
"v0-rtc" as const,
|
||||
),
|
||||
expectedReason: "ServiceNotFound",
|
||||
},
|
||||
{
|
||||
name: "internal",
|
||||
error: ConnectionError.internal("Internal server error", {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
}),
|
||||
expectedReason: "InternalError",
|
||||
},
|
||||
])(
|
||||
"should display LiveKit $name error correctly",
|
||||
async ({ error, expectedReason }) => {
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw new LivekitConnectionError(error);
|
||||
};
|
||||
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Check title
|
||||
await screen.findByText("Failed to connect to Livekit server");
|
||||
|
||||
// Check that reason is displayed in the description
|
||||
expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedReason)).toBeInTheDocument();
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
test("should link to troubleshoot guide when timeout error", async () => {
|
||||
const error = new PeerConnectionTimeoutError();
|
||||
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("Connection timeout");
|
||||
|
||||
// Verify the link is present and has correct href
|
||||
const link = screen.getByText("troubleshooting guide");
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/#sfu-connectivity-troubleshooting",
|
||||
);
|
||||
|
||||
// Snapshot the complete rendered error
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import { FullScreenView } from "../FullScreenView.tsx";
|
||||
import { ErrorView } from "../ErrorView.tsx";
|
||||
import { type WidgetHelpers } from "../widget.ts";
|
||||
import styles from "../ErrorView.module.css";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
|
||||
@@ -78,6 +80,9 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const technicalError =
|
||||
error.cause instanceof MatrixError ? error.cause : null;
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
<ErrorView
|
||||
@@ -87,7 +92,26 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessage ?? (
|
||||
{error.localisedMessageKey ? (
|
||||
<Trans
|
||||
// @ts-expect-error - Dynamic i18nKey from error object
|
||||
i18nKey={error.localisedMessageKey}
|
||||
values={error.localisedMessageValues}
|
||||
components={[
|
||||
<a
|
||||
href={String(error.localisedMessageValues?.linkUrl || "#")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{/* Content injected by Trans component */}
|
||||
</a>,
|
||||
<b />,
|
||||
<code />,
|
||||
]}
|
||||
/>
|
||||
) : error.localisedMessage ? (
|
||||
error.localisedMessage
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="error.unexpected_ec_error"
|
||||
components={[<b />, <code />]}
|
||||
@@ -95,6 +119,16 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{technicalError ? (
|
||||
<details className={styles.technicalDetails}>
|
||||
<summary className={styles.technicalDetailsSummary}>
|
||||
{t("technical_details")}
|
||||
</summary>
|
||||
<pre className={styles.technicalDetailsPre}>
|
||||
{technicalError.message}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{actions &&
|
||||
actions.map((action, index) => (
|
||||
<Button
|
||||
|
||||
@@ -19,13 +19,20 @@ import {
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import { render, waitFor, screen, act } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import {
|
||||
type MatrixClient,
|
||||
JoinRule,
|
||||
type RoomState,
|
||||
UnsupportedStickyEventsEndpointError,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, {
|
||||
PointerEventsCheckLevel,
|
||||
} from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -44,6 +51,7 @@ import {
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
@@ -61,7 +69,10 @@ vi.mock("react-use-measure", () => ({
|
||||
|
||||
vi.hoisted(
|
||||
() =>
|
||||
(global.ImageData = class MockImageData {
|
||||
// Use globalThis rather than global because vite-plugin-node-polyfills seems
|
||||
// to rewrite global into an import which then interferes with vitest's hoisting
|
||||
// which runs before imports.
|
||||
(globalThis.ImageData = class MockImageData {
|
||||
public data: number[] = [];
|
||||
} as unknown as typeof ImageData),
|
||||
);
|
||||
@@ -125,6 +136,9 @@ beforeEach(() => {
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
joined = true,
|
||||
options: {
|
||||
withErrorBoundary?: boolean;
|
||||
} = {},
|
||||
): {
|
||||
rtcSession: MatrixRTCSession;
|
||||
getByText: ReturnType<typeof render>["getByText"];
|
||||
@@ -161,24 +175,36 @@ function createGroupCallView(
|
||||
video: { enabled: false },
|
||||
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
|
||||
} as unknown as MuteStates;
|
||||
const groupCallView = (
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
);
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
@@ -246,12 +272,15 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.skip("Should close widget when all other left and have time to play a sound", async () => {
|
||||
test("Should close widget when all other left and have time to play a sound", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widgetClosedCalled = Promise.withResolvers<void>();
|
||||
let widgetClosedCalled = false;
|
||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
||||
Promise.withResolvers<void>();
|
||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
||||
if (action === ElementWidgetActions.Close) {
|
||||
widgetClosedCalled.resolve();
|
||||
widgetClosedCalled = true;
|
||||
widgetClosedResolver();
|
||||
}
|
||||
});
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -267,7 +296,7 @@ test.skip("Should close widget when all other left and have time to play a sound
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
const resolvePlaySound = Promise.withResolvers<void>();
|
||||
playSound = vi.fn().mockReturnValue(resolvePlaySound);
|
||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
@@ -278,16 +307,17 @@ test.skip("Should close widget when all other left and have time to play a sound
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
expect(widgetSendMock).not.toHaveBeenCalled();
|
||||
expect(widgetClosedCalled).toBeFalsy();
|
||||
resolvePlaySound.resolve();
|
||||
await flushPromises();
|
||||
|
||||
expect(playSound).toHaveBeenCalledWith("left");
|
||||
|
||||
await widgetClosedCalled.promise;
|
||||
// Expect the leave sound to be played but silent (volumeOverwrite = 0)
|
||||
// The allOthersLeft effect should already play a leave sound for the last user in the call.
|
||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||
await widgetClosedPromise;
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeTruthy();
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
}, 80000);
|
||||
|
||||
test("Should close widget when all other left", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -385,6 +415,46 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
|
||||
test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
|
||||
// Mirror the shape the SDK emits: the MembershipManager scheduler wraps
|
||||
// the original UnsupportedStickyEventsEndpointError in a generic Error
|
||||
// but preserves the original on `.cause`.
|
||||
const stickyError = new UnsupportedStickyEventsEndpointError(
|
||||
"Server does not support the sticky events",
|
||||
"sendStickyEvent",
|
||||
);
|
||||
const wrappedError = new Error(
|
||||
"The MembershipManager shut down because of the end condition: " +
|
||||
String(stickyError),
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
|
||||
);
|
||||
|
||||
await screen.findByText("Homeserver does not support Matrix 2.0 calls");
|
||||
});
|
||||
|
||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
await act(() =>
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
new Error("something else broke"),
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText("Connection lost");
|
||||
});
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
@@ -395,7 +465,11 @@ test("user can reconnect after a membership manager error", async () => {
|
||||
// async state update should be processed automatically by the waitFor call),
|
||||
// and yet here we are.
|
||||
await act(async () =>
|
||||
user.click(screen.getByRole("button", { name: "Reconnect" })),
|
||||
user
|
||||
// With css vitest turned on this test thinks that the button has pointer_events: none;.
|
||||
// TODO investigate if this is a test setup issue or an actual problem.
|
||||
.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never })
|
||||
.click(screen.getByRole("button", { name: "Reconnect" })),
|
||||
);
|
||||
// In-call controls should be visible again
|
||||
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
|
||||
import {
|
||||
type MatrixClient,
|
||||
JoinRule,
|
||||
type Room,
|
||||
UnsupportedStickyEventsEndpointError,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
Room as LivekitRoom,
|
||||
isE2EESupported as isE2EESupportedBrowser,
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
ElementCallError,
|
||||
StickyEventsRequiredError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||
@@ -120,7 +126,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
useAudioContext<CallEventSounds>({
|
||||
sounds: callEventAudioSounds,
|
||||
latencyHint: "interactive",
|
||||
muted: muteAllAudio,
|
||||
@@ -162,8 +168,22 @@ export const GroupCallView: FC<Props> = ({
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
(error) => setExternalError(new ConnectionLostError()),
|
||||
(error) => {
|
||||
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
|
||||
// not advertise MSC4354 (sticky events), the SDK throws an
|
||||
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
|
||||
// scheduler wraps it and exposes the original via `.cause`.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.cause instanceof UnsupportedStickyEventsEndpointError
|
||||
) {
|
||||
setExternalError(new StickyEventsRequiredError());
|
||||
} else {
|
||||
setExternalError(new ConnectionLostError());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Sanity check the room object
|
||||
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
|
||||
@@ -318,12 +338,26 @@ export const GroupCallView: FC<Props> = ({
|
||||
(
|
||||
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
|
||||
): void => {
|
||||
let playSound: CallEventSounds = "left";
|
||||
if (reason === "timeout" || reason === "decline") playSound = reason;
|
||||
let audioPromise: Promise<void> | undefined = undefined;
|
||||
switch (reason) {
|
||||
case "allOthersLeft":
|
||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||
// already plays the "left" sound when the remote participant's media
|
||||
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
||||
// (used to destory the widget)
|
||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||
break;
|
||||
case "timeout":
|
||||
case "decline":
|
||||
audioPromise = leaveSoundContext.current?.playSound(reason);
|
||||
break;
|
||||
default:
|
||||
audioPromise = leaveSoundContext.current?.playSound("left");
|
||||
}
|
||||
|
||||
setJoined(false);
|
||||
setLeft(true);
|
||||
const audioPromise = leaveSoundContext.current?.playSound(playSound);
|
||||
|
||||
// We need to wait until the callEnded event is tracked on PostHog,
|
||||
// otherwise the iframe may get killed first.
|
||||
const posthogRequest = new Promise((resolve) => {
|
||||
|
||||
@@ -26,6 +26,33 @@ Please see LICENSE in the repository root for full details.
|
||||
);
|
||||
}
|
||||
|
||||
.header.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header.overlay {
|
||||
/* Note that the header is still position: sticky in this case so that certain
|
||||
tiles can move down out of the way of the header when visible. */
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.header.overlay.hidden {
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
/* Switch to position: absolute so the header takes up no space in the layout
|
||||
when hidden. */
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.header.overlay:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.header.filler {
|
||||
block-size: var(--cpd-space-6x);
|
||||
background: none;
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
@@ -14,10 +15,15 @@ import {
|
||||
type MockedFunction,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
type RenderResult,
|
||||
getByRole,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -34,7 +40,10 @@ import {
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
type CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
} from "../state/CallViewModel/CallViewModel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
@@ -48,7 +57,10 @@ import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.hoisted(
|
||||
() =>
|
||||
(global.ImageData = class MockImageData {
|
||||
// Use globalThis rather than global because vite-plugin-node-polyfills seems
|
||||
// to rewrite global into an import which then interferes with vitest's hoisting
|
||||
// which runs before imports.
|
||||
(globalThis.ImageData = class MockImageData {
|
||||
public data: number[] = [];
|
||||
} as unknown as typeof ImageData),
|
||||
);
|
||||
@@ -100,13 +112,12 @@ beforeEach(() => {
|
||||
interface CreateInCallViewArgs {
|
||||
mediaDevices?: ECMediaDevices;
|
||||
callViewModelOptions?: Partial<CallViewModelOptions>;
|
||||
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
|
||||
initialRoute?: string;
|
||||
/** If true, wraps the rendered tree in an AppBar provider */
|
||||
withAppBar?: boolean;
|
||||
}
|
||||
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
rtcSession: MockRTCSession;
|
||||
vm: CallViewModel;
|
||||
} {
|
||||
const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
|
||||
const muteState = mockMuteStates();
|
||||
@@ -118,7 +129,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
remoteParticipants$: of([remoteParticipant]),
|
||||
},
|
||||
);
|
||||
const { vm, rtcSession } = getBasicCallViewModelEnvironment(
|
||||
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
@@ -129,20 +140,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
const room = rtcSession.room;
|
||||
const client = room.client;
|
||||
|
||||
const Router = args.initialRoute
|
||||
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
|
||||
<MemoryRouter initialEntries={[args.initialRoute!]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
)
|
||||
: BrowserRouter;
|
||||
|
||||
const inCallView = (
|
||||
<InCallView
|
||||
client={client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
footerVm={footerVm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
@@ -163,7 +167,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
|
||||
|
||||
const renderResult = render(
|
||||
<Router>
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
@@ -174,11 +178,12 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</MediaDevicesContext>
|
||||
</Router>,
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
...renderResult,
|
||||
rtcSession,
|
||||
vm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,57 +194,46 @@ describe("InCallView", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings button with AppBar header", () => {
|
||||
it("mobile landscape, is accessible when showHeader is false", () => {
|
||||
// windowSize with height <= 600 results in "flat" windowMode,
|
||||
// which means showHeader$ emits false.
|
||||
const { getAllByRole } = createInCallView({
|
||||
initialRoute: "/?header=app_bar",
|
||||
withAppBar: true,
|
||||
callViewModelOptions: {
|
||||
// Set windowMode$ to "flat" (height <= 600)
|
||||
windowSize$: constant({ width: 1000, height: 500 }),
|
||||
},
|
||||
});
|
||||
// When showHeader is false, hideSettingsButton is false,
|
||||
// so the settings button is visible in the footer.
|
||||
const settingsBtn = getAllByRole("button", { name: "Settings" });
|
||||
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
|
||||
// the narrow layout and another one for the wide layout.
|
||||
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
|
||||
// but we can at least check that both buttons are rendered and have the correct classes.
|
||||
expect(settingsBtn.length).toBe(2);
|
||||
expect(settingsBtn[0]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"settings-bottom-left",
|
||||
);
|
||||
expect(settingsBtn[0]).toBeVisible();
|
||||
beforeEach(() => {
|
||||
// getUrlParams() reads window.location directly rather than from the
|
||||
// React Router context, so MemoryRouter alone is not enough to make
|
||||
// it see "header=app_bar". Push the real URL so both paths agree.
|
||||
window.history.pushState({}, "", "?header=app_bar");
|
||||
});
|
||||
|
||||
it("mobile portrait, is accessible when showHeader is true", () => {
|
||||
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
|
||||
// which means showHeader$ emits true.
|
||||
const { getAllByRole } = createInCallView({
|
||||
initialRoute: "/?header=app_bar",
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, "", "/");
|
||||
});
|
||||
|
||||
it("mobile portrait, is visible in the header", () => {
|
||||
createInCallView({
|
||||
withAppBar: true,
|
||||
callViewModelOptions: {
|
||||
// Set windowMode$ to "normal" (height >= 600)
|
||||
windowSize$: constant({ width: 1000, height: 800 }),
|
||||
// Narrow like a mobile phone in portrait orientation
|
||||
windowSize$: constant({ width: 400, height: 700 }),
|
||||
},
|
||||
});
|
||||
// When showHeader is true and headerStyle is AppBar,
|
||||
// hideSettingsButton is true in the footer, but the settings
|
||||
// button is rendered in the AppBar via useAppBarSecondaryButton.
|
||||
const settingsBtns = getAllByRole("button", { name: "Settings" });
|
||||
|
||||
expect(settingsBtns.length).toBe(1);
|
||||
expect(settingsBtns[0]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"settings-app-bar",
|
||||
);
|
||||
expect(settingsBtns[0]).toBeVisible();
|
||||
getByRole(screen.getByRole("banner"), "button", {
|
||||
name: "Settings",
|
||||
});
|
||||
});
|
||||
|
||||
it("mobile landscape, is not visible anywhere", () => {
|
||||
const { queryByRole } = createInCallView({
|
||||
withAppBar: true,
|
||||
callViewModelOptions: {
|
||||
// Flat like a mobile phone in landscape orientation
|
||||
windowSize$: constant({ width: 700, height: 400 }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOutputSwitcher", () => {
|
||||
it("is visible and can be clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -43,7 +42,6 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
type GridMode,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { useInitial } from "../useInitial";
|
||||
@@ -51,12 +49,9 @@ import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import {
|
||||
type CallLayoutOutputs,
|
||||
defaultPipAlignment,
|
||||
defaultSpotlightAlignment,
|
||||
} from "../grid/CallLayout";
|
||||
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
||||
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
|
||||
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
@@ -68,11 +63,7 @@ import {
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
|
||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
@@ -90,14 +81,23 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { useLatest } from "../useLatest.ts";
|
||||
import { CallFooter } from "../components/CallFooter.tsx";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
"--call-view-safe-area-inset-top"?: string;
|
||||
"--call-view-safe-area-inset-bottom"?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState"
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
// TODO refactor those reasons into an enum
|
||||
@@ -108,7 +108,9 @@ export interface ActiveCallProps extends Omit<
|
||||
|
||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const [vm, setVm] = useState<CallViewModel | null>(null);
|
||||
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
const urlParams = useUrlParams();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
@@ -118,6 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
urlParams;
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
@@ -141,7 +144,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
||||
|
||||
return (): void => {
|
||||
logger.info("END CALL VIEW SCOPE");
|
||||
scope.end();
|
||||
};
|
||||
}, [
|
||||
@@ -153,13 +155,44 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
urlParams,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (vm === null) return;
|
||||
|
||||
const scope = new ObservableScope();
|
||||
const footerVm = createCallFooterViewModel(
|
||||
scope,
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
);
|
||||
setFooterVm(footerVm);
|
||||
|
||||
return (): void => {
|
||||
scope.end();
|
||||
};
|
||||
}, [
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
vm,
|
||||
]);
|
||||
|
||||
if (vm === null) return null;
|
||||
if (footerVm === null) return null;
|
||||
|
||||
return (
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView {...props} vm={vm} />
|
||||
<InCallView {...props} vm={vm} footerVm={footerVm} />
|
||||
</ReactionsSenderProvider>
|
||||
);
|
||||
};
|
||||
@@ -167,6 +200,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
export interface InCallViewProps {
|
||||
client: MatrixClient;
|
||||
vm: CallViewModel;
|
||||
footerVm: ViewModel<FooterSnapshot>;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
@@ -177,14 +211,14 @@ export interface InCallViewProps {
|
||||
export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
vm,
|
||||
footerVm,
|
||||
matrixInfo,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { supportsReactions, sendReaction, toggleRaisedHand } =
|
||||
useReactionsSender();
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
// TODO-MULTI-SFU This is unused now??
|
||||
@@ -220,17 +254,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
muted: muteAllAudio,
|
||||
});
|
||||
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
|
||||
|
||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
setAudioEnabled,
|
||||
@@ -242,16 +270,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||
const gridMode = useBehavior(vm.gridMode$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
|
||||
const fatalCallError = useBehavior(vm.fatalError$);
|
||||
// Stop the rendering and throw for the error boundary
|
||||
@@ -276,10 +302,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}
|
||||
}, [ringing, latestPickupPhaseAudio]);
|
||||
|
||||
const onViewClick = useCallback(
|
||||
(e: ReactMouseEvent) => {
|
||||
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
|
||||
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
||||
// so this still only fires for taps.
|
||||
const onViewPointerUp = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
if (
|
||||
(e.nativeEvent as PointerEvent).pointerType === "touch" &&
|
||||
e.pointerType === "touch" &&
|
||||
// If an interactive element was tapped, don't count this as a tap on the screen
|
||||
(e.target as Element).closest?.("button, input") === null
|
||||
)
|
||||
@@ -296,28 +325,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const openProfile = useMemo(
|
||||
() =>
|
||||
// Profile settings are unavailable in widget mode
|
||||
widget === null
|
||||
? (): void => {
|
||||
setSettingsTab("profile");
|
||||
setSettingsModalOpen(true);
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
: null,
|
||||
[setSettingsTab, setSettingsModalOpen],
|
||||
[setSettingsTab, setSettingsOpen],
|
||||
);
|
||||
|
||||
const [headerRef, headerBounds] = useMeasure();
|
||||
@@ -328,15 +347,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
width: bounds.width,
|
||||
height:
|
||||
bounds.height -
|
||||
headerBounds.height -
|
||||
(windowMode === "flat" ? 0 : footerBounds.height),
|
||||
(edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
|
||||
}),
|
||||
[
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
headerBounds.height,
|
||||
footerBounds.height,
|
||||
windowMode,
|
||||
edgeToEdge,
|
||||
],
|
||||
);
|
||||
const gridBoundsObservable$ = useObservable(
|
||||
@@ -344,64 +362,50 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
[gridBounds],
|
||||
);
|
||||
|
||||
const spotlightAlignment$ = useInitial(
|
||||
() => new BehaviorSubject(defaultSpotlightAlignment),
|
||||
);
|
||||
const pipAlignment$ = useInitial(
|
||||
() => new BehaviorSubject(defaultPipAlignment),
|
||||
);
|
||||
|
||||
const setGridMode = useCallback(
|
||||
(mode: GridMode) => vm.setGridMode(mode),
|
||||
[vm],
|
||||
);
|
||||
|
||||
useAppBarHidden(!showHeader);
|
||||
|
||||
let header: ReactNode = null;
|
||||
if (showHeader) {
|
||||
switch (headerStyle) {
|
||||
case HeaderStyle.AppBar: {
|
||||
// dont build a header here. The AppBar will take care of it.
|
||||
break;
|
||||
}
|
||||
case HeaderStyle.None:
|
||||
// Cosmetic header to fill out space while still affecting the bounds of
|
||||
// the grid
|
||||
header = (
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case HeaderStyle.Standard:
|
||||
header = (
|
||||
<Header
|
||||
className={styles.header}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{showControls && onShareClick !== null && (
|
||||
<InviteButton
|
||||
className={styles.invite}
|
||||
onClick={onShareClick}
|
||||
/>
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
switch (headerStyle) {
|
||||
case HeaderStyle.AppBar: {
|
||||
// dont build a header here. The AppBar will take care of it.
|
||||
break;
|
||||
}
|
||||
case HeaderStyle.None:
|
||||
// Cosmetic header to fill out space while still affecting the bounds of
|
||||
// the grid
|
||||
header = showHeader && (
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case HeaderStyle.Standard:
|
||||
header = (
|
||||
<Header
|
||||
className={classNames(styles.header, {
|
||||
[styles.overlay]: edgeToEdge,
|
||||
[styles.hidden]: !showHeader,
|
||||
})}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{showControls && onShareClick !== null && (
|
||||
<InviteButton className={styles.invite} onClick={onShareClick} />
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
// The reconnecting toast cannot be dismissed
|
||||
@@ -448,12 +452,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
||||
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
||||
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
||||
const showSpeakingIndicatorsValue = useBehavior(
|
||||
vm.showSpeakingIndicators$,
|
||||
);
|
||||
const showSpotlightIndicatorsValue = useBehavior(
|
||||
const showSpotlightIndicators = useBehavior(
|
||||
vm.showSpotlightIndicators$,
|
||||
);
|
||||
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
|
||||
return model instanceof GridTileViewModel ? (
|
||||
<GridTile
|
||||
@@ -464,7 +467,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetHeight={targetHeight}
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
||||
showSpeakingIndicators={showSpeakingIndicators}
|
||||
showNameTags={showNameTags}
|
||||
focusable={!contentObscured}
|
||||
/>
|
||||
) : (
|
||||
@@ -475,7 +479,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicatorsValue}
|
||||
showIndicators={showSpotlightIndicators}
|
||||
showNameTags={showNameTags}
|
||||
focusable={!contentObscured}
|
||||
className={classNames(className, styles.tile)}
|
||||
style={style}
|
||||
@@ -486,20 +491,18 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
);
|
||||
|
||||
const layouts = useMemo(() => {
|
||||
const inputs = {
|
||||
minBounds$: gridBoundsObservable$,
|
||||
spotlightAlignment$,
|
||||
pipAlignment$,
|
||||
};
|
||||
const inputs = { minBounds$: gridBoundsObservable$ };
|
||||
return {
|
||||
grid: makeGridLayout(inputs),
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||
"one-on-one": makeOneOnOneLayout(inputs),
|
||||
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
|
||||
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
|
||||
}, [gridBoundsObservable$]);
|
||||
|
||||
const showFooter = useBehavior(footerVm.showFooter$);
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
@@ -511,6 +514,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetWidth={gridBounds.width}
|
||||
targetHeight={gridBounds.height}
|
||||
showIndicators={false}
|
||||
showNameTags={showNameTags}
|
||||
focusable={!contentObscured}
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
@@ -523,9 +527,26 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
key="fixed"
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart:
|
||||
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
|
||||
height: gridBounds.height,
|
||||
// If not edge-to-edge, consume the header insets right here.
|
||||
insetBlockStart: edgeToEdge ? 0 : bounds.top + headerBounds.height,
|
||||
height: edgeToEdge ? "100%" : gridBounds.height,
|
||||
// If edge-to-edge, compute new safe area insets that account for the
|
||||
// header and footer, passing them down to the tiles.
|
||||
"--call-view-safe-area-inset-top":
|
||||
edgeToEdge && headerStyle !== HeaderStyle.None && showHeader
|
||||
? // Header has two relevant cases: if it's an app bar, it lives
|
||||
// outside the InCallView and consumes the safe area insets
|
||||
// itself. Otherwise account for the safe area and header size
|
||||
// as part of the InCallView.
|
||||
headerStyle === HeaderStyle.AppBar
|
||||
? `${bounds.top}px`
|
||||
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
||||
: undefined,
|
||||
"--call-view-safe-area-inset-bottom":
|
||||
edgeToEdge && showFooter
|
||||
? // Footer always lives inside the InCallView.
|
||||
`calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
|
||||
: undefined,
|
||||
}}
|
||||
model={layout}
|
||||
Layout={layers.fixed}
|
||||
@@ -543,75 +564,52 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
);
|
||||
// The grid tiles go *under* the spotlight in the portrait layout, but
|
||||
// *over* the spotlight in the expanded layout
|
||||
return layout.type === "spotlight-expanded" ? (
|
||||
<>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
|
||||
// Put the right layer in the foreground for the requested layout
|
||||
switch (layers.foreground) {
|
||||
case "fixed":
|
||||
return (
|
||||
<>
|
||||
{scrollingGrid}
|
||||
{fixedGrid}
|
||||
</>
|
||||
);
|
||||
case "scrolling":
|
||||
return (
|
||||
<>
|
||||
{fixedGrid}
|
||||
{scrollingGrid}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const settingsButtonInAppBar =
|
||||
headerStyle === HeaderStyle.AppBar && showHeader;
|
||||
useAppBarSecondaryButton(
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
data-testid="settings-app-bar"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = (
|
||||
<CallFooter
|
||||
ref={footerRef}
|
||||
hidden={!showFooter}
|
||||
hideControls={!showControls}
|
||||
asOverlay={windowMode === "flat"}
|
||||
asPip={layout.type === "pip"}
|
||||
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
||||
hideLogo={headerStyle !== HeaderStyle.Standard}
|
||||
layoutMode={gridMode}
|
||||
setLayoutMode={setGridMode}
|
||||
audioEnabled={audioEnabled}
|
||||
toggleAudio={toggleAudio ?? undefined}
|
||||
videoEnabled={videoEnabled}
|
||||
toggleVideo={toggleVideo ?? undefined}
|
||||
sharingScreen={sharingScreen}
|
||||
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
|
||||
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
reactionData={supportsReactions ? vm : undefined}
|
||||
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
|
||||
// Only pass the openSettings function if the settings button is not in the app bar.
|
||||
// If there is no fn the button will be hidden in the footer.
|
||||
openSettings={settingsButtonInAppBar ? undefined : openSettings}
|
||||
hangup={vm.hangup}
|
||||
//Debug props
|
||||
debugTileLayout={debugTileLayout}
|
||||
tileStoreGeneration={tileStoreGeneration}
|
||||
/>
|
||||
const footer = footerVm !== null && (
|
||||
<CallFooter ref={footerRef} vm={footerVm} />
|
||||
);
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
return (
|
||||
// The onClick handler here exists to control the visibility of the footer,
|
||||
// The pointer handler here exists to control the visibility of the footer,
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
ref={containerRef}
|
||||
onClick={onViewClick}
|
||||
onPointerUp={onViewPointerUp}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
@@ -638,8 +636,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={matrixRoom.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
open={settingsOpen}
|
||||
onDismiss={(): void => setSettingsOpen(false)}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
livekitRooms={allConnections
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.toggle {
|
||||
padding: 2px;
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
/* Safari puts a margin on these, which is not removed via appearance: none */
|
||||
margin: 0;
|
||||
block-size: var(--cpd-space-11x);
|
||||
inline-size: var(--cpd-space-11x);
|
||||
cursor: pointer;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-bg-action-secondary-rest);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.toggle svg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
padding: calc(2.5 * var(--cpd-space-1x));
|
||||
pointer-events: none;
|
||||
color: var(--cpd-color-icon-primary);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.toggle svg:nth-child(2) {
|
||||
inset-inline-start: 2px;
|
||||
}
|
||||
|
||||
.toggle svg:nth-child(4) {
|
||||
inset-inline-end: 2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.toggle input:hover {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle input:active {
|
||||
background: var(--cpd-color-bg-action-secondary-pressed);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.toggle input:checked {
|
||||
background: var(--cpd-color-bg-action-primary-rest);
|
||||
}
|
||||
|
||||
.toggle input:checked + svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.toggle input:checked:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle input:checked:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
.toggle input:first-child {
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
|
||||
const meta = {
|
||||
component: LayoutToggle,
|
||||
} satisfies Meta<typeof LayoutToggle>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
},
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ChangeEvent, type FC, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import styles from "./LayoutToggle.module.css";
|
||||
|
||||
export type Layout = "spotlight" | "grid";
|
||||
|
||||
type Props = {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => setLayout(e.target.value as Layout),
|
||||
[setLayout],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.toggle, className)}>
|
||||
<Tooltip label={t("layout_spotlight_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||
<Tooltip label={t("layout_grid_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
name="layout"
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
111
src/room/LobbyView.test.tsx
Normal file
111
src/room/LobbyView.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { axe } from "vitest-axe";
|
||||
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { mockMediaDevices, mockMuteStates } from "../utils/test";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import lobbyStyles from "./LobbyView.module.css";
|
||||
import headerStyles from "../Header.module.css";
|
||||
|
||||
vi.mock("@livekit/components-react", () => ({
|
||||
usePreviewTracks: (): unknown[] => [],
|
||||
}));
|
||||
|
||||
vi.mock("../livekit/TrackProcessorContext", () => ({
|
||||
useTrackProcessor: (): ProcessorState => ({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
}),
|
||||
useTrackProcessorSync: (): void => {},
|
||||
}));
|
||||
|
||||
vi.mock("react-use-measure", () => ({
|
||||
default: (): [() => void, object] => [(): void => {}, {}],
|
||||
}));
|
||||
|
||||
vi.mock("../settings/SettingsModal", () => ({
|
||||
SettingsModal: (): null => null,
|
||||
defaultSettingsTab: "general",
|
||||
}));
|
||||
|
||||
const mockClient = {
|
||||
getUserId: () => "@user:example.org",
|
||||
getDeviceId: () => "DEVICE",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
const matrixInfo = {
|
||||
userId: "@user:example.org",
|
||||
displayName: "Test User",
|
||||
avatarUrl: "",
|
||||
roomId: "!room:example.org",
|
||||
roomName: "Test Room",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: { kind: E2eeType.NONE } satisfies EncryptionSystem,
|
||||
};
|
||||
|
||||
function renderLobbyView(
|
||||
props: Partial<Parameters<typeof LobbyView>[0]> = {},
|
||||
): ReturnType<typeof render> {
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
const muteStates = mockMuteStates();
|
||||
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<TooltipProvider>
|
||||
<LobbyView
|
||||
client={mockClient}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => {}}
|
||||
confineToRoom={false}
|
||||
hideHeader={false}
|
||||
participantCount={3}
|
||||
onShareClick={null}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LobbyView", () => {
|
||||
it("renders with header and participant count", async () => {
|
||||
const { container } = renderLobbyView();
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(
|
||||
container.getElementsByClassName(headerStyles.header).length,
|
||||
).toBeTruthy();
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("renders without header", () => {
|
||||
const { container } = renderLobbyView({ hideHeader: true });
|
||||
expect(
|
||||
container.getElementsByClassName(headerStyles.header).length,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("renders with waiting for invite state", () => {
|
||||
const { getByTestId } = renderLobbyView({
|
||||
waitingForInvite: true,
|
||||
});
|
||||
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { useInitial } from "../useInitial";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
@@ -46,7 +47,10 @@ import {
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { CallFooter } from "../components/CallFooter";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -91,6 +95,11 @@ export const LobbyView: FC<Props> = ({
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
|
||||
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
@@ -106,6 +115,7 @@ export const LobbyView: FC<Props> = ({
|
||||
logger.error("Failed to navigate to /", error);
|
||||
});
|
||||
}, [navigate]);
|
||||
const hangup = confineToRoom ? undefined : onLeaveClick;
|
||||
|
||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
||||
const recentsButton = !confineToRoom && (
|
||||
@@ -178,6 +188,27 @@ export const LobbyView: FC<Props> = ({
|
||||
|
||||
useTrackProcessorSync(videoTrack);
|
||||
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
const footerScope = new ObservableScope();
|
||||
setFooterVm(
|
||||
createLobbyFooterViewModel(
|
||||
footerScope,
|
||||
muteStates,
|
||||
devices,
|
||||
openSettings,
|
||||
hangup,
|
||||
// Logo and header are connected: only show the logo in SPA with header.
|
||||
!hideHeader,
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
footerScope.end();
|
||||
};
|
||||
}, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
return (
|
||||
@@ -209,7 +240,7 @@ export const LobbyView: FC<Props> = ({
|
||||
className={classNames(styles.join, {
|
||||
[styles.wait]: waitingForInvite,
|
||||
})}
|
||||
size={waitingForInvite ? "sm" : "lg"}
|
||||
size={waitingForInvite ? "md" : "lg"}
|
||||
disabled={waitingForInvite}
|
||||
onClick={() => {
|
||||
if (!waitingForInvite) onEnter();
|
||||
@@ -221,18 +252,11 @@ export const LobbyView: FC<Props> = ({
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
</div>
|
||||
<CallFooter
|
||||
audioEnabled={audioEnabled}
|
||||
videoEnabled={videoEnabled}
|
||||
toggleAudio={toggleAudio ?? undefined}
|
||||
toggleVideo={toggleVideo ?? undefined}
|
||||
openSettings={openSettings}
|
||||
hangup={!confineToRoom ? onLeaveClick : undefined}
|
||||
// Logo and header are connected. We will only show the logo in SPA with header.
|
||||
hideLogo={hideHeader}
|
||||
>
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
</CallFooter>
|
||||
{footerVm !== null && (
|
||||
<CallFooter vm={footerVm}>
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
</CallFooter>
|
||||
)}
|
||||
</div>
|
||||
{client && (
|
||||
<SettingsModal
|
||||
|
||||
@@ -6,7 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.preview {
|
||||
margin-inline: var(--inline-content-inset);
|
||||
margin-left: var(--content-inset-left);
|
||||
margin-right: var(--content-inset-right);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
@@ -80,6 +81,7 @@ video.mirror {
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
<div
|
||||
class="fixedGrid grid"
|
||||
style="inset-block-start: NaNpx;"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
@@ -147,9 +148,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
Only works while using app
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
class="_button_1nw83_8"
|
||||
data-kind="primary"
|
||||
data-size="sm"
|
||||
data-size="md"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -164,6 +165,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
<div
|
||||
class="footer"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
@@ -305,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
@@ -326,10 +328,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_i_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
@@ -350,10 +353,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
@@ -374,8 +378,34 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_s_"
|
||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
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
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_14_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
@@ -396,21 +426,23 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
class="toggle layout"
|
||||
<fieldset
|
||||
aria-label="Layout"
|
||||
class="_toggle_13rnk_9 layout"
|
||||
data-size="lg"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="_r_11_"
|
||||
name="layout"
|
||||
aria-labelledby="_r_19_"
|
||||
name="layoutMode"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
/>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
@@ -418,25 +450,25 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="_r_16_"
|
||||
aria-labelledby="_r_1e_"
|
||||
checked=""
|
||||
name="layout"
|
||||
name="layoutMode"
|
||||
type="radio"
|
||||
value="grid"
|
||||
/>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
||||
/>
|
||||
</svg>
|
||||
</form>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
382
src/room/__snapshots__/LobbyView.test.tsx.snap
Normal file
382
src/room/__snapshots__/LobbyView.test.tsx.snap
Normal file
@@ -0,0 +1,382 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="inRoom"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
>
|
||||
<div
|
||||
class="roomHeaderInfo"
|
||||
data-size="lg"
|
||||
>
|
||||
<span
|
||||
aria-label="!room:example.org"
|
||||
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
|
||||
data-color="3"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 56px;"
|
||||
>
|
||||
T
|
||||
</span>
|
||||
<div
|
||||
class="nameLine"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
data-testid="roomHeader_roomName"
|
||||
>
|
||||
Test Room
|
||||
</h1>
|
||||
<span
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-labelledby="_r_0_"
|
||||
class="lock"
|
||||
data-encrypted="false"
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="participantsLine"
|
||||
>
|
||||
<svg
|
||||
aria-label="Participants"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
|
||||
/>
|
||||
<path
|
||||
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
|
||||
data-testid="roomHeader_participants_count"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="content"
|
||||
>
|
||||
<div
|
||||
class="preview"
|
||||
>
|
||||
<video
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div
|
||||
class="avatarContainer"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
aria-label="@user:example.org"
|
||||
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
|
||||
data-color="6"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: NaNpx;"
|
||||
>
|
||||
T
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttonBar"
|
||||
>
|
||||
<button
|
||||
class="_button_1nw83_8 join"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="lobby_joinCall"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Join call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="_link_k9ljz_8"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
href="/"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Back to recents
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_6_"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="logo"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 48 48"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="Logo Mark"
|
||||
>
|
||||
<rect
|
||||
fill="#0DBD8B"
|
||||
height="48"
|
||||
rx="23.93"
|
||||
width="47.86"
|
||||
/>
|
||||
<g
|
||||
id="Union"
|
||||
>
|
||||
<path
|
||||
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
aria-label="Element Call"
|
||||
fill="none"
|
||||
height="11"
|
||||
viewBox="0 0 160 22"
|
||||
width="80"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="Logo Type"
|
||||
>
|
||||
<g
|
||||
id="Vector"
|
||||
>
|
||||
<path
|
||||
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_b_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
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="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_g_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
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="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_l_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="switch"
|
||||
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="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_q_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
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="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -14,7 +14,14 @@ import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
|
||||
import {
|
||||
customLivekitUrl as customLivekitUrlSetting,
|
||||
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
} from "./settings";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
// Mock url params hook to avoid environment-dependent snapshot churn.
|
||||
vi.mock("../UrlParams", () => ({
|
||||
useUrlParams: (): { mocked: boolean; answer: number } => ({
|
||||
@@ -248,4 +255,161 @@ describe("DeveloperSettingsTab", () => {
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// Add this test inside the describe("DeveloperSettingsTab", () => { block,
|
||||
// after the custom livekit url tests:
|
||||
|
||||
describe("enable extended livekit logs", () => {
|
||||
afterEach(() => {
|
||||
enableExtendedLivekitLogsSetting.setValue(false);
|
||||
});
|
||||
|
||||
it("toggles extended livekit logs setting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Enable extended livekit logs");
|
||||
|
||||
// Initial state should be unchecked (default false)
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(false);
|
||||
|
||||
// Click to enable
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(true);
|
||||
|
||||
// Click to disable
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(false);
|
||||
});
|
||||
|
||||
it("Use the current setting value on render", () => {
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
// Set the value to true before rendering
|
||||
enableExtendedLivekitLogsSetting.setValue(true);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText("Enable extended livekit logs");
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix rtc mode", () => {
|
||||
afterEach(() => {
|
||||
matrixRTCModeSetting.setValue(MatrixRTCMode.Legacy);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function getModeRadios(): {
|
||||
legacy: HTMLInputElement;
|
||||
compatibility: HTMLInputElement;
|
||||
matrix20: HTMLInputElement;
|
||||
} {
|
||||
return {
|
||||
legacy: screen.getByDisplayValue(
|
||||
MatrixRTCMode.Legacy,
|
||||
) as HTMLInputElement,
|
||||
compatibility: screen.getByDisplayValue(
|
||||
MatrixRTCMode.Compatibility,
|
||||
) as HTMLInputElement,
|
||||
matrix20: screen.getByDisplayValue(
|
||||
MatrixRTCMode.Matrix_2_0,
|
||||
) as HTMLInputElement,
|
||||
};
|
||||
}
|
||||
|
||||
it("radios reflect the localStorage setting when config does not force the mode", async () => {
|
||||
mockConfig({});
|
||||
matrixRTCModeSetting.setValue(MatrixRTCMode.Compatibility);
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const radios = getModeRadios();
|
||||
expect(radios.compatibility).toBeChecked();
|
||||
expect(radios.legacy).not.toBeChecked();
|
||||
expect(radios.matrix20).not.toBeChecked();
|
||||
// None are disabled by config; only Matrix_2_0 may be disabled by sticky-events support.
|
||||
expect(radios.legacy).not.toBeDisabled();
|
||||
expect(radios.compatibility).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
MatrixRTCMode.Legacy,
|
||||
MatrixRTCMode.Compatibility,
|
||||
MatrixRTCMode.Matrix_2_0,
|
||||
])(
|
||||
"disables all radios and shows the config value (%s) as checked when matrix_rtc_mode is set",
|
||||
async (configMode) => {
|
||||
mockConfig({ matrix_rtc_mode: configMode });
|
||||
// Local setting is intentionally different from the config value to
|
||||
// prove config wins.
|
||||
matrixRTCModeSetting.setValue(
|
||||
configMode === MatrixRTCMode.Legacy
|
||||
? MatrixRTCMode.Compatibility
|
||||
: MatrixRTCMode.Legacy,
|
||||
);
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const radios = getModeRadios();
|
||||
expect(radios.legacy).toBeDisabled();
|
||||
expect(radios.compatibility).toBeDisabled();
|
||||
expect(radios.matrix20).toBeDisabled();
|
||||
|
||||
const checkedValue = (
|
||||
{
|
||||
[MatrixRTCMode.Legacy]: radios.legacy,
|
||||
[MatrixRTCMode.Compatibility]: radios.compatibility,
|
||||
[MatrixRTCMode.Matrix_2_0]: radios.matrix20,
|
||||
} as const
|
||||
)[configMode];
|
||||
expect(checkedValue).toBeChecked();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Config } from "../config/Config";
|
||||
import {
|
||||
type Setting,
|
||||
useSetting,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
matrixRTCMode as matrixRTCModeSetting,
|
||||
customLivekitUrl as customLivekitUrlSetting,
|
||||
MatrixRTCMode,
|
||||
advancedScreenShare as advancedScreenShareSetting,
|
||||
screenShareResolution as screenShareResolutionSetting,
|
||||
screenShareFramerate as screenShareFramerateSetting,
|
||||
@@ -60,7 +60,9 @@ import {
|
||||
noiseSuppressionSetting,
|
||||
autoGainControlSetting,
|
||||
type VideoCodec,
|
||||
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
|
||||
} from "./settings";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
import styles from "./DeveloperSettingsTab.module.css";
|
||||
import settingsStyles from "./SettingsModal.module.css";
|
||||
import { Slider } from "../Slider";
|
||||
@@ -111,6 +113,11 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
},
|
||||
[setMatrixRTCMode],
|
||||
);
|
||||
const configMatrixRTCMode = Config.get().matrix_rtc_mode as
|
||||
| MatrixRTCMode
|
||||
| undefined;
|
||||
const matrixRTCModeForced = configMatrixRTCMode !== undefined;
|
||||
const effectiveMatrixRTCMode = configMatrixRTCMode ?? matrixRTCMode;
|
||||
|
||||
const [showConnectionStats, setShowConnectionStats] = useSetting(
|
||||
showConnectionStatsSetting,
|
||||
@@ -120,6 +127,10 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
|
||||
const [enableExtendedLivekitLogs, setEnableExtendedLivekitLogs] = useSetting(
|
||||
enableExtendedLivekitLogsSetting,
|
||||
);
|
||||
|
||||
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
|
||||
useState<string | null>(null);
|
||||
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
|
||||
@@ -423,7 +434,21 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
},
|
||||
[setAlwaysShowIphoneEarpiece],
|
||||
)}
|
||||
/>{" "}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="enableLivekitExtendedLogs"
|
||||
type="checkbox"
|
||||
label="Enable extended livekit logs"
|
||||
checked={enableExtendedLivekitLogs}
|
||||
onChange={useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setEnableExtendedLivekitLogs(event.target.checked);
|
||||
},
|
||||
[setEnableExtendedLivekitLogs],
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<EditInPlace
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
@@ -491,13 +516,15 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||
{t("developer_mode.matrixRTCMode.title")}
|
||||
</Heading>
|
||||
{matrixRTCModeForced && <p>Your deployment overrides the mode.</p>}
|
||||
<Form>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Legacy}
|
||||
checked={effectiveMatrixRTCMode === MatrixRTCMode.Legacy}
|
||||
value={MatrixRTCMode.Legacy}
|
||||
disabled={matrixRTCModeForced}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
@@ -511,8 +538,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Compatibility}
|
||||
checked={effectiveMatrixRTCMode === MatrixRTCMode.Compatibility}
|
||||
value={MatrixRTCMode.Compatibility}
|
||||
disabled={matrixRTCModeForced}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
@@ -526,9 +554,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={matrixRTCMode === MatrixRTCMode.Matrix_2_0}
|
||||
checked={effectiveMatrixRTCMode === MatrixRTCMode.Matrix_2_0}
|
||||
value={MatrixRTCMode.Matrix_2_0}
|
||||
disabled={!stickyEventsSupported}
|
||||
disabled={matrixRTCModeForced || !stickyEventsSupported}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
|
||||
const devices = useMediaDevices();
|
||||
useEffect(() => {
|
||||
if (open) devices.requestDeviceNames();
|
||||
if (open) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [open, devices]);
|
||||
|
||||
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
|
||||
|
||||
@@ -185,17 +185,53 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
Show iPhone earpiece option on all platforms
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
<div
|
||||
class="fieldRow"
|
||||
>
|
||||
<div
|
||||
class="_field_19upo_26"
|
||||
class="field checkboxField"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_6_"
|
||||
id="enableLivekitExtendedLogs"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
for="enableLivekitExtendedLogs"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="#000"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20 6-11 11-5-5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Enable extended livekit logs
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
class="_root_1o4d9_17"
|
||||
>
|
||||
<div
|
||||
class="_field_1o4d9_27"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_6_"
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_7_"
|
||||
>
|
||||
Custom Livekit-url
|
||||
</label>
|
||||
@@ -203,17 +239,17 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_controls_17lij_8"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_7_"
|
||||
aria-describedby="radix-_r_8_"
|
||||
class="_control_d83jn_10"
|
||||
id="radix-_r_6_"
|
||||
id="radix-_r_7_"
|
||||
name="input"
|
||||
title=""
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_7_"
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_8_"
|
||||
>
|
||||
Currently, no overwrite is set. Url from well-known or config is used.
|
||||
</span>
|
||||
@@ -225,22 +261,22 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
MatrixRTC mode
|
||||
</h3>
|
||||
<form
|
||||
class="_root_19upo_16"
|
||||
class="_root_1o4d9_17"
|
||||
>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
class="_inline-field_1o4d9_33"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
class="_inline-field-control_1o4d9_45"
|
||||
>
|
||||
<div
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
checked=""
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_8_"
|
||||
id="radix-_r_9_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -252,35 +288,35 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
class="_inline-field-body_1o4d9_39"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_8_"
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_9_"
|
||||
>
|
||||
Legacy: state events & oldest membership SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_9_"
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_a_"
|
||||
>
|
||||
Compatible with old versions of EC that do not support multi SFU
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
class="_inline-field_1o4d9_33"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
class="_inline-field-control_1o4d9_45"
|
||||
>
|
||||
<div
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_a_"
|
||||
id="radix-_r_b_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -292,35 +328,35 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
class="_inline-field-body_1o4d9_39"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_a_"
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_b_"
|
||||
>
|
||||
Compatibility: state events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_b_"
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_c_"
|
||||
>
|
||||
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_19upo_32"
|
||||
class="_inline-field_1o4d9_33"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_19upo_44"
|
||||
class="_inline-field-control_1o4d9_45"
|
||||
>
|
||||
<div
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_c_"
|
||||
id="radix-_r_d_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -332,17 +368,17 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_19upo_38"
|
||||
class="_inline-field-body_1o4d9_39"
|
||||
>
|
||||
<label
|
||||
class="_label_19upo_59"
|
||||
for="radix-_r_c_"
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_d_"
|
||||
>
|
||||
Matrix 2.0: sticky events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_19upo_85 _help-message_19upo_91"
|
||||
id="radix-_r_d_"
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_e_"
|
||||
>
|
||||
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
|
||||
</span>
|
||||
@@ -372,9 +408,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
local
|
||||
)
|
||||
</p>
|
||||
<pre
|
||||
class="pre"
|
||||
>
|
||||
<pre>
|
||||
{
|
||||
"region": "local",
|
||||
"version": "1.2.3"
|
||||
@@ -384,9 +418,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
<p>
|
||||
Local Participant
|
||||
</p>
|
||||
<pre
|
||||
class="pre"
|
||||
>
|
||||
<pre>
|
||||
localParticipantIdentity
|
||||
</pre>
|
||||
<p>
|
||||
@@ -413,9 +445,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
remote
|
||||
)
|
||||
</p>
|
||||
<pre
|
||||
class="pre"
|
||||
>
|
||||
<pre>
|
||||
{
|
||||
"region": "remote",
|
||||
"version": "4.5.6"
|
||||
@@ -425,9 +455,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
<p>
|
||||
Local Participant
|
||||
</p>
|
||||
<pre
|
||||
class="pre"
|
||||
>
|
||||
<pre>
|
||||
localParticipantIdentity
|
||||
</pre>
|
||||
<p>
|
||||
|
||||
@@ -502,6 +502,13 @@ export async function init(): Promise<void> {
|
||||
};
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
global.mx_rage_logger.log(
|
||||
LogLevel.error,
|
||||
`Unhandled promise rejection: ${event.reason}`,
|
||||
);
|
||||
});
|
||||
|
||||
return tryInitStorage();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
|
||||
export class Setting<T> {
|
||||
public constructor(
|
||||
@@ -142,17 +143,10 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
export enum MatrixRTCMode {
|
||||
Legacy = "legacy",
|
||||
Compatibility = "compatibility",
|
||||
/** This implies using
|
||||
* - sticky events
|
||||
* - hashed RTC backend identity
|
||||
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
|
||||
* - use the hashed identity for the local membership
|
||||
*/
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
export const enableExtendedLivekitLogs = new Setting<boolean>(
|
||||
"extended-livekit-logs",
|
||||
false,
|
||||
);
|
||||
|
||||
export const matrixRTCMode = new Setting<MatrixRTCMode>(
|
||||
"matrix-rtc-mode",
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
aliceParticipant,
|
||||
aliceRtcMember,
|
||||
aliceUserId,
|
||||
bob,
|
||||
bobId,
|
||||
bobRtcMember,
|
||||
local,
|
||||
@@ -64,7 +65,7 @@ import {
|
||||
localParticipant,
|
||||
withCallViewModel as withCallViewModelInMode,
|
||||
} from "./CallViewModelTestUtils.ts";
|
||||
import { MatrixRTCMode } from "../../settings/settings.ts";
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
|
||||
import { initializeWidget } from "../../widget.ts";
|
||||
|
||||
initializeWidget();
|
||||
@@ -84,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider");
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../../Platform", () => ({
|
||||
get platform(): string {
|
||||
return getPlatform();
|
||||
},
|
||||
isFirefox: (): boolean => false,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../state/CallViewModel/localMember/localTransport",
|
||||
async (importOriginal) => ({
|
||||
@@ -133,12 +142,19 @@ export interface SpotlightExpandedLayoutSummary {
|
||||
pip?: string;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayoutSummary {
|
||||
type: "one-on-one";
|
||||
export interface OneOnOneLandscapeLayoutSummary {
|
||||
type: "one-on-one-landscape";
|
||||
spotlight: string;
|
||||
pip: string;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayoutSummary {
|
||||
type: "one-on-one-portrait";
|
||||
spotlight: string[];
|
||||
pip?: string;
|
||||
pipSize: "sm" | "lg";
|
||||
}
|
||||
|
||||
export interface PipLayoutSummary {
|
||||
type: "pip";
|
||||
spotlight: string[];
|
||||
@@ -149,7 +165,8 @@ export type LayoutSummary =
|
||||
| SpotlightLandscapeLayoutSummary
|
||||
| SpotlightPortraitLayoutSummary
|
||||
| SpotlightExpandedLayoutSummary
|
||||
| OneOnOneLayoutSummary
|
||||
| OneOnOneLandscapeLayoutSummary
|
||||
| OneOnOnePortraitLayoutSummary
|
||||
| PipLayoutSummary;
|
||||
|
||||
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
@@ -187,7 +204,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
pip: pip?.id,
|
||||
}),
|
||||
);
|
||||
case "one-on-one":
|
||||
case "one-on-one-landscape":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
(spotlight, pip) => ({
|
||||
@@ -196,6 +213,20 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
pip: pip.id,
|
||||
}),
|
||||
);
|
||||
case "one-on-one-portrait":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight.media$,
|
||||
l.pip?.media$ ?? constant(undefined),
|
||||
l.pipSize$,
|
||||
],
|
||||
(spotlight, pip, pipSize) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
pip: pip?.id,
|
||||
pipSize,
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
return l.spotlight.media$.pipe(
|
||||
map((spotlight) => ({
|
||||
@@ -405,7 +436,7 @@ describe.each([
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -421,6 +452,85 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
test("one-on-one portrait layout shows local tile when video is enabled", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Local participant enables their video, then disables it
|
||||
const videoInputMarbles = " ny--n";
|
||||
// While tile is shown, tap the screen twice
|
||||
const tapScreenInputMarbles = "--aa-";
|
||||
// Layout should show local tile, make it small, enlarge it again, then hide it
|
||||
const expectedLayoutMarbles = "abcba";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
roomMembers: [local, alice],
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
videoEnabled: new Map([
|
||||
[localParticipant, behavior(videoInputMarbles, yesNo)],
|
||||
]),
|
||||
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
|
||||
},
|
||||
(vm) => {
|
||||
schedule(tapScreenInputMarbles, { a: () => vm.tapScreen() });
|
||||
|
||||
expectObservable(vm.edgeToEdge$).toBe("y", yesNo); // Edge-to-edge-layout
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one-portrait",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-portrait",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pipSize: "lg",
|
||||
},
|
||||
c: {
|
||||
type: "one-on-one-portrait",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pipSize: "sm",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("one-on-one portrait layout shows name tags in room with 3 members", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
// Both Alice and Bob are with us in the room
|
||||
roomMembers: [local, alice, bob],
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
|
||||
},
|
||||
(vm) => {
|
||||
// Uses one-on-one portrait layout
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
type: "one-on-one-portrait",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
});
|
||||
// It wouldn't be clear whether Alice or Bob is the remote video tile,
|
||||
// so the interface must put a name tag on it
|
||||
expectObservable(vm.showNameTags$).toBe("y", yesNo);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const visibilityInputMarbles = "a";
|
||||
@@ -576,7 +686,7 @@ describe.each([
|
||||
});
|
||||
|
||||
test("layout reacts to window size", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const windowSizeInputMarbles = "abc";
|
||||
const expectedLayoutMarbles = " abc";
|
||||
withCallViewModel(
|
||||
@@ -584,7 +694,7 @@ describe.each([
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
windowSize$: behavior(windowSizeInputMarbles, {
|
||||
a: { width: 300, height: 600 }, // Start very narrow, like a phone
|
||||
a: { width: 380, height: 700 }, // Start very narrow, like a phone
|
||||
b: { width: 1000, height: 800 }, // Go to normal desktop window size
|
||||
c: { width: 200, height: 180 }, // Go to PiP size
|
||||
}),
|
||||
@@ -595,13 +705,14 @@ describe.each([
|
||||
{
|
||||
a: {
|
||||
// This is the expected one-on-one layout for a narrow window
|
||||
type: "spotlight-expanded",
|
||||
type: "one-on-one-portrait",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
// In a larger window, expect the normal one-on-one layout
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -735,6 +846,59 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
// Test cases for footer visibility in PIP mode across different platforms
|
||||
const PIP_FOOTER_VISIBILITY_TEST_CASES: Array<{
|
||||
platform: "ios" | "android" | "desktop";
|
||||
expectedMarbles: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
platform: "ios",
|
||||
expectedMarbles: "tf",
|
||||
description: "hidden on iOS",
|
||||
},
|
||||
{
|
||||
platform: "android",
|
||||
expectedMarbles: "tf",
|
||||
description: "hidden on Android",
|
||||
},
|
||||
{
|
||||
platform: "desktop",
|
||||
expectedMarbles: "t",
|
||||
description: "visible on desktop",
|
||||
},
|
||||
];
|
||||
|
||||
it.each(PIP_FOOTER_VISIBILITY_TEST_CASES)(
|
||||
"footer is $description in PIP mode",
|
||||
({ platform: testPlatform, expectedMarbles }) => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
// Set platform for this test case
|
||||
getPlatform.mockReturnValue(testPlatform);
|
||||
|
||||
// Enable PIP mode after initial render
|
||||
const pipControlInputMarbles = "-e";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
},
|
||||
(vm) => {
|
||||
schedule(pipControlInputMarbles, {
|
||||
e: () => window.controls.enablePip(),
|
||||
});
|
||||
|
||||
expectObservable(vm.showFooter$).toBe(expectedMarbles, {
|
||||
t: true,
|
||||
f: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Switch to spotlight immediately
|
||||
@@ -956,7 +1120,7 @@ describe.each([
|
||||
grid: [`${localId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -999,7 +1163,7 @@ describe.each([
|
||||
grid: [`${localId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -1009,7 +1173,7 @@ describe.each([
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${daveId}:0`,
|
||||
},
|
||||
@@ -1227,7 +1391,7 @@ describe.each([
|
||||
// ringing the entire time (even once timed out)
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
@@ -1266,12 +1430,12 @@ describe.each([
|
||||
// ringing the entire time
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||
a: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one",
|
||||
type: "one-on-one-landscape",
|
||||
spotlight: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "livekit-client";
|
||||
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
@@ -59,16 +60,18 @@ import {
|
||||
} from "../../utils/observable";
|
||||
import {
|
||||
duplicateTiles,
|
||||
MatrixRTCMode,
|
||||
playReactionsSound,
|
||||
showReactions,
|
||||
} from "../../settings/settings";
|
||||
import { Config } from "../../config/Config";
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions";
|
||||
import { isFirefox, platform } from "../../Platform";
|
||||
import { setPipEnabled$ } from "../../controls";
|
||||
import { TileStore } from "../TileStore";
|
||||
import { gridLikeLayout } from "../GridLikeLayout";
|
||||
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
||||
import { oneOnOneLayout } from "../OneOnOneLayout";
|
||||
import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout";
|
||||
import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout";
|
||||
import { pipLayout } from "../PipLayout";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
@@ -86,10 +89,12 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneLayoutMedia,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
type OneOnOnePortraitLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
@@ -327,16 +332,6 @@ export interface CallViewModel {
|
||||
{ sender: string; emoji: string; startX: number }[]
|
||||
>;
|
||||
|
||||
// window/layout
|
||||
/**
|
||||
* The general shape of the window.
|
||||
*/
|
||||
windowMode$: Behavior<WindowMode>;
|
||||
spotlightExpanded$: Behavior<boolean>;
|
||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
|
||||
/**
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
@@ -347,10 +342,23 @@ export interface CallViewModel {
|
||||
tileStoreGeneration$: Behavior<number>;
|
||||
showSpotlightIndicators$: Behavior<boolean>;
|
||||
showSpeakingIndicators$: Behavior<boolean>;
|
||||
showNameTags$: Behavior<boolean>;
|
||||
spotlightExpanded$: Behavior<boolean>;
|
||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
|
||||
// header/footer visibility
|
||||
showHeader$: Behavior<boolean>;
|
||||
showFooter$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the call layout should be displayed edge-to-edge, with the footer
|
||||
* and header as overlays.
|
||||
*/
|
||||
edgeToEdge$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
|
||||
// audio routing
|
||||
/**
|
||||
@@ -409,8 +417,15 @@ export function createCallViewModel$(
|
||||
options.encryptionSystem,
|
||||
matrixRTCSession,
|
||||
);
|
||||
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
|
||||
// It is validated at config load (src/config/Config.ts) so the cast is safe.
|
||||
const configMatrixRTCMode = Config.get().matrix_rtc_mode as
|
||||
| MatrixRTCMode
|
||||
| undefined;
|
||||
const matrixRTCMode$ =
|
||||
options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy);
|
||||
configMatrixRTCMode !== undefined
|
||||
? constant(configMatrixRTCMode)
|
||||
: (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy));
|
||||
|
||||
// Each hbar seperates a block of input variables required for the CallViewModel to function.
|
||||
// The outputs of this block is written under the hbar.
|
||||
@@ -558,6 +573,7 @@ export function createCallViewModel$(
|
||||
connectionManager,
|
||||
matrixRTCSession,
|
||||
localTransport$,
|
||||
roomId: matrixRoom.roomId,
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
@@ -774,6 +790,7 @@ export function createCallViewModel$(
|
||||
callPickupState === "timeout" ||
|
||||
callPickupState === "decline"
|
||||
) {
|
||||
// TODO: Respect io.element.functional_members
|
||||
for (const member of roomMembers.values()) {
|
||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||
yield {
|
||||
@@ -1054,6 +1071,7 @@ export function createCallViewModel$(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
edgeToEdge: false,
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
@@ -1061,9 +1079,12 @@ export function createCallViewModel$(
|
||||
}),
|
||||
);
|
||||
|
||||
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
|
||||
const spotlightLandscapeLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightLandscapeLayoutMedia> =>
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-landscape",
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
@@ -1071,16 +1092,20 @@ export function createCallViewModel$(
|
||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
||||
type: "spotlight-portrait",
|
||||
edgeToEdge: false,
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
const spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
|
||||
const spotlightExpandedLayoutMedia$ = (
|
||||
edgeToEdge: boolean,
|
||||
): Observable<SpotlightExpandedLayoutMedia> =>
|
||||
spotlightAndPip$.pipe(
|
||||
switchMap(({ spotlight, pip$ }) =>
|
||||
pip$.pipe(
|
||||
map((pip) => ({
|
||||
type: "spotlight-expanded" as const,
|
||||
edgeToEdge,
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
})),
|
||||
@@ -1088,55 +1113,88 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
const oneOnOneLayoutMedia$: Observable<{
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
);
|
||||
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(
|
||||
vm,
|
||||
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
if (remote !== undefined) return of({ local, remote });
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map((ringingMedia) => {
|
||||
return ringingMedia.length === 1
|
||||
? {
|
||||
local,
|
||||
remote: ringingMedia[0],
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
|
||||
if (remote !== undefined)
|
||||
return of({
|
||||
type: "one-on-one" as const,
|
||||
spotlight: remote,
|
||||
pip: local,
|
||||
});
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map((ringingMedia) => {
|
||||
return ringingMedia.length === 1
|
||||
? {
|
||||
type: "one-on-one" as const,
|
||||
spotlight: local,
|
||||
pip: ringingMedia[0],
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOneLandscapeLayoutMedia$: Observable<OneOnOneLandscapeLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
map((media) => {
|
||||
if (media === null) return null;
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-landscape" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-landscape" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOnePortraitLayoutMedia$: Observable<OneOnOnePortraitLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((media) => {
|
||||
if (media === null) return of(null);
|
||||
return media.local.videoEnabled$.pipe(
|
||||
map((videoEnabled) => ({
|
||||
type: "one-on-one-portrait" as const,
|
||||
edgeToEdge: true as const,
|
||||
spotlight: media.remote,
|
||||
pip: videoEnabled ? media.local : undefined,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
||||
map((spotlight) => ({ type: "pip", spotlight })),
|
||||
map((spotlight) => ({
|
||||
type: "pip",
|
||||
edgeToEdge: platform !== "desktop",
|
||||
spotlight,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1151,7 +1209,7 @@ export function createCallViewModel$(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
return oneOnOneLandscapeLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
@@ -1160,15 +1218,15 @@ export function createCallViewModel$(
|
||||
return spotlightExpanded$.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? spotlightExpandedLayoutMedia$
|
||||
: spotlightLandscapeLayoutMedia$,
|
||||
? spotlightExpandedLayoutMedia$(false)
|
||||
: spotlightLandscapeLayoutMedia$(false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
return oneOnOnePortraitLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
@@ -1177,9 +1235,7 @@ export function createCallViewModel$(
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
spotlightExpandedLayoutMedia$,
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
@@ -1189,9 +1245,9 @@ export function createCallViewModel$(
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$;
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$;
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1202,6 +1258,201 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const showSpotlightIndicators$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(map((l) => l.type !== "grid")),
|
||||
);
|
||||
|
||||
const showSpeakingIndicators$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(
|
||||
map((l) => {
|
||||
switch (l.type) {
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
// If the spotlight is showing the active speaker, we can do without
|
||||
// speaking indicators as they're a redundant visual cue. But if
|
||||
// screen sharing feeds are in the spotlight we still need them.
|
||||
return l.spotlight.some((m) => m.type === "screen share");
|
||||
// In expanded spotlight layout, the active speaker is always shown in
|
||||
// the picture-in-picture tile so there is no need for speaking
|
||||
// indicators. And in one-on-one layout there's no question as to who is
|
||||
// speaking.
|
||||
case "spotlight-expanded":
|
||||
case "one-on-one-landscape":
|
||||
case "one-on-one-portrait":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const showNameTags$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(
|
||||
switchMap((l) =>
|
||||
l.type === "pip" || l.type === "one-on-one-portrait"
|
||||
? matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
// Hide name tags by default in these layouts. For safety we
|
||||
// still need to show them in case it wouldn't be clear who
|
||||
// the spotlight media belongs to.
|
||||
// TODO: Respect io.element.functional_members (while still
|
||||
// being careful to never show a functional member's media
|
||||
// without a name tag!)
|
||||
// TODO: Only hide name tags in DMs, not group chats that just
|
||||
// happen to have only 2 users
|
||||
members.size > 2,
|
||||
),
|
||||
)
|
||||
: of(true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) =>
|
||||
mode === "normal"
|
||||
? layoutMedia$.pipe(
|
||||
map(
|
||||
(l) =>
|
||||
l.type === "spotlight-landscape" ||
|
||||
l.type === "spotlight-expanded",
|
||||
),
|
||||
)
|
||||
: of(false),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map((enabled) =>
|
||||
enabled ? (): void => spotlightExpandedToggle$.next() : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const edgeToEdge$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)),
|
||||
);
|
||||
|
||||
const screenTap$ = new Subject<void>();
|
||||
const controlsTap$ = new Subject<void>();
|
||||
const screenHover$ = new Subject<void>();
|
||||
const screenUnhover$ = new Subject<void>();
|
||||
|
||||
const naturallyShowFooter$ = scope.behavior<boolean>(
|
||||
edgeToEdge$.pipe(
|
||||
switchMap((edgeToEdge) => {
|
||||
if (!edgeToEdge) return of(true);
|
||||
|
||||
// Sadly Firefox has some layering glitches that prevent the footer
|
||||
// from appearing properly. They happen less often if we never hide
|
||||
// the footer.
|
||||
if (isFirefox()) return of(true);
|
||||
|
||||
// Layout is edge-to-edge; show/hide the footer in response to interactions
|
||||
return windowMode$.pipe(
|
||||
switchMap((mode) => {
|
||||
if (mode === "pip" && platform !== "desktop") {
|
||||
// No controls are shown in mobile pip as interactions are disabled
|
||||
return of(false);
|
||||
}
|
||||
const showInitially = mode !== "flat";
|
||||
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
|
||||
|
||||
return merge(
|
||||
screenTap$.pipe(map(() => "tap screen" as const)),
|
||||
controlsTap$.pipe(map(() => "tap controls" as const)),
|
||||
screenHover$.pipe(map(() => "hover" as const)),
|
||||
).pipe(
|
||||
switchScan((state, interaction) => {
|
||||
switch (interaction) {
|
||||
case "tap screen":
|
||||
return state
|
||||
? // Toggle visibility on tap
|
||||
of(false)
|
||||
: // Hide after a timeout
|
||||
timeout$.pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
case "tap controls":
|
||||
// The user is interacting with things, so reset the timeout
|
||||
return timeout$.pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
case "hover":
|
||||
// Show on hover and hide after a timeout
|
||||
return race(timeout$, screenUnhover$.pipe(take(1))).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
}
|
||||
}, showInitially),
|
||||
startWith(showInitially),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const urlParams = getUrlParams();
|
||||
const showFooterUrlParams = !(
|
||||
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||
);
|
||||
const showFooter$ = scope.behavior(
|
||||
naturallyShowFooter$.pipe(
|
||||
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
|
||||
),
|
||||
);
|
||||
const settingsOpen$ = new BehaviorSubject(false);
|
||||
const setSettingsOpen$ = constant((open: boolean) => {
|
||||
settingsOpen$.next(open);
|
||||
});
|
||||
|
||||
const showHeader$ = scope.behavior<boolean>(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) => {
|
||||
// In small windows the header would be too obstructive
|
||||
if (mode === "pip" || mode === "flat") return of(false);
|
||||
// In edge-to-edge layouts, couple the visibility of the header
|
||||
// to that of the footer
|
||||
return edgeToEdge$.pipe(
|
||||
switchMap((edgeToEdge) => (edgeToEdge ? showFooter$ : of(true))),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The alignment of the floating spotlight tile, if present.
|
||||
*/
|
||||
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
/**
|
||||
* The size of the small picture-in-picture tile, if present, when in portrait.
|
||||
*/
|
||||
const portraitPipSize$ = scope.behavior(
|
||||
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
|
||||
);
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present, when in portrait.
|
||||
*/
|
||||
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
/**
|
||||
* The alignment of the small picture-in-picture tile, if present, when in landscape.
|
||||
*/
|
||||
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "start",
|
||||
});
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
// first render a layout. To deal with this we assume initially that no tiles
|
||||
@@ -1228,16 +1479,33 @@ export function createCallViewModel$(
|
||||
case "spotlight-portrait":
|
||||
[layout, newTiles] = gridLikeLayout(
|
||||
media,
|
||||
spotlightAlignment$,
|
||||
visibleTiles,
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(media, prevTiles);
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one":
|
||||
[layout, newTiles] = oneOnOneLayout(media, prevTiles);
|
||||
case "one-on-one-landscape":
|
||||
[layout, newTiles] = oneOnOneLandscapeLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-portrait":
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
@@ -1265,130 +1533,6 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(map(({ tiles }) => tiles.generation)),
|
||||
);
|
||||
|
||||
const showSpotlightIndicators$ = scope.behavior<boolean>(
|
||||
layout$.pipe(map((l) => l.type !== "grid")),
|
||||
);
|
||||
|
||||
const showSpeakingIndicators$ = scope.behavior<boolean>(
|
||||
layout$.pipe(
|
||||
switchMap((l) => {
|
||||
switch (l.type) {
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
// If the spotlight is showing the active speaker, we can do without
|
||||
// speaking indicators as they're a redundant visual cue. But if
|
||||
// screen sharing feeds are in the spotlight we still need them.
|
||||
return l.spotlight.media$.pipe(
|
||||
map((models: MediaViewModel[]) =>
|
||||
models.some((m) => m.type === "screen share"),
|
||||
),
|
||||
);
|
||||
// In expanded spotlight layout, the active speaker is always shown in
|
||||
// the picture-in-picture tile so there is no need for speaking
|
||||
// indicators. And in one-on-one layout there's no question as to who is
|
||||
// speaking.
|
||||
case "spotlight-expanded":
|
||||
case "one-on-one":
|
||||
return of(false);
|
||||
default:
|
||||
return of(true);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) =>
|
||||
mode === "normal"
|
||||
? layout$.pipe(
|
||||
map(
|
||||
(l) =>
|
||||
l.type === "spotlight-landscape" ||
|
||||
l.type === "spotlight-expanded",
|
||||
),
|
||||
)
|
||||
: of(false),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map((enabled) =>
|
||||
enabled ? (): void => spotlightExpandedToggle$.next() : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const screenTap$ = new Subject<void>();
|
||||
const controlsTap$ = new Subject<void>();
|
||||
const screenHover$ = new Subject<void>();
|
||||
const screenUnhover$ = new Subject<void>();
|
||||
|
||||
const showHeader$ = scope.behavior<boolean>(
|
||||
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
|
||||
);
|
||||
|
||||
const urlParams = getUrlParams();
|
||||
const showFooterUrlParams = !(
|
||||
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||
);
|
||||
const showFooterLayout$ = scope.behavior<boolean>(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) => {
|
||||
switch (mode) {
|
||||
case "pip":
|
||||
return of(platform === "desktop" ? true : false);
|
||||
case "normal":
|
||||
case "narrow":
|
||||
return of(true);
|
||||
case "flat":
|
||||
// Sadly Firefox has some layering glitches that prevent the footer
|
||||
// from appearing properly. They happen less often if we never hide
|
||||
// the footer.
|
||||
if (isFirefox()) return of(true);
|
||||
// Show/hide the footer in response to interactions
|
||||
return merge(
|
||||
screenTap$.pipe(map(() => "tap screen" as const)),
|
||||
controlsTap$.pipe(map(() => "tap controls" as const)),
|
||||
screenHover$.pipe(map(() => "hover" as const)),
|
||||
).pipe(
|
||||
switchScan((state, interaction) => {
|
||||
switch (interaction) {
|
||||
case "tap screen":
|
||||
return state
|
||||
? // Toggle visibility on tap
|
||||
of(false)
|
||||
: // Hide after a timeout
|
||||
timer(showFooterMs).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
case "tap controls":
|
||||
// The user is interacting with things, so reset the timeout
|
||||
return timer(showFooterMs).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
case "hover":
|
||||
// Show on hover and hide after a timeout
|
||||
return race(
|
||||
timer(showFooterMs),
|
||||
screenUnhover$.pipe(take(1)),
|
||||
).pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
);
|
||||
}
|
||||
}, false),
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
const showFooter$ = scope.behavior(
|
||||
showFooterLayout$.pipe(
|
||||
map((showFooter) => showFooter && showFooterUrlParams),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Whether audio is currently being output through the earpiece.
|
||||
*/
|
||||
@@ -1592,7 +1736,6 @@ export function createCallViewModel$(
|
||||
audibleReactions$: audibleReactions$,
|
||||
visibleReactions$: visibleReactions$,
|
||||
|
||||
windowMode$: windowMode$,
|
||||
spotlightExpanded$: spotlightExpanded$,
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
@@ -1618,8 +1761,12 @@ export function createCallViewModel$(
|
||||
tileStoreGeneration$: tileStoreGeneration$,
|
||||
showSpotlightIndicators$: showSpotlightIndicators$,
|
||||
showSpeakingIndicators$: showSpeakingIndicators$,
|
||||
showNameTags$,
|
||||
showHeader$: showHeader$,
|
||||
showFooter$: showFooter$,
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
|
||||
@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
ConnectionState,
|
||||
type LocalParticipant,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { BehaviorSubject, combineLatest, map, of } from "rxjs";
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
import { type Behavior, constant } from "../Behavior";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { type MatrixRTCMode } from "../../settings/settings";
|
||||
import { type MatrixRTCMode } from "../../config/ConfigOptions";
|
||||
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
@@ -72,6 +72,7 @@ export interface CallViewModelInputs {
|
||||
roomMembers: RoomMember[];
|
||||
livekitConnectionState$: Behavior<ConnectionState>;
|
||||
speaking: Map<Participant, Behavior<boolean>>;
|
||||
videoEnabled: Map<Participant, Behavior<boolean>>;
|
||||
sharingScreen: Map<Participant, Behavior<boolean>>;
|
||||
mediaDevices: MediaDevices;
|
||||
initialSyncState: SyncState;
|
||||
@@ -98,6 +99,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
ConnectionState.Connected,
|
||||
),
|
||||
speaking = new Map(),
|
||||
videoEnabled = new Map(),
|
||||
sharingScreen = new Map(),
|
||||
mediaDevices = mockMediaDevices({}),
|
||||
initialSyncState = SyncState.Syncing,
|
||||
@@ -151,11 +153,19 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
.mockReturnValue(remoteParticipants$);
|
||||
const mediaSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||
.mockImplementation((p) =>
|
||||
of({ participant: p } as Partial<
|
||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
||||
);
|
||||
.mockImplementation((p) => {
|
||||
return (videoEnabled.get(p) ?? constant(false)).pipe(
|
||||
map((videoEnabled) => ({
|
||||
participant: p,
|
||||
isMicrophoneEnabled: false,
|
||||
isCameraEnabled: videoEnabled,
|
||||
isScreenShareEnabled: false,
|
||||
cameraTrack: {
|
||||
isMuted: !videoEnabled,
|
||||
} as unknown as TrackPublication,
|
||||
})),
|
||||
);
|
||||
});
|
||||
const eventsSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||
.mockImplementation((p, ...eventTypes) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ObservableScope } from "../../ObservableScope";
|
||||
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||
import { testScope, withTestScheduler } from "../../../utils/test";
|
||||
|
||||
/**
|
||||
* Minimal stub of a Matrix client sufficient for our tests:
|
||||
@@ -96,107 +97,240 @@ describe("createHomeserverConnected$", () => {
|
||||
|
||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||
// easy enough to read them so I think they can stay.
|
||||
it("is false when sync state is not Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||
it("reports syncing reason when sync state is not Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Make sync loop OK
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// Indicate probable leave before connection
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("becomes true only when all three conditions are satisfied", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// 1. Sync loop connected
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected
|
||||
// 2. Membership connected
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
|
||||
});
|
||||
|
||||
it("drops back to false when sync loop leaves Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Reach connected state
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Sync loop error => should flip false
|
||||
// Sync loop error => should report syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("drops back to false when membership status becomes disconnected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setMembershipStatus(Status.Disconnected);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
});
|
||||
|
||||
it("composite sequence reflects each individual failure reason", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
|
||||
// Initially false (sync error + disconnected + not probably left)
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
|
||||
// Fix sync only
|
||||
// Fix sync only → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
||||
|
||||
// Fix membership
|
||||
// Fix membership → all conditions satisfied
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Introduce probablyLeft -> false
|
||||
// Introduce probablyLeft → certainlyConnected
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Restore notProbablyLeft -> true again
|
||||
// Restore notProbablyLeft → connected again
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.combined$.value).toBe(true);
|
||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
||||
|
||||
// Drop sync -> false
|
||||
// Drop sync → syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.combined$.value).toBe(false);
|
||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - combined$ reason values", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
// Start with sync failing and membership disconnected
|
||||
client = new MockMatrixClient(SyncState.Error);
|
||||
session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Disconnected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("is [true, null] when all three conditions are satisfied", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(combined$.value).toEqual([true, null]);
|
||||
});
|
||||
|
||||
it("reports syncing when sync loop is not Syncing", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// client starts with SyncState.Error, membership also disconnected
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// session still Status.Disconnected
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
});
|
||||
|
||||
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
session.setProbablyLeft(true);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
});
|
||||
|
||||
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Both sync (Error) and membership (Disconnected) are failing
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
});
|
||||
|
||||
it("updates reason as conditions change", () => {
|
||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Initially: syncing fails
|
||||
expect(combined$.value).toEqual([false, "sync"]);
|
||||
|
||||
// Fix sync → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(combined$.value).toEqual([false, "membership"]);
|
||||
|
||||
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
||||
|
||||
// Clear probablyLeft → all conditions satisfied
|
||||
session.setProbablyLeft(false);
|
||||
expect(combined$.value).toEqual([true, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||
const GRACE_PERIOD = 5;
|
||||
|
||||
function marbleTest(
|
||||
syncStateMarbles: string,
|
||||
expectedConnectedMarbles: string,
|
||||
): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const syncState$ = behavior(syncStateMarbles, {
|
||||
s: SyncState.Syncing,
|
||||
e: SyncState.Error,
|
||||
});
|
||||
const client = new MockMatrixClient(syncState$.value);
|
||||
schedule(syncStateMarbles, {
|
||||
s: () => client.setSyncState(SyncState.Syncing),
|
||||
e: () => client.setSyncState(SyncState.Error),
|
||||
});
|
||||
const session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Connected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
const hsConnected = createHomeserverConnected$(
|
||||
testScope(),
|
||||
client,
|
||||
session,
|
||||
GRACE_PERIOD,
|
||||
);
|
||||
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
||||
y: [true, null],
|
||||
n: [false, "sync"],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||
// - Initial state: Everything is connected
|
||||
// - Sync error occurs -> should remain connected due to grace period
|
||||
// - After grace period, not connected
|
||||
marbleTest("se", "y-----n");
|
||||
// If the sync error takes longer to occur, it should take equally long for
|
||||
// the connection state to change
|
||||
marbleTest("s--e", "y-------n");
|
||||
});
|
||||
|
||||
it("recovers immediately if sync returns during grace period", () => {
|
||||
// - Initial state: Connected
|
||||
// - Sync error occurs
|
||||
// - Sync recovers BEFORE the grace period expires
|
||||
// - Connection state remains constant
|
||||
marbleTest("se--s", "y");
|
||||
});
|
||||
|
||||
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||
// - Initial error: connection eventually flips to false
|
||||
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||
marbleTest("e-----s", "y----ny");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,23 @@ import {
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
|
||||
import {
|
||||
fromEvent,
|
||||
startWith,
|
||||
map,
|
||||
tap,
|
||||
type Observable,
|
||||
distinctUntilChanged,
|
||||
switchMap,
|
||||
of,
|
||||
delay,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../../../config/Config";
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { and$ } from "../../../utils/observable";
|
||||
import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
|
||||
/**
|
||||
@@ -25,8 +36,14 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
*/
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
|
||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
export interface HomeserverConnected {
|
||||
combined$: Behavior<boolean>;
|
||||
/**
|
||||
* Emits `[true, null]` when the homeserver connection is healthy, or
|
||||
* `[false, reason]` when one of the three sub-conditions fails.
|
||||
*/
|
||||
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
|
||||
rtsSession$: Behavior<Status>;
|
||||
}
|
||||
|
||||
@@ -34,29 +51,48 @@ export interface HomeserverConnected {
|
||||
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||
* for the purposes of a MatrixRTC session.
|
||||
*
|
||||
* Becomes FALSE if ANY sub-condition is fulfilled:
|
||||
* 1. Sync loop is not in SyncState.Syncing
|
||||
* 2. membershipStatus !== Status.Connected
|
||||
* 3. probablyLeft === true
|
||||
* `combined$` emits `null` when all conditions are satisfied, or the first failing
|
||||
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
||||
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
||||
* 2. membershipStatus !== Status.Connected → "membership"
|
||||
* 3. probablyLeft === true → "probablyLeft"
|
||||
*
|
||||
* @param scope - The observable scope for lifecycle management.
|
||||
* @param client - The Matrix client to monitor sync state.
|
||||
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||
* If not provided, uses the config value (default 10000ms).
|
||||
*/
|
||||
export function createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
gracePeriodMs?: number,
|
||||
): HomeserverConnected {
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||
|
||||
const syncing$ = (
|
||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||
).pipe(
|
||||
startWith([client.getSyncState()]),
|
||||
map(([state]) => state === SyncState.Syncing),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isSyncing) => {
|
||||
if (isSyncing || graceMs <= 0) {
|
||||
return of(isSyncing);
|
||||
}
|
||||
return of(false).pipe(delay(graceMs), startWith(true));
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const rtsSession$ = scope.behavior<Status>(
|
||||
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||
),
|
||||
Status.Unknown,
|
||||
matrixRTCSession.membershipStatus ?? Status.Unknown,
|
||||
);
|
||||
|
||||
const membershipConnected$ = rtsSession$.pipe(
|
||||
@@ -80,9 +116,22 @@ export function createHomeserverConnected$(
|
||||
);
|
||||
|
||||
const combined$ = scope.behavior(
|
||||
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
|
||||
tap((connected) => {
|
||||
logger.info(`Homeserver connected update: ${connected}`);
|
||||
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||
map(
|
||||
([syncing, membership, certainly]): [
|
||||
boolean,
|
||||
HomeserverDisconnectReason | null,
|
||||
] => {
|
||||
if (!syncing) return [false, "sync"];
|
||||
if (!membership) return [false, "membership"];
|
||||
if (!certainly) return [false, "probablyLeft"];
|
||||
return [true, null];
|
||||
},
|
||||
),
|
||||
tap(([connected, reason]) => {
|
||||
logger.info(
|
||||
`Homeserver connected update: ${connected ? "connected" : reason}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,13 +11,23 @@ import {
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
} from "vitest";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
import { BehaviorSubject, map, of } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LocalParticipant, type LocalTrack } from "livekit-client";
|
||||
|
||||
import { MatrixRTCMode } from "../../../settings/settings";
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
|
||||
import { MatrixRTCMode } from "../../../config/ConfigOptions";
|
||||
import { type HomeserverDisconnectReason } from "./HomeserverConnected";
|
||||
import {
|
||||
flushPromises,
|
||||
mockConfig,
|
||||
@@ -215,9 +225,13 @@ describe("LocalMembership", () => {
|
||||
createPublisherFactory: vi.fn(),
|
||||
joinMatrixRTC: async (): Promise<void> => {},
|
||||
homeserverConnected: {
|
||||
combined$: constant(true),
|
||||
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
|
||||
true,
|
||||
null,
|
||||
]),
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
roomId: "!test-room-id:example.org",
|
||||
};
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
@@ -667,4 +681,210 @@ describe("LocalMembership", () => {
|
||||
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||
});
|
||||
// TODO add tests for matrix local matrix participation.
|
||||
|
||||
describe("reconnecting analytics", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("does not fire CallReconnecting for the initial non-connected state at startup", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
// Simulate startup where membership isn't established yet
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([false, "membership"]);
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$: new BehaviorSubject({
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
}),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Membership is established — call is now connected
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).not.toHaveBeenCalled();
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([true, null]);
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$: new BehaviorSubject({
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
}),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
hsReason$.next([false, "sync"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"sync",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("reports livekit reason when livekit disconnects then reconnects", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const connectionState$ = new BehaviorSubject<ConnectionState>(
|
||||
ConnectionState.LivekitConnected,
|
||||
);
|
||||
const mutableConnection = {
|
||||
...connectionTransportAConnected,
|
||||
state$: connectionState$,
|
||||
} as unknown as Connection;
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(mutableConnection, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([true, null]),
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$: new BehaviorSubject({
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
}),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
connectionState$.next(ConnectionState.LivekitDisconnected);
|
||||
connectionState$.next(ConnectionState.LivekitConnected);
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledWith(
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"livekit",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("fires one event per completed reconnection cycle", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const trackSpy = vi.spyOn(
|
||||
PosthogAnalytics.instance.eventCallReconnecting,
|
||||
"track",
|
||||
);
|
||||
|
||||
const hsReason$ = new BehaviorSubject<
|
||||
[boolean, HomeserverDisconnectReason | null]
|
||||
>([true, null]);
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
connectionManagerData.add(connectionTransportAConnected, []);
|
||||
|
||||
createLocalMembership$({
|
||||
scope,
|
||||
...defaultCreateLocalMemberValues,
|
||||
homeserverConnected: {
|
||||
combined$: hsReason$,
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||
},
|
||||
localTransport$: new BehaviorSubject({
|
||||
advertised$: new BehaviorSubject(aTransport),
|
||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
||||
}),
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
hsReason$.next([false, "membership"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
hsReason$.next([false, "probablyLeft"]);
|
||||
hsReason$.next([false, "sync"]);
|
||||
hsReason$.next([false, "membership"]);
|
||||
hsReason$.next([true, null]);
|
||||
|
||||
expect(trackSpy).toHaveBeenCalledTimes(2);
|
||||
expect(trackSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"membership",
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(trackSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
defaultCreateLocalMemberValues.roomId,
|
||||
"probablyLeft",
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,6 @@ import { ElementWidgetActions, widget } from "../../../widget.ts";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||
import {
|
||||
MatrixRTCMode,
|
||||
advancedScreenShare,
|
||||
screenShareResolution,
|
||||
screenShareFramerate,
|
||||
@@ -63,6 +62,7 @@ import {
|
||||
screenShareCodec,
|
||||
parseResolution,
|
||||
} from "../../../settings/settings.ts";
|
||||
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts";
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
ConnectionState,
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
type FailedToStartError,
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { and$ } from "../../../utils/observable.ts";
|
||||
import { type LocalTransport } from "./LocalTransport.ts";
|
||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||
|
||||
@@ -138,6 +137,7 @@ interface Props {
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
roomId: string;
|
||||
localTransport$: Behavior<LocalTransport>;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
@@ -161,6 +161,7 @@ interface Props {
|
||||
* @param props.logger The logger to use.
|
||||
* @param props.muteStates The mute states for video and audio.
|
||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||
* @param props.roomId The room ID used as the call identifier in analytics events.
|
||||
* @returns
|
||||
* - publisher: The handle to create tracks and publish them to the room.
|
||||
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
||||
@@ -178,6 +179,7 @@ export const createLocalMembership$ = ({
|
||||
logger: parentLogger,
|
||||
muteStates,
|
||||
matrixRTCSession,
|
||||
roomId: roomId,
|
||||
}: Props): {
|
||||
/**
|
||||
* This request to start audio and video tracks.
|
||||
@@ -503,20 +505,35 @@ export const createLocalMembership$ = ({
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether we are "fully" connected to the call. Accounts for both the
|
||||
* connection to the MatrixRTC session and the LiveKit publish connection.
|
||||
* The disconnect reason for the combined Matrix + LiveKit connection, or null
|
||||
* when fully connected. Homeserver reasons take priority over livekit.
|
||||
* Both connectivity state and reason come from the same combineLatest emission,
|
||||
* avoiding any race between the two.
|
||||
*/
|
||||
const matrixAndLivekitConnected$ = scope.behavior(
|
||||
and$(
|
||||
const connectionDisconnectReason$ = scope.behavior(
|
||||
combineLatest([
|
||||
homeserverConnected.combined$,
|
||||
localConnectionState$.pipe(
|
||||
map((state) => state === ConnectionState.LivekitConnected),
|
||||
),
|
||||
).pipe(
|
||||
]).pipe(
|
||||
map(([[hsConnected, hsReason], livekitConnected]) => {
|
||||
if (!hsConnected) return hsReason!;
|
||||
if (!livekitConnected) return "livekit" as const;
|
||||
return null;
|
||||
}),
|
||||
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether we are "fully" connected to the call. Accounts for both the
|
||||
* connection to the MatrixRTC session and the LiveKit publish connection.
|
||||
*/
|
||||
const matrixAndLivekitConnected$ = scope.behavior(
|
||||
connectionDisconnectReason$.pipe(map((reason) => reason === null)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether we should tell the user that we're reconnecting to the call.
|
||||
*/
|
||||
@@ -528,6 +545,33 @@ export const createLocalMembership$ = ({
|
||||
false,
|
||||
);
|
||||
|
||||
let reconnectStart: {
|
||||
time: number;
|
||||
reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>;
|
||||
} | null = null;
|
||||
connectionDisconnectReason$
|
||||
.pipe(distinctUntilChanged(), pairwise(), scope.bind())
|
||||
.subscribe(([prev, reason]) => {
|
||||
if (reason !== null) {
|
||||
// Only begin tracking when transitioning FROM connected (null → non-null).
|
||||
// This prevents the initial startup phase — where we may be non-null before
|
||||
// the first real connection — from being counted as a reconnect.
|
||||
if (prev === null) {
|
||||
reconnectStart ??= { time: Date.now(), reason };
|
||||
}
|
||||
} else if (reconnectStart !== null) {
|
||||
PosthogAnalytics.instance.eventCallReconnecting.track(
|
||||
roomId,
|
||||
reconnectStart.reason,
|
||||
(Date.now() - reconnectStart.time) / 1000,
|
||||
);
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(
|
||||
reconnectStart.reason,
|
||||
);
|
||||
reconnectStart = null;
|
||||
}
|
||||
});
|
||||
|
||||
// inform the widget about the connect and disconnect intent from the user.
|
||||
scope
|
||||
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||
@@ -615,7 +659,7 @@ export const createLocalMembership$ = ({
|
||||
// TODO refactor this based no livekitState$
|
||||
combineLatest([participant$, homeserverConnected.combined$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([participant, connected]) => {
|
||||
.subscribe(([participant, [connected]]) => {
|
||||
if (!participant) return;
|
||||
const publications = participant.trackPublications.values();
|
||||
if (connected) {
|
||||
@@ -828,6 +872,19 @@ export function enterRTCSession(
|
||||
};
|
||||
}
|
||||
|
||||
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER:
|
||||
// - The /sync loop is unresponsive for > `gracePeriod` ms, or
|
||||
// - A delayed leave event is emitted (after `leaveDelay` ms period).
|
||||
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
|
||||
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
|
||||
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
|
||||
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
|
||||
|
||||
// Math.min is used to account for the respective worst case: /sync not available or leave event emitted.
|
||||
const maxWaitTime = Math.min(gracePeriod, leaveDelay);
|
||||
const maximumNetworkErrorRetryCount =
|
||||
Math.ceil(maxWaitTime / retryInterval) + 1;
|
||||
|
||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||
|
||||
@@ -853,6 +910,7 @@ export function enterRTCSession(
|
||||
membershipEventExpiryMs:
|
||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ function createMockLocalTrack(source: Track.Source): LocalTrack {
|
||||
|
||||
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
||||
enabled$: BehaviorSubject<boolean>;
|
||||
syncing$: BehaviorSubject<boolean>;
|
||||
setHandler: (h: (enabled: boolean) => void) => void;
|
||||
unsetHandler: () => void;
|
||||
} {
|
||||
@@ -70,6 +71,7 @@ function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
||||
|
||||
const ms = {
|
||||
enabled$,
|
||||
syncing$: new BehaviorSubject(false),
|
||||
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
|
||||
currentHandler = h;
|
||||
}),
|
||||
@@ -132,6 +134,7 @@ beforeEach(() => {
|
||||
new Map(),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
|
||||
vi.mocked(localParticipant).createTracks = vi
|
||||
|
||||
@@ -112,27 +112,49 @@ export class Publisher {
|
||||
this.logger.info("Local track published", localTrackPublication);
|
||||
const lkRoom = this.connection.livekitRoom;
|
||||
if (!this.shouldPublish) {
|
||||
this.logger.debug("Not publishing, pausing upstream");
|
||||
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
|
||||
this.logger.error(`Failed to pause upstreams`, e);
|
||||
});
|
||||
}
|
||||
// also check the mute state and apply it
|
||||
if (localTrackPublication.source === Track.Source.Microphone) {
|
||||
const enabled = this.muteStates.audio.enabled$.value;
|
||||
lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable microphone track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
const muteState = this.muteStates.audio;
|
||||
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||
// state while the handler is mid-flight, so the handler itself will apply
|
||||
// the correct mute state once it completes.
|
||||
if (!muteState.syncing$.value) {
|
||||
const enabled = muteState.enabled$.value;
|
||||
if (!enabled) {
|
||||
this.logger.info(
|
||||
"Local audio track just published but muted meanwhile, setting enabled to false",
|
||||
);
|
||||
lkRoom.localParticipant.setMicrophoneEnabled(false).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable microphone track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (localTrackPublication.source === Track.Source.Camera) {
|
||||
const enabled = this.muteStates.video.enabled$.value;
|
||||
lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable camera track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
const muteState = this.muteStates.video;
|
||||
// skip this if a sync is in progress: enabled$ still reflects the old
|
||||
// state while the handler is mid-flight, so the handler itself will apply
|
||||
// the correct mute state once it completes.
|
||||
if (!muteState.syncing$.value) {
|
||||
const enabled = muteState.enabled$.value;
|
||||
if (!enabled) {
|
||||
this.logger.info(
|
||||
"Local video track just published but muted meanwhile, setting enabled to false",
|
||||
);
|
||||
lkRoom.localParticipant.setCameraEnabled(false).catch((e) => {
|
||||
this.logger.error(
|
||||
`Failed to enable camera track, enabled:${enabled}`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -379,10 +401,11 @@ export class Publisher {
|
||||
if (!this.shouldPublish && enable) {
|
||||
await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]);
|
||||
}
|
||||
return enable;
|
||||
} catch (e) {
|
||||
this.logger.error("Failed to update LiveKit audio input mute state", e);
|
||||
return lkRoom.localParticipant.isMicrophoneEnabled;
|
||||
}
|
||||
return lkRoom.localParticipant.isMicrophoneEnabled;
|
||||
});
|
||||
this.muteStates.video.setHandler(async (enable) => {
|
||||
try {
|
||||
@@ -393,10 +416,11 @@ export class Publisher {
|
||||
if (!this.shouldPublish && enable) {
|
||||
await this.pauseUpstreams(lkRoom, [Track.Source.Camera]);
|
||||
}
|
||||
return enable;
|
||||
} catch (e) {
|
||||
this.logger.error("Failed to update LiveKit video input mute state", e);
|
||||
return lkRoom.localParticipant.isCameraEnabled;
|
||||
}
|
||||
return lkRoom.localParticipant.isCameraEnabled;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,10 @@ describe("Start connection states", () => {
|
||||
await deferredSFU.promise;
|
||||
return {
|
||||
status: 500,
|
||||
body: "Internal Server Error",
|
||||
body: {
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -282,7 +285,7 @@ describe("Start connection states", () => {
|
||||
capturedState.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
"SFU Config fetch failed with status code 500",
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
livekitFocus.livekit_alias,
|
||||
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionError,
|
||||
type Room as LivekitRoom,
|
||||
ConnectionErrorReason,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
@@ -30,6 +31,8 @@ import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
PeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
@@ -248,6 +251,13 @@ export class Connection {
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
|
||||
if (e.reason === ConnectionErrorReason.Timeout) {
|
||||
// Unabled to establish peer connection within the timeout
|
||||
throw new PeerConnectionTimeoutError();
|
||||
}
|
||||
|
||||
throw new LivekitConnectionError(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { constant } from "./Behavior.ts";
|
||||
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
||||
import { ElementWidgetActions, widget } from "../widget.ts";
|
||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||
import { MatrixRTCMode } from "../settings/settings.ts";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
||||
|
||||
vi.mock("@livekit/components-core", { spy: true });
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Layout, type LayoutMedia } from "./layout-types.ts";
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
} from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
|
||||
export type GridLikeLayoutType =
|
||||
@@ -19,6 +25,7 @@ export type GridLikeLayoutType =
|
||||
*/
|
||||
export function gridLikeLayout(
|
||||
media: LayoutMedia & { type: GridLikeLayoutType },
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>,
|
||||
visibleTiles: number,
|
||||
setVisibleTiles: (value: number) => void,
|
||||
prevTiles: TileStore,
|
||||
@@ -37,6 +44,7 @@ export function gridLikeLayout(
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile,
|
||||
grid: tiles.gridTiles,
|
||||
spotlightAlignment$,
|
||||
setVisibleTiles,
|
||||
} as Layout & { type: GridLikeLayoutType },
|
||||
tiles,
|
||||
|
||||
@@ -92,6 +92,75 @@ describe("MuteState", () => {
|
||||
await flushPromises();
|
||||
expect(lastEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("should ignore toggle while syncing but still process set requests", async () => {
|
||||
const deviceStub = {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic", { type: "name", name: "Microphone" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant({ id: "mic" }),
|
||||
select(): void {},
|
||||
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
|
||||
|
||||
const muteState = new MuteState(
|
||||
testScope,
|
||||
deviceStub,
|
||||
false,
|
||||
constant(false),
|
||||
);
|
||||
|
||||
const first = Promise.withResolvers<boolean>();
|
||||
const second = Promise.withResolvers<boolean>();
|
||||
const handler = vi
|
||||
.fn<(desired: boolean) => Promise<boolean>>()
|
||||
.mockImplementationOnce(async () => first.promise)
|
||||
.mockImplementationOnce(async () => second.promise);
|
||||
muteState.setHandler(handler);
|
||||
|
||||
const syncingValues: boolean[] = [];
|
||||
muteState.syncing$.subscribe((syncing) => {
|
||||
syncingValues.push(syncing);
|
||||
});
|
||||
|
||||
let setEnabled: ((enabled: boolean) => void) | null = null;
|
||||
muteState.setEnabled$.subscribe((setter) => {
|
||||
setEnabled = setter;
|
||||
});
|
||||
let toggle: (() => void) | null = null;
|
||||
muteState.toggle$.subscribe((toggleFn) => {
|
||||
toggle = toggleFn;
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Start syncing by requesting unmute.
|
||||
toggle!();
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenNthCalledWith(1, true);
|
||||
|
||||
// Toggle requests are ignored while syncing.
|
||||
toggle!();
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// setEnabled still updates latest desired state while syncing (push-to-talk).
|
||||
setEnabled!(false);
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.resolve(true);
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
second.resolve(false);
|
||||
await flushPromises();
|
||||
expect(syncingValues).toContain(true);
|
||||
expect(syncingValues.at(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MuteStates", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
interface MuteStateData {
|
||||
enabled$: Observable<boolean>;
|
||||
syncing$: Observable<boolean>;
|
||||
set: ((enabled: boolean) => void) | null;
|
||||
toggle: (() => void) | null;
|
||||
}
|
||||
@@ -79,33 +80,40 @@ export class MuteState<Label, Selected> {
|
||||
this.handler$.value(false).catch((err) => {
|
||||
logger.error("MuteState-disable: handler error", err);
|
||||
});
|
||||
return { enabled$: of(false), set: null, toggle: null };
|
||||
return {
|
||||
enabled$: of(false),
|
||||
syncing$: of(false),
|
||||
set: null,
|
||||
toggle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Assume the default value only once devices are actually connected
|
||||
let enabled = this.enabledByDefault;
|
||||
const set$ = new Subject<boolean>();
|
||||
const toggle$ = new Subject<void>();
|
||||
const syncing$ = new BehaviorSubject(false);
|
||||
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
|
||||
const enabled$ = new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(enabled);
|
||||
let latestDesired = this.enabledByDefault;
|
||||
let syncing = false;
|
||||
|
||||
const sync = async (): Promise<void> => {
|
||||
if (enabled === latestDesired) syncing = false;
|
||||
else {
|
||||
if (enabled === latestDesired) {
|
||||
syncing$.next(false);
|
||||
} else {
|
||||
const previouslyEnabled = enabled;
|
||||
syncing$.next(true);
|
||||
enabled = await firstValueFrom(
|
||||
this.handler$.pipe(
|
||||
switchMap(async (handler) => handler(latestDesired)),
|
||||
),
|
||||
);
|
||||
if (enabled === previouslyEnabled) {
|
||||
syncing = false;
|
||||
syncing$.next(false);
|
||||
} else {
|
||||
subscriber.next(enabled);
|
||||
syncing = true;
|
||||
syncing$.next(true);
|
||||
sync().catch((err) => {
|
||||
// TODO: better error handling
|
||||
logger.error("MuteState: handler error", err);
|
||||
@@ -116,21 +124,28 @@ export class MuteState<Label, Selected> {
|
||||
|
||||
const s = desired$.subscribe((desired) => {
|
||||
latestDesired = desired;
|
||||
if (syncing === false) {
|
||||
syncing = true;
|
||||
if (syncing$.value === false) {
|
||||
syncing$.next(true);
|
||||
sync().catch((err) => {
|
||||
// TODO: better error handling
|
||||
logger.error("MuteState: handler error", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
return (): void => s.unsubscribe();
|
||||
return (): void => {
|
||||
s.unsubscribe();
|
||||
syncing$.complete();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
set: (enabled: boolean): void => set$.next(enabled),
|
||||
toggle: (): void => toggle$.next(),
|
||||
toggle: (): void => {
|
||||
if (syncing$.value) return;
|
||||
toggle$.next();
|
||||
},
|
||||
enabled$,
|
||||
syncing$,
|
||||
};
|
||||
}),
|
||||
),
|
||||
@@ -147,6 +162,10 @@ export class MuteState<Label, Selected> {
|
||||
this.data$.pipe(map(({ toggle }) => toggle)),
|
||||
);
|
||||
|
||||
public readonly syncing$: Behavior<boolean> = this.scope.behavior(
|
||||
this.data$.pipe(switchMap(({ syncing$ }) => syncing$)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly device: MediaDevice<Label, Selected>,
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types";
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneLandscapeLayout,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one layout with the given media.
|
||||
* Produces a one-on-one landscape layout with the given media.
|
||||
*/
|
||||
export function oneOnOneLayout(
|
||||
media: OneOnOneLayoutMedia,
|
||||
export function oneOnOneLandscapeLayout(
|
||||
media: OneOnOneLandscapeLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneLayout, TileStore] {
|
||||
): [OneOnOneLandscapeLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
update.registerGridTile(media.pip);
|
||||
update.registerGridTile(media.spotlight);
|
||||
const tiles = update.build();
|
||||
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
|
||||
pip: tiles.gridTilesByMedia.get(media.pip)!,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
43
src/state/OneOnOnePortraitLayout.ts
Normal file
43
src/state/OneOnOnePortraitLayout.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOnePortraitLayout,
|
||||
type OneOnOnePortraitLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one portrait layout with the given media.
|
||||
*/
|
||||
export function oneOnOnePortraitLayout(
|
||||
media: OneOnOnePortraitLayoutMedia,
|
||||
pipSize$: Behavior<"sm" | "lg">,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOnePortraitLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
update.registerSpotlight([media.spotlight], true);
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
return [
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
|
||||
pipSize$,
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
}
|
||||
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type SpotlightExpandedLayout,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
} from "./layout-types";
|
||||
@@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore";
|
||||
*/
|
||||
export function spotlightExpandedLayout(
|
||||
media: SpotlightExpandedLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
@@ -27,7 +31,8 @@ export function spotlightExpandedLayout(
|
||||
{
|
||||
type: media.type,
|
||||
spotlight: tiles.spotlightTile!,
|
||||
pip: tiles.gridTiles[0],
|
||||
pip: tiles.gridTiles.at(0),
|
||||
pipAlignment$,
|
||||
},
|
||||
tiles,
|
||||
];
|
||||
|
||||
49
src/state/ViewModel.ts
Normal file
49
src/state/ViewModel.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2026 Element Software Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
export type ViewModel<Snapshot> = {
|
||||
[K in keyof Snapshot as `${string & K}$`]: Behavior<Snapshot[K]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This allows to build a view model (or Partial view model)
|
||||
* with BehaviorSubjects.
|
||||
* It can be used in tests and for simplifying view model creation for non reactive snapshot parameters.
|
||||
*
|
||||
* @param snapshot The snapshot values this view model with start with. ({a: number, b: string})
|
||||
* @returns A view model: ({a$: BehaviroSubject<number>, b$: BehaviroSubject<string>}) (note the automatic addition of $ at the end of the keys)
|
||||
*/
|
||||
export function createStaticViewModel<Snapshot>(
|
||||
snapshot: Snapshot,
|
||||
): ViewModel<Snapshot> {
|
||||
const vm = {} as ViewModel<Snapshot>;
|
||||
for (const key in snapshot) {
|
||||
(vm as Record<string, Behavior<unknown>>)[`${key}$`] = new BehaviorSubject(
|
||||
snapshot[key],
|
||||
);
|
||||
}
|
||||
return vm;
|
||||
}
|
||||
|
||||
export function useStaticViewModel<Snapshot>(
|
||||
snapshot: Snapshot,
|
||||
): ViewModel<Snapshot> {
|
||||
const [vm] = useState(() => createStaticViewModel(snapshot));
|
||||
useEffect(() => {
|
||||
for (const key in snapshot) {
|
||||
(vm as unknown as Record<string, BehaviorSubject<unknown>>)[
|
||||
`${key}$`
|
||||
].next(snapshot[key]);
|
||||
}
|
||||
}, [snapshot, vm]);
|
||||
return vm;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
@@ -13,39 +15,53 @@ import {
|
||||
type GridTileViewModel,
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
edgeToEdge: false;
|
||||
spotlight?: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayoutMedia {
|
||||
type: "spotlight-landscape";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayoutMedia {
|
||||
type: "spotlight-portrait";
|
||||
edgeToEdge: false;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayoutMedia {
|
||||
type: "spotlight-expanded";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayoutMedia {
|
||||
type: "one-on-one";
|
||||
export interface OneOnOneLandscapeLayoutMedia {
|
||||
type: "one-on-one-landscape";
|
||||
edgeToEdge: false;
|
||||
spotlight: UserMediaViewModel;
|
||||
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayoutMedia {
|
||||
type: "one-on-one-portrait";
|
||||
edgeToEdge: true;
|
||||
spotlight: UserMediaViewModel | RingingMediaViewModel;
|
||||
pip?: LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayoutMedia {
|
||||
type: "pip";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
}
|
||||
|
||||
@@ -54,13 +70,20 @@ export type LayoutMedia =
|
||||
| SpotlightLandscapeLayoutMedia
|
||||
| SpotlightPortraitLayoutMedia
|
||||
| SpotlightExpandedLayoutMedia
|
||||
| OneOnOneLayoutMedia
|
||||
| OneOnOneLandscapeLayoutMedia
|
||||
| OneOnOnePortraitLayoutMedia
|
||||
| PipLayoutMedia;
|
||||
|
||||
export interface Alignment {
|
||||
inline: "start" | "end";
|
||||
block: "start" | "end";
|
||||
}
|
||||
|
||||
export interface GridLayout {
|
||||
type: "grid";
|
||||
spotlight?: SpotlightTileViewModel;
|
||||
grid: GridTileViewModel[];
|
||||
spotlightAlignment$: BehaviorSubject<Alignment>;
|
||||
setVisibleTiles: (value: number) => void;
|
||||
}
|
||||
|
||||
@@ -82,12 +105,22 @@ export interface SpotlightExpandedLayout {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayout {
|
||||
type: "one-on-one";
|
||||
export interface OneOnOneLandscapeLayout {
|
||||
type: "one-on-one-landscape";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayout {
|
||||
type: "one-on-one-portrait";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface PipLayout {
|
||||
@@ -104,5 +137,6 @@ export type Layout =
|
||||
| SpotlightLandscapeLayout
|
||||
| SpotlightPortraitLayout
|
||||
| SpotlightExpandedLayout
|
||||
| OneOnOneLayout
|
||||
| OneOnOneLandscapeLayout
|
||||
| OneOnOnePortraitLayout
|
||||
| PipLayout;
|
||||
|
||||
@@ -10,6 +10,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--hover-space-margin: var(--cpd-space-1x);
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
box-shadow: var(--draggable-shadow);
|
||||
}
|
||||
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
@@ -72,6 +73,10 @@ borders don't support gradients */
|
||||
}
|
||||
}
|
||||
|
||||
.tile.edgeToEdge {
|
||||
--media-view-border-radius: 0;
|
||||
}
|
||||
|
||||
.muteIcon[data-muted="true"] {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
@@ -109,6 +110,7 @@ test("GridTile displays ringing media", async () => {
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
|
||||
@@ -62,6 +62,7 @@ interface TileProps {
|
||||
targetHeight: number;
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
showNameTags: boolean;
|
||||
focusable: boolean;
|
||||
}
|
||||
|
||||
@@ -398,6 +399,7 @@ interface GridTileProps {
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
showSpeakingIndicators: boolean;
|
||||
showNameTags: boolean;
|
||||
focusable: boolean;
|
||||
}
|
||||
|
||||
@@ -419,9 +421,9 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
<RingingMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
{...props}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (media.local) {
|
||||
|
||||
@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
calc(var(--media-view-border-radius) - var(--cpd-space-3x))
|
||||
);
|
||||
padding: var(--fg-inset);
|
||||
transition: padding 0.3s;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
@@ -94,6 +95,12 @@ unconditionally select the container so we can use cqmin units */
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.fg {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nameTag {
|
||||
grid-area: nameTag;
|
||||
place-self: end start;
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("MediaView", () => {
|
||||
targetHeight: 200,
|
||||
mirror: false,
|
||||
unencryptedWarning: false,
|
||||
showNameTags: true,
|
||||
video: trackReference,
|
||||
userId: "@alice:example.com",
|
||||
mxcAvatarUrl: undefined,
|
||||
@@ -107,6 +108,16 @@ describe("MediaView", () => {
|
||||
expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("is shown and accessible even with name tag hidden", async () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} unencryptedWarning showNameTags={false} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
screen.getByRole("img", { name: "Not encrypted" });
|
||||
});
|
||||
|
||||
test("is not shown", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -44,6 +44,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
||||
showNameTags: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
@@ -72,6 +73,7 @@ export const MediaView: FC<Props> = ({
|
||||
userId,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
showNameTags,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
@@ -94,6 +96,23 @@ export const MediaView: FC<Props> = ({
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
|
||||
const warnings = unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
@@ -184,34 +203,23 @@ export const MediaView: FC<Props> = ({
|
||||
</Text>
|
||||
</div>
|
||||
)*/}
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
||||
{showNameTags && targetWidth >= 100 ? (
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{displayName}
|
||||
</Text>
|
||||
{warnings}
|
||||
</div>
|
||||
) : (
|
||||
warnings
|
||||
)}
|
||||
{primaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
@@ -10,6 +10,7 @@ Please see LICENSE in the repository root for full details.
|
||||
inline-size: 100%;
|
||||
display: flex;
|
||||
border-radius: var(--cpd-space-6x);
|
||||
box-shadow: var(--draggable-shadow);
|
||||
contain: strict;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -33,6 +34,14 @@ Please see LICENSE in the repository root for full details.
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.maximised .item {
|
||||
/* Ensure that foreground elements lie within the safe area */
|
||||
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
|
||||
calc(env(safe-area-inset-right) + 10px)
|
||||
calc(var(--call-view-safe-area-inset-bottom, 0px) + 10px)
|
||||
calc(env(safe-area-inset-left) + 10px);
|
||||
}
|
||||
|
||||
.item.snap {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
@@ -106,6 +107,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
focusable
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
@@ -135,6 +137,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
@@ -171,6 +174,7 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user