Merge branch 'toger5/move-settings-out-of-bottom-bar' into toger5/bottom-bar-storybook

This commit is contained in:
Timo K
2026-04-13 18:40:14 +02:00
14 changed files with 371 additions and 104 deletions

View File

@@ -271,7 +271,11 @@ describe("UrlParams", () => {
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "video",
});
});
it("accepts start_call_dm mobile", () => {
@@ -308,6 +312,29 @@ describe("UrlParams", () => {
),
).toMatchObject(joinExistingCallDefaults("desktop"));
});
it("accepts start_call_voice", () => {
expect(
computeUrlParams(
"?intent=start_call_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "audio",
});
});
it("accepts join_existing_voice", () => {
expect(
computeUrlParams(
"?intent=join_existing_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...joinExistingCallDefaults("desktop"),
callIntent: "audio",
});
});
});
describe("skipLobby", () => {

View File

@@ -31,6 +31,8 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallVoice = "start_call_voice",
JoinExistingCallVoice = "join_existing_voice",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
@@ -416,6 +418,15 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through

View File

@@ -27,9 +27,8 @@ Please see LICENSE in the repository root for full details.
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */
opacity: 1;
transition: opacity 0.15s;
}
@@ -38,6 +37,11 @@ Please see LICENSE in the repository root for full details.
display: grid;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the footer takes up no space in the layout
when hidden. */
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.footer.overlay:has(:focus-visible) {
@@ -72,7 +76,7 @@ Please see LICENSE in the repository root for full details.
}
/*First hide the logo*/
@media (max-width: 660px) {
@media (max-width: 750px) {
.logo {
display: none;
}

View File

@@ -108,8 +108,9 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -265,8 +266,9 @@ exports[`should have a close button in widget mode 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -418,8 +420,9 @@ exports[`should render the error page with link back to home 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -571,8 +574,9 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -724,8 +728,9 @@ exports[`should report correct error for 'Connection lost' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -881,8 +886,9 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -1029,8 +1035,9 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"

View File

@@ -17,7 +17,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<span
aria-label=""
class="_avatar_zysgz_8 roomAvatar _avatar-imageless_zysgz_55"
class="_avatar_7h2br_8 roomAvatar _avatar-imageless_7h2br_55"
data-color="1"
data-type="round"
role="img"
@@ -117,8 +117,9 @@ exports[`InCallView > rendering > renders 1`] = `
data-show="false"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"

View File

@@ -547,9 +547,7 @@ export function createCallViewModel$(
},
connectionManager,
matrixRTCSession,
localTransport$: scope.behavior(
localTransport$.pipe(switchMap((t) => t.advertised$)),
),
localTransport$,
logger: logger.getChild(`[${Date.now()}]`),
});

View File

@@ -33,13 +33,20 @@ import {
PublishState,
TrackState,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
initializeWidget();
@@ -214,7 +221,7 @@ describe("LocalMembership", () => {
};
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
@@ -230,11 +237,16 @@ describe("LocalMembership", () => {
),
};
const aLocalTransport: LocalTransport = {
advertised$: localTransport$,
active$: behavior("a", { a: null }),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$,
localTransport$: behavior("a", { a: aLocalTransport }),
});
localMembership.requestJoinAndPublish();
@@ -245,10 +257,62 @@ describe("LocalMembership", () => {
});
});
it("Should not publish to active transport if advertised has errors", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
null,
);
// Populate a connection for active
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportBConnected, []);
const mockConnectionManager = {
transports$: constant(new Epoch([bTransport])),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
};
const aLocalTransport: LocalTransport = {
advertised$,
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
};
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
return {} as unknown as Publisher;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$: behavior("a", { a: aLocalTransport }),
});
localMembership.requestJoinAndPublish();
expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
});
// Should not have created any publisher
expect(publisherFactory).toHaveBeenCalledTimes(0);
});
});
it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
};
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
@@ -264,7 +328,7 @@ describe("LocalMembership", () => {
leaveRoomSession: vi.fn(),
},
connectionManager: mockConnectionManager,
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)";
@@ -279,10 +343,31 @@ describe("LocalMembership", () => {
const aTransport = {
livekit_service_url: "a",
} as LivekitTransportConfig;
const aTransportWithSFUConfig = {
transport: aTransport,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransportWithSFUConfig;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransportConfig;
const bTransportWithSFUConfig = {
transport: bTransport,
sfuConfig: {
jwt: "foo2",
livekitAlias: "bar2",
livekitIdentity: "baz2",
url: "bro2",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
localParticipant: {
@@ -307,7 +392,11 @@ describe("LocalMembership", () => {
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: activeTransport$,
};
const publishers: Publisher[] = [];
let seed = 0;
@@ -343,10 +432,13 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
localTransport$.next(bTransport);
activeTransport$.next({
...aTransportWithSFUConfig,
transport: bTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
@@ -368,8 +460,6 @@ describe("LocalMembership", () => {
it("only start tracks if requested", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
@@ -396,6 +486,11 @@ describe("LocalMembership", () => {
typeof vi.fn
>;
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
};
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
@@ -405,7 +500,7 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
@@ -428,9 +523,15 @@ describe("LocalMembership", () => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
null,
);
const activeTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: activeTransport$,
};
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
);
@@ -470,14 +571,14 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$,
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(aTransport);
activeTransport$.next(aTransportWithSFUConfig);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,

View File

@@ -62,6 +62,8 @@ import {
} 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";
export enum TransportState {
/** Not even a transport is available to the LocalMembership */
@@ -127,7 +129,7 @@ interface Props {
createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LivekitTransportConfig | null>;
localTransport$: Behavior<LocalTransport>;
matrixRTCSession: Pick<
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
@@ -160,7 +162,7 @@ interface Props {
export const createLocalMembership$ = ({
scope,
connectionManager,
localTransport$: localTransportCanThrow$,
localTransport$,
homeserverConnected,
createPublisherFactory,
joinMatrixRTC,
@@ -205,23 +207,43 @@ export const createLocalMembership$ = ({
const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`);
// We consider error on the transport as fatal.
// Whether it is the active transport or the preferred transport.
const handleTransportError = (e: unknown): Observable<null> => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error from localTransport"),
);
}
setTransportError(error);
return of(null);
};
// This is the transport that we will advertise in our membership.
const advertisedTransport$ = localTransport$.pipe(
switchMap((lt) => lt.advertised$),
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const localTransport$ = scope.behavior(
localTransportCanThrow$.pipe(
catchError((e: unknown) => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error
? e
: new Error("Unknown error from localTransport"),
);
}
setTransportError(error);
return of(null);
const activeTransport$ = scope.behavior(
localTransport$.pipe(
switchMap((lt) => {
return combineLatest([lt.active$, lt.advertised$]).pipe(
map(([active, advertised]) => {
// Our policy is to not publish to another transport if our prefered transport is miss-configured
if (advertised == null) return null;
return active?.transport ?? null;
}),
);
}),
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
),
);
@@ -229,7 +251,7 @@ export const createLocalMembership$ = ({
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
activeTransport$,
]).pipe(
map(([{ value: connectionData }, localTransport]) => {
if (localTransport === null) {
@@ -398,7 +420,7 @@ export const createLocalMembership$ = ({
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([
localConnectionState$,
localTransport$,
activeTransport$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
@@ -537,9 +559,11 @@ export const createLocalMembership$ = ({
});
});
// Keep matrix rtc session in sync with localTransport$, connectRequested$
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
scope.reconcile(
scope.behavior(combineLatest([localTransport$, joinAndPublishRequested$])),
scope.behavior(
combineLatest([advertisedTransport$, joinAndPublishRequested$]),
),
async ([transport, shouldConnect]) => {
if (!transport) return;
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.

View File

@@ -106,7 +106,7 @@ export function isLocalTransportWithSFUConfig(
return "transport" in obj && "sfuConfig" in obj;
}
interface LocalTransport {
export interface LocalTransport {
/**
* The transport to be advertised in our MatrixRTC membership. `null` when not
* yet fetched/validated.

View File

@@ -82,9 +82,9 @@ unconditionally select the container so we can use cqmin units */
);
inset: var(--fg-inset);
display: grid;
grid-template-columns: 30px 1fr 30px;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas: "status status reactions" "nameTag nameTag button";
grid-template-areas: "status reactions" "nameTag button";
gap: var(--cpd-space-1x);
place-items: start;
}
@@ -125,6 +125,7 @@ unconditionally select the container so we can use cqmin units */
.reactions {
grid-area: reactions;
place-self: start end;
display: flex;
gap: var(--cpd-space-1x);
}
@@ -192,4 +193,5 @@ unconditionally select the container so we can use cqmin units */
.fg > button:first-of-type {
grid-area: button;
place-self: end;
}