Compare commits

..
Author SHA1 Message Date
Robin 1d7e2288f7 Fix call.element.dev not updating after branch rename 2026-09-09 08:49:08 +02:00
9 changed files with 1482 additions and 1629 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -39,7 +39,7 @@ Uncomment the markdown table below and fill in the last line:
## Checklist
- [ ] A linked, pre-approved issue exists for this feature or UI change.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
+5 -5
View File
@@ -6,7 +6,7 @@ on:
- opened
- labeled
push:
branches: [livekit, full-mesh]
branches: [main]
jobs:
build_full_element_call:
# Use the full package vite build
@@ -22,8 +22,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
# Deploy livekit branch to call.element.dev after build completes
if: github.ref == 'refs/heads/livekit'
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
runs-on: ubuntu-latest
steps:
@@ -43,8 +43,8 @@ jobs:
}
})
docker_for_develop:
# Build docker and publish docker for livekit branch after build completes
if: github.ref == 'refs/heads/livekit'
# Build docker and publish docker for main branch after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
permissions:
contents: write
-11
View File
@@ -39,17 +39,6 @@
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
+2 -2
View File
@@ -81,7 +81,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.11",
"@vitest/ui": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -101,7 +101,7 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.66.0",
"oxfmt": "^0.61.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"pako": "^2.0.4",
+1458 -1450
View File
File diff suppressed because it is too large Load Diff
@@ -516,12 +516,11 @@ describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const warn = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn,
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
new Connection(
@@ -546,7 +545,6 @@ describe("remote track logging", () => {
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
isEncrypted: true,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
@@ -566,27 +564,11 @@ describe("remote track logging", () => {
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111",
]);
// Encryption status changes are logged; cryptor errors are warnings
fakeLivekitRoom.emit(
RoomEvent.ParticipantEncryptionStatusChanged,
false,
bob,
);
expect(messages().at(-1)).toBe(
"Encryption status of @bob:example.org:DEV111: encrypted=false",
);
const cryptorError = new Error("missing key at index 3");
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
expect(warn).toHaveBeenCalledWith(
"Encryption error for @bob:example.org:DEV111:",
cryptorError,
);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
@@ -186,11 +186,8 @@ export class Connection {
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
// The encryption flag matters: if the publisher encrypts but this client
// believes the track is unencrypted, the cryptor is bypassed and raw
// ciphertext reaches the decoder (audible as loud noise bursts).
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
@@ -235,20 +232,6 @@ export class Connection {
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
const onEncryptionStatusChanged = (
encrypted: boolean,
p?: Participant,
): void =>
log.info(
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
);
// livekit-client throttles these per cryptor; they indicate frames being
// dropped (missing/invalid key).
const onEncryptionError = (error: Error, p?: Participant): void =>
log.warn(
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
error,
);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -260,12 +243,7 @@ export class Connection {
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.on(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.on(RoomEvent.EncryptionError, onEncryptionError);
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -277,12 +255,7 @@ export class Connection {
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.off(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.off(RoomEvent.EncryptionError, onEncryptionError);
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
});
}
-49
View File
@@ -9,12 +9,8 @@ import { expect, onTestFinished, test, vi } from "vitest";
import {
type LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
type RemoteAudioTrack,
type RemoteTrackPublication,
Track,
TrackEvent,
TrackPublication,
} from "livekit-client";
import { waitFor } from "@testing-library/dom";
@@ -27,7 +23,6 @@ import {
withTestScheduler,
mockRemoteParticipant,
mockRemoteScreenShare,
mockEmitter,
} from "../../utils/test";
import { constant } from "../Behavior";
@@ -165,50 +160,6 @@ test("control a participant's screen share volume", () => {
});
});
test("re-applies the playback volume when an audio element is attached", () => {
// A participant whose microphone track does not exist yet (they joined muted)
const track = mockEmitter<RemoteAudioTrack>() as unknown as RemoteAudioTrack;
let micPublication: Partial<RemoteTrackPublication> = {};
const setVolumeSpy = vi.fn();
const participant = mockRemoteParticipant({
setVolume: setVolumeSpy,
getTrackPublication: (source) =>
(source === Track.Source.Microphone
? micPublication
: {}) as RemoteTrackPublication,
});
const vm = mockRemoteMedia(rtcMembership, {}, participant);
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a-b-c|", {
a() {
vm.togglePlaybackMuted();
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
setVolumeSpy.mockClear();
},
b() {
// The participant unmutes: their track gets published and subscribed,
// then the renderer attaches an audio element to it
micPublication = { track };
participant.emit(
ParticipantEvent.TrackSubscriptionStatusChanged,
micPublication as RemoteTrackPublication,
TrackPublication.SubscriptionStatus.Subscribed,
);
expect(setVolumeSpy).not.toHaveBeenCalled();
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
setVolumeSpy.mockClear();
},
c() {
// ...and again for every further attach (e.g. a re-render)
track.emit(TrackEvent.ElementAttached, {} as HTMLMediaElement);
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
},
});
expectObservable(vm.playbackMuted$).toBe("ab", { a: false, b: true });
});
});
test("local media remembers whether it should always be shown", () => {
const vm1 = mockLocalMedia(
rtcMembership,
+8 -58
View File
@@ -6,18 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RemoteParticipant, Track, TrackEvent } from "livekit-client";
import { observeParticipantMedia } from "@livekit/components-core";
import {
combineLatest,
distinctUntilChanged,
fromEvent,
map,
NEVER,
of,
startWith,
switchMap,
} from "rxjs";
import { type RemoteParticipant } from "livekit-client";
import { combineLatest, map, of, switchMap } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
@@ -70,54 +60,14 @@ export function createRemoteUserMedia(
logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`);
});
// Emits whenever an audio element is attached to this participant's
// microphone track: a new subscription after they rejoin or reconnect, or
// their first unmute if they joined muted (EC only publishes the track
// then). The requested volume has to be applied again at that point.
// livekit-client stores the volume on the RemoteAudioTrack and re-applies
// it on attach, except that a stored volume of 0 is dropped by a truthiness
// check (RemoteAudioTrack.attach: `if (this.elementVolume)`), so "mute for
// me" would otherwise be lost as soon as a new track arrives.
const audioElementAttached$ = inputs.participant$.pipe(
switchMap((p) =>
p === null
? NEVER
: observeParticipantMedia(p).pipe(
map(() => p.getTrackPublication(Track.Source.Microphone)?.track),
distinctUntilChanged(),
switchMap((track) =>
track === undefined
? NEVER
: fromEvent(track, TrackEvent.ElementAttached),
),
),
),
);
const volumeControls = createVolumeControls(scope, {
pretendToBeDisconnected$,
sink$: scope.behavior(
combineLatest([
inputs.participant$,
audioElementAttached$.pipe(startWith(null)),
]).pipe(
map(([p, attached]) => (volume) => {
if (attached !== null)
logger.info(
`[RemoteUserMedia ${inputs.id}] re-applying playback volume ${volume} after audio element attach`,
);
p?.setVolume(volume);
}),
),
),
});
volumeControls.playbackMuted$.pipe(scope.bind()).subscribe((muted) => {
logger.info(`[RemoteUserMedia ${inputs.id}] playbackMuted=${muted}`);
});
return {
...baseUserMedia,
...volumeControls,
...createVolumeControls(scope, {
pretendToBeDisconnected$,
sink$: scope.behavior(
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
),
}),
local: false,
speaking$: scope.behavior(
pretendToBeDisconnected$.pipe(