Say outright whether the profile is ours to change

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 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-09-08 15:38:59 +02:00
co-authored by Claude Fable 5.1
parent f0359259f9
commit 7cd21476f7
7 changed files with 53 additions and 17 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ import { NEVER, Subject } from "rxjs";
import { import {
type DeviceMuteRequest, type DeviceMuteRequest,
type DeviceMuteState, type DeviceMuteState,
type HostBridge, type ElementCallHostBridge,
type HostRequest, type HostRequest,
} from "../index"; } from "../index";
@@ -19,7 +19,7 @@ import {
* so that the harness can watch both directions of the conversation between * so that the harness can watch both directions of the conversation between
* Element Call and its host. * Element Call and its host.
*/ */
export interface DevHostBridge extends HostBridge { export interface DevHostBridge extends ElementCallHostBridge {
/** Tells Element Call the host has changed theme. */ /** Tells Element Call the host has changed theme. */
requestTheme(name: string): void; requestTheme(name: string): void;
/** Tells Element Call to leave the call. */ /** Tells Element Call to leave the call. */
+21 -2
View File
@@ -100,6 +100,14 @@ export {
*/ */
export type ElementCallConfiguration = Partial<UrlParams>; export type ElementCallConfiguration = Partial<UrlParams>;
/**
* 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<HostBridge, "supportsProfileChanges">;
export interface ElementCallProps { export interface ElementCallProps {
/** /**
* The client to place the call with. Element Call does not authenticate * 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. * 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. * Without one, Element Call assumes it has no host to talk to.
*/ */
hostBridge?: HostBridge; hostBridge?: ElementCallHostBridge;
} }
/** /**
@@ -181,8 +189,19 @@ export const ElementCall: FC<ElementCallProps> = ({
roomId, roomId,
intent = UserIntent.JoinExistingCall, intent = UserIntent.JoinExistingCall,
config, config,
hostBridge = nullHostBridge, hostBridge: suppliedHostBridge = nullHostBridge,
}): ReactNode => { }): 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 // The container is what Element Call decorates and portals into, so nothing
// inside can render until we have it. // inside can render until we have it.
const [container, setContainer] = useState<HTMLDivElement | null>(null); const [container, setContainer] = useState<HTMLDivElement | null>(null);
+9
View File
@@ -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", () => { describe("supportsReactions", () => {
const capabilities = [ const capabilities = [
"org.matrix.msc2762.send.event:m.reaction", "org.matrix.msc2762.send.event:m.reaction",
@@ -278,6 +283,10 @@ describe("nullHostBridge", () => {
expect(nullHostBridge.close).toBeUndefined(); 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", () => { test("offers no media download, so Element Call uses its own client", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined(); expect(nullHostBridge.downloadMedia).toBeUndefined();
}); });
+15 -1
View File
@@ -96,8 +96,16 @@ export interface HostBridge {
/** The host wants to change, or read back, the device mute state. */ /** The host wants to change, or read back, the device mute state. */
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>; deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
// 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. */ /** Whether the host permits Element Call to send and receive reactions. */
readonly supportsReactions: boolean; readonly supportsReactions: boolean;
/** /**
@@ -122,6 +130,9 @@ export const nullHostBridge: HostBridge = {
join$: NEVER, join$: NEVER,
hangUp$: NEVER, hangUp$: NEVER,
deviceMute$: 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 // Standalone Element Call reaches the homeserver itself, so nothing is
// withholding these from it. // withholding these from it.
supportsReactions: true, supportsReactions: true,
@@ -176,6 +187,9 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
join$: requests(ElementWidgetActions.JoinCall), join$: requests(ElementWidgetActions.JoinCall),
hangUp$: requests(ElementWidgetActions.HangupCall), hangUp$: requests(ElementWidgetActions.HangupCall),
deviceMute$: requests(ElementWidgetActions.DeviceMute), 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. // 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 // Read on access rather than up front: the widget API negotiates its
// capabilities asynchronously, and the bridge is built before that settles. // capabilities asynchronously, and the bridge is built before that settles.
+2 -4
View File
@@ -116,10 +116,8 @@ export const GroupCallView: FC<Props> = ({
const hostBridge = useHostBridge(); const hostBridge = useHostBridge();
// A host that can close us is a host that decides when we stop existing, so // 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 // we neither show our own post-call screens nor assume we have time to
// finish what we are doing. // finish what we are doing. (Whose account the user's is, by contrast, is
// TODO: this reads a capability as a proxy for who owns our lifetime. Worth // stated outright: see `HostBridge.supportsProfileChanges`.)
// finding a more direct way to express it — see the guidance in UrlParams.ts
// on naming behaviours rather than situations.
const hostControlsLifetime = hostBridge.close !== undefined; const hostControlsLifetime = hostBridge.close !== undefined;
const muteAllAudio = useBehavior(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
+2 -5
View File
@@ -329,11 +329,8 @@ export const InCallView: FC<InCallViewProps> = ({
const openProfile = useMemo( const openProfile = useMemo(
() => () =>
// A host that can dismiss us is a host that owns the user's account, so // The profile is only ours to edit when the account is ours
// their profile is not ours to edit. hostBridge.supportsProfileChanges
// TODO: another use of the close capability as a proxy — see the note in
// GroupCallView.
hostBridge.close === undefined
? (): void => { ? (): void => {
setSettingsTab("profile"); setSettingsTab("profile");
setSettingsOpen(true); setSettingsOpen(true);
+2 -3
View File
@@ -235,9 +235,8 @@ export const SettingsModal: FC<Props> = ({
}; };
const tabs = [audioTab, videoTab]; const tabs = [audioTab, videoTab];
// A host that can dismiss us is a host that owns the user's account, so their // The profile is only ours to edit when the account is ours
// profile is not ours to edit. if (hostBridge.supportsProfileChanges) tabs.push(profileTab);
if (hostBridge.close === undefined) tabs.push(profileTab);
tabs.push(preferencesTab); tabs.push(preferencesTab);
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox // for full package we want to show the analytics consent checkbox