From 7cd21476f7542583ffae7552b48f26986f499d8b Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 15:38:59 +0200 Subject: [PATCH] Say outright whether the profile is ours to change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether to offer the profile settings was inferred from whether the host could close Element Call. For a component with no host bridge — the default — nothing could, so an embedded Element Call let the user edit the profile of an account that belongs to the host application. `HostBridge.supportsProfileChanges` states it directly: true standalone, where Element Call signed the user in itself; false for a widget's host and for anything embedding the component (which sets it itself, since the client it hands over is its own). The profile tab and the profile shortcut follow that. What a host's ability to close us still decides — what to show after the call ends — is a question about who owns our lifetime, and stays keyed on `close`. Co-Authored-By: Claude Fable 5.1 --- component/dev/DevHostBridge.ts | 4 ++-- component/index.tsx | 23 +++++++++++++++++++++-- src/HostBridge.test.ts | 9 +++++++++ src/HostBridge.ts | 16 +++++++++++++++- src/room/GroupCallView.tsx | 6 ++---- src/room/InCallView.tsx | 7 ++----- src/settings/SettingsModal.tsx | 5 ++--- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/component/dev/DevHostBridge.ts b/component/dev/DevHostBridge.ts index 417e4833a..837079203 100644 --- a/component/dev/DevHostBridge.ts +++ b/component/dev/DevHostBridge.ts @@ -10,7 +10,7 @@ import { NEVER, Subject } from "rxjs"; import { type DeviceMuteRequest, type DeviceMuteState, - type HostBridge, + type ElementCallHostBridge, type HostRequest, } from "../index"; @@ -19,7 +19,7 @@ import { * so that the harness can watch both directions of the conversation between * Element Call and its host. */ -export interface DevHostBridge extends HostBridge { +export interface DevHostBridge extends ElementCallHostBridge { /** Tells Element Call the host has changed theme. */ requestTheme(name: string): void; /** Tells Element Call to leave the call. */ diff --git a/component/index.tsx b/component/index.tsx index a64cee655..75420d0f1 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -100,6 +100,14 @@ export { */ export type ElementCallConfiguration = Partial; +/** + * What a host embedding Element Call implements to talk to it. This is the + * {@link HostBridge} less what Element Call already knows about such a host: + * the account is the host's, since the client is, so the profile is not + * Element Call's to change. + */ +export type ElementCallHostBridge = Omit; + export interface ElementCallProps { /** * The client to place the call with. Element Call does not authenticate @@ -132,7 +140,7 @@ export interface ElementCallProps { * joined or hung up, to be asked to keep the call on screen, and so on. * Without one, Element Call assumes it has no host to talk to. */ - hostBridge?: HostBridge; + hostBridge?: ElementCallHostBridge; } /** @@ -181,8 +189,19 @@ export const ElementCall: FC = ({ roomId, intent = UserIntent.JoinExistingCall, config, - hostBridge = nullHostBridge, + hostBridge: suppliedHostBridge = nullHostBridge, }): ReactNode => { + // Whatever the host says or does not say, the account is its own: it signed + // the user in and handed us the client. So Element Call never offers to edit + // the profile from inside a component. + const hostBridge = useMemo( + (): HostBridge => ({ + ...suppliedHostBridge, + supportsProfileChanges: false, + }), + [suppliedHostBridge], + ); + // The container is what Element Call decorates and portals into, so nothing // inside can render until we have it. const [container, setContainer] = useState(null); diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index 4ab42a135..41410ecae 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -247,6 +247,11 @@ describe("createWidgetHostBridge", () => { }); }); + test("does not offer profile changes, since the host signed the user in", () => { + const bridge = createWidgetHostBridge(mockWidget({})); + expect(bridge.supportsProfileChanges).toBe(false); + }); + describe("supportsReactions", () => { const capabilities = [ "org.matrix.msc2762.send.event:m.reaction", @@ -278,6 +283,10 @@ describe("nullHostBridge", () => { expect(nullHostBridge.close).toBeUndefined(); }); + test("supports profile changes, since Element Call signed the user in itself", () => { + expect(nullHostBridge.supportsProfileChanges).toBe(true); + }); + test("offers no media download, so Element Call uses its own client", () => { expect(nullHostBridge.downloadMedia).toBeUndefined(); }); diff --git a/src/HostBridge.ts b/src/HostBridge.ts index bb078672d..665866439 100644 --- a/src/HostBridge.ts +++ b/src/HostBridge.ts @@ -96,8 +96,16 @@ export interface HostBridge { /** The host wants to change, or read back, the device mute state. */ deviceMute$: Observable>; - // What the host is capable of. + // What the host is, and is capable of. + /** + * Whether Element Call may offer to change the user's profile — their + * display name and avatar. Only when the account is Element Call's own, + * which is to say standalone: a widget's host and an application embedding + * Element Call both signed the user in themselves, so the profile is theirs + * to manage and Element Call must not offer to edit it. + */ + readonly supportsProfileChanges: boolean; /** Whether the host permits Element Call to send and receive reactions. */ readonly supportsReactions: boolean; /** @@ -122,6 +130,9 @@ export const nullHostBridge: HostBridge = { join$: NEVER, hangUp$: NEVER, deviceMute$: NEVER, + // Standalone, the account is Element Call's own: it signed the user in, so + // it may offer to change the profile. + supportsProfileChanges: true, // Standalone Element Call reaches the homeserver itself, so nothing is // withholding these from it. supportsReactions: true, @@ -176,6 +187,9 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { join$: requests(ElementWidgetActions.JoinCall), hangUp$: requests(ElementWidgetActions.HangupCall), deviceMute$: requests(ElementWidgetActions.DeviceMute), + // The client we are a widget of signed the user in, so the profile is its + // to manage + supportsProfileChanges: false, // Element Call needs the host's permission to send reactions on its behalf. // Read on access rather than up front: the widget API negotiates its // capabilities asynchronously, and the bridge is built before that settles. diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 054ff450d..e3cbea32b 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -116,10 +116,8 @@ export const GroupCallView: FC = ({ const hostBridge = useHostBridge(); // A host that can close us is a host that decides when we stop existing, so // we neither show our own post-call screens nor assume we have time to - // finish what we are doing. - // TODO: this reads a capability as a proxy for who owns our lifetime. Worth - // finding a more direct way to express it — see the guidance in UrlParams.ts - // on naming behaviours rather than situations. + // finish what we are doing. (Whose account the user's is, by contrast, is + // stated outright: see `HostBridge.supportsProfileChanges`.) const hostControlsLifetime = hostBridge.close !== undefined; const muteAllAudio = useBehavior(muteAllAudio$); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e2df75295..9482d66c3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -329,11 +329,8 @@ export const InCallView: FC = ({ const openProfile = useMemo( () => - // A host that can dismiss us is a host that owns the user's account, so - // their profile is not ours to edit. - // TODO: another use of the close capability as a proxy — see the note in - // GroupCallView. - hostBridge.close === undefined + // The profile is only ours to edit when the account is ours + hostBridge.supportsProfileChanges ? (): void => { setSettingsTab("profile"); setSettingsOpen(true); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 35604d983..933ac66f5 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -235,9 +235,8 @@ export const SettingsModal: FC = ({ }; const tabs = [audioTab, videoTab]; - // A host that can dismiss us is a host that owns the user's account, so their - // profile is not ours to edit. - if (hostBridge.close === undefined) tabs.push(profileTab); + // The profile is only ours to edit when the account is ours + if (hostBridge.supportsProfileChanges) tabs.push(profileTab); tabs.push(preferencesTab); if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { // for full package we want to show the analytics consent checkbox