diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index c85cddf5..8d4233a1 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -42,7 +42,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: ${{ inputs.docker_tags}} @@ -51,7 +51,7 @@ jobs: uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 - name: Build and push Docker image - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index a0af77fc..ffd73d5e 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ work: experimental_features: # MSC3266: Room summary API. Used for knocking over federation msc3266_enabled: true + # MSC4222 needed for syncv2 state_after. This allow clients to + # correctly track the state of the room. + msc4222_enabled: true # The maximum allowed duration by which sent events can be delayed, as # per MSC4140. @@ -109,6 +112,10 @@ summary contains the room join rules. We need that to decide if the user gets prompted with the option to knock ("Request to join call"), a cannot join error or the join view. +MSC4222 allow clients to opt-in to a change of the sync v2 API that allows them +to correctly track the state of the room. This is required by Element Call to +track room state reliably. + Element Call requires a Livekit SFU alongside a [Livekit JWT service](https://github.com/element-hq/lk-jwt-service) to work. The url to the Livekit JWT service can either be configured in the config of Element Call @@ -213,7 +220,7 @@ To add a new translation key you can do these steps: 1. Add the new key entry to the code where the new key is used: `t("some_new_key")` 1. Run `yarn i18n` to extract the new key and update the translation files. This - will add a skeleton entry to the `locales/en-GB/app.json` file: + will add a skeleton entry to the `locales/en/app.json` file: ```jsonc { ... @@ -221,7 +228,7 @@ To add a new translation key you can do these steps: ... } ``` -1. Update the skeleton entry in the `locales/en-GB/app.json` file with +1. Update the skeleton entry in the `locales/en/app.json` file with the English translation: ```jsonc diff --git a/backend/dev_homeserver.yaml b/backend/dev_homeserver.yaml index b41de45b..5697c32e 100644 --- a/backend/dev_homeserver.yaml +++ b/backend/dev_homeserver.yaml @@ -25,6 +25,9 @@ trusted_key_servers: experimental_features: # MSC3266: Room summary API. Used for knocking over federation msc3266_enabled: true + # MSC4222 needed for syncv2 state_after. This allow clients to + # correctly track the state of the room. + msc4222_enabled: true # The maximum allowed duration by which sent events can be delayed, as # per MSC4140. Must be a positive value if set. Defaults to no diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index 7d71d727..3acf2b5e 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -21,7 +21,7 @@ export default { }, ], }, - locales: ["en-GB"], + locales: ["en"], output: "locales/$LOCALE/$NAMESPACE.json", input: ["src/**/*.{ts,tsx}"], sort: true, diff --git a/localazy.json b/localazy.json index 2b9f713c..823e4a3e 100644 --- a/localazy.json +++ b/localazy.json @@ -7,13 +7,13 @@ "features": ["plural_postfix_us", "filter_untranslated"], "files": [ { - "pattern": "locales/en-GB/*.json", + "pattern": "locales/en/*.json", "lang": "inherited" }, { "group": "existing", "pattern": "locales/*/*.json", - "excludes": ["locales/en-GB/*.json"], + "excludes": ["locales/en/*.json"], "lang": "${autodetectLang}" } ] @@ -25,9 +25,6 @@ "output": "locales/${langLsrDash}/${file}" } ], - "includeSourceLang": "${includeSourceLang|false}", - "langAliases": { - "en": "en_GB" - } + "includeSourceLang": "${includeSourceLang|false}" } } diff --git a/locales/en-GB/app.json b/locales/en/app.json similarity index 99% rename from locales/en-GB/app.json rename to locales/en/app.json index acc92741..94de7235 100644 --- a/locales/en-GB/app.json +++ b/locales/en/app.json @@ -195,6 +195,7 @@ "expand": "Expand", "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", - "volume": "Volume" + "volume": "Volume", + "waiting_for_media": "Waiting for media..." } } diff --git a/package.json b/package.json index ebaa59ff..71e3f9c9 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jsdom": "^21.1.7", "@types/lodash-es": "^4.17.12", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "@types/pako": "^2.0.3", "@types/qrcode": "^1.5.5", "@types/react-dom": "^18.3.0", @@ -63,7 +63,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@use-gesture/react": "^10.2.11", - "@vector-im/compound-design-tokens": "^1.9.1", + "@vector-im/compound-design-tokens": "^2.0.0", "@vector-im/compound-web": "^7.2.0", "@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-react": "^4.0.1", diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts index 4a8830da..3c65e620 100644 --- a/src/@types/i18next.d.ts +++ b/src/@types/i18next.d.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import "i18next"; // import all namespaces (for the default language, only) -import app from "../../locales/en-GB/app.json"; +import app from "../../locales/en/app.json"; declare module "i18next" { interface CustomTypeOptions { diff --git a/src/TranslatedError.ts b/src/TranslatedError.ts index 0dbe675a..420556be 100644 --- a/src/TranslatedError.ts +++ b/src/TranslatedError.ts @@ -22,7 +22,7 @@ export abstract class TranslatedError extends Error { messageKey: ParseKeys, translationFn: TFunction, ) { - super(translationFn(messageKey, { lng: "en-GB" } as TOptions)); + super(translationFn(messageKey, { lng: "en" } as TOptions)); this.translatedMessage = translationFn(messageKey); } } diff --git a/src/index.css b/src/index.css index bf6d1605..aeeccaf4 100644 --- a/src/index.css +++ b/src/index.css @@ -47,6 +47,11 @@ layer(compound); --background-gradient: url("graphics/backgroundGradient.svg"); } +:root, +[class*="cpd-theme-"] { + --video-tile-background: var(--cpd-color-bg-subtle-secondary); +} + .cpd-theme-dark { --cpd-color-border-accent: var(--cpd-color-green-1100); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); diff --git a/src/initializer.tsx b/src/initializer.tsx index 47634078..e9290504 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -24,7 +24,7 @@ import { platform } from "./Platform"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { -// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", +// "../locales/en/app.json": "/whatever/assets/root/locales/en-aabbcc.json", // ... // } const locales = import.meta.glob("../locales/*/*.json", { @@ -41,7 +41,7 @@ const getLocaleUrl = ( const supportedLngs = [ ...new Set( Object.keys(locales).map((url) => { - // The URLs are of the form ../locales/en-GB/app.json + // The URLs are of the form ../locales/en/app.json // This extracts the language code from the URL const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; if (!lang) { @@ -133,7 +133,7 @@ export class Initializer { .use(languageDetector) .use(initReactI18next) .init({ - fallbackLng: "en-GB", + fallbackLng: "en", defaultNS: "app", keySeparator: ".", nsSeparator: false, diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index 573807fe..89422af7 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -18,8 +18,7 @@ Please see LICENSE in the repository root for full details. width: 100%; height: 100%; object-fit: cover; - background-color: black; - background-color: var(--cpd-color-bg-subtle-primary); + background-color: var(--video-tile-background); } video.mirror { @@ -35,7 +34,7 @@ video.mirror { display: flex; justify-content: center; align-items: center; - background-color: var(--cpd-color-bg-subtle-secondary); + background-color: var(--video-tile-background); } .buttonBar { diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b10fc51d..5dbfb1ca 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -43,7 +43,6 @@ import { ECConnectionState, } from "../livekit/useECConnectionState"; import { E2eeType } from "../e2ee/e2eeType"; -import { showNonMemberTiles } from "../settings/settings"; vi.mock("@livekit/components-core"); @@ -637,49 +636,6 @@ test("participants must have a MatrixRTCSession to be visible", () => { }); }); -test("shows participants without MatrixRTCSession when enabled in settings", () => { - // enable the setting: - showNonMemberTiles.setValue(true); - withTestScheduler(({ hot, expectObservable }) => { - const scenarioInputMarbles = " abc"; - const expectedLayoutMarbles = "abc"; - - withCallViewModel( - hot(scenarioInputMarbles, { - a: [], - b: [aliceParticipant], - c: [aliceParticipant, bobParticipant], - }), - of([]), - of(ConnectionState.Connected), - new Map(), - (vm) => { - vm.setGridMode("grid"); - expectObservable(summarizeLayout(vm.layout)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: ["local:0"], - }, - b: { - type: "one-on-one", - local: "local:0", - remote: `${aliceId}:0`, - }, - c: { - type: "grid", - spotlight: undefined, - grid: ["local:0", `${aliceId}:0`, `${bobId}:0`], - }, - }, - ); - }, - ); - }); -}); - it("should show at least one tile per MatrixRTCSession", () => { withTestScheduler(({ hot, expectObservable }) => { // iterate through some combinations of MatrixRTC memberships diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 393a5893..95762c3f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -66,7 +66,7 @@ import { } from "./MediaViewModel"; import { accumulate, finalizeValue } from "../utils/observable"; import { ObservableScope } from "./ObservableScope"; -import { duplicateTiles, showNonMemberTiles } from "../settings/settings"; +import { duplicateTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; import { setPipEnabled } from "../controls"; import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel"; @@ -427,8 +427,6 @@ export class CallViewModel extends ViewModel { }, ); - private readonly nonMemberItemCount = new BehaviorSubject(0); - /** * List of MediaItems that we want to display */ @@ -443,7 +441,6 @@ export class CallViewModel extends ViewModel { this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged, ).pipe(startWith(null)), - showNonMemberTiles.value, ]).pipe( scan( ( @@ -453,7 +450,6 @@ export class CallViewModel extends ViewModel { { participant: localParticipant }, duplicateTiles, _membershipsChanged, - showNonMemberTiles, ], ) => { const newItems = new Map( @@ -491,17 +487,9 @@ export class CallViewModel extends ViewModel { } for (let i = 0; i < 1 + duplicateTiles; i++) { const indexedMediaId = `${livekitParticipantId}:${i}`; - let prevMedia = prevItems.get(indexedMediaId); + const prevMedia = prevItems.get(indexedMediaId); if (prevMedia && prevMedia instanceof UserMedia) { prevMedia.updateParticipant(participant); - if (prevMedia.vm.member === undefined) { - // We have a previous media created because of the `debugShowNonMember` flag. - // In this case we actually replace the media item. - // This "hack" never occurs if we do not use the `debugShowNonMember` debugging - // option and if we always find a room member for each rtc member (which also - // only fails if we have a fundamental problem) - prevMedia = undefined; - } } yield [ indexedMediaId, @@ -537,63 +525,7 @@ export class CallViewModel extends ViewModel { }.bind(this)(), ); - // Generate non member items (items without a corresponding MatrixRTC member) - // Those items should not be rendered, they are participants in livekit that do not have a corresponding - // matrix rtc members. This cannot be any good: - // - A malicious user impersonates someone - // - Someone injects abusive content - // - The user cannot have encryption keys so it makes no sense to participate - // We can only trust users that have a matrixRTC member event. - // - // This is still available as a debug option. This can be useful - // - If one wants to test scalability using the livekit cli. - // - If an experimental project does not yet do the matrixRTC bits. - // - If someone wants to debug if the LK connection works but matrixRTC room state failed to arrive. - const newNonMemberItems = showNonMemberTiles - ? new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - for (const participant of remoteParticipants) { - for (let i = 0; i < 1 + duplicateTiles; i++) { - const maybeNonMemberParticipantId = - participant.identity + ":" + i; - if (!newItems.has(maybeNonMemberParticipantId)) { - const nonMemberId = maybeNonMemberParticipantId; - yield [ - nonMemberId, - // We create UserMedia with or without a participant. - // This will be the initial value of a BehaviourSubject. - // Once a participant appears we will update the BehaviourSubject. (see above) - prevItems.get(nonMemberId) ?? - new UserMedia( - nonMemberId, - undefined, - participant, - this.encryptionSystem, - this.livekitRoom, - ), - ]; - } - } - } - }.bind(this)(), - ) - : new Map(); - if (newNonMemberItems.size > 0) { - logger.debug("Added NonMember items: ", newNonMemberItems); - } - - const newNonMemberItemCount = - newNonMemberItems.size / (1 + duplicateTiles); - if (this.nonMemberItemCount.value !== newNonMemberItemCount) - this.nonMemberItemCount.next(newNonMemberItemCount); - - const combinedNew = new Map([ - ...newNonMemberItems.entries(), - ...newItems.entries(), - ]); - - for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); - return combinedNew; + return newItems; }, new Map(), ), @@ -723,49 +655,42 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly pip: Observable = - this.screenShares.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) { - return this.spotlightSpeaker; - } + private readonly pip: Observable = combineLatest([ + this.screenShares, + this.spotlightSpeaker, + this.mediaItems, + ]).pipe( + switchMap(([screenShares, spotlight, mediaItems]) => { + if (screenShares.length > 0) { + return this.spotlightSpeaker; + } + if (!spotlight || spotlight.local) { + return of(null); + } - return this.spotlightSpeaker.pipe( - switchMap((speaker) => { - if (!speaker || speaker.local) { - return of(null); - } + const localUserMedia = mediaItems.find( + (m) => m.vm instanceof LocalUserMediaViewModel, + ) as UserMedia | undefined; - return this.mediaItems.pipe( - switchMap((mediaItems) => { - const localUserMedia = mediaItems.find( - (m) => m.vm instanceof LocalUserMediaViewModel, - ) as UserMedia | undefined; + const localUserMediaViewModel = localUserMedia?.vm as + | LocalUserMediaViewModel + | undefined; - const localUserMediaViewModel = localUserMedia?.vm as - | LocalUserMediaViewModel - | undefined; + if (!localUserMediaViewModel) { + return of(null); + } + return localUserMediaViewModel.alwaysShow.pipe( + map((alwaysShow) => { + if (alwaysShow) { + return localUserMediaViewModel; + } - if (!localUserMediaViewModel) { - return of(null); - } - - return localUserMediaViewModel.alwaysShow.pipe( - map((alwaysShow) => { - if (alwaysShow) { - return localUserMediaViewModel; - } - - return null; - }), - ); - }), - ); - }), - ); - }), - this.scope.state(), - ); + return null; + }), + ); + }), + this.scope.state(), + ); private readonly hasRemoteScreenShares: Observable = this.spotlight.pipe( @@ -878,15 +803,16 @@ export class CallViewModel extends ViewModel { this.mediaItems.pipe( map((mediaItems) => { if (mediaItems.length !== 2) return null; - const local = mediaItems.find((vm) => vm.vm.local)! - .vm as LocalUserMediaViewModel; + const local = mediaItems.find((vm) => vm.vm.local)?.vm as + | LocalUserMediaViewModel + | undefined; const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as | RemoteUserMediaViewModel | undefined; // There might not be a remote tile if there are screen shares, or if // only the local user is in the call and they're using the duplicate // tiles option - if (remote === undefined) return null; + if (!remote || !local) return null; return { type: "one-on-one", local, remote }; }), diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index dd7dfc50..70d6fead 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -15,7 +15,7 @@ Please see LICENSE in the repository root for full details. inline-size: 100%; block-size: 100%; object-fit: contain; - background-color: var(--cpd-color-bg-subtle-primary); + background-color: var(--video-tile-background); /* This transform is a no-op, but it forces Firefox to use a different rendering path, one that actually clips the corners of