- {previewBar} - - ); + ); + // Reuse mx_RoomView_body so the timeline fills and the composer anchors + // to the bottom exactly like the main pane; mx_RoomView_chatColumn just + // adds the column framing. + chatColumn = ( +
{timelineBody}
+ ); + } else { + // Blap (Discord-style): keep chat in the main pane, render the call as + // a compact right-hand column instead of a full-pane takeover. + mainSplitContentClassName = "mx_MainSplit_call mx_MainSplit_callColumn"; + mainSplitBody = timelineBody; + callColumn = ( +
+ + +
+ ); + } } } const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); @@ -2714,6 +2783,26 @@ export class RoomView extends React.Component { this.state.mainSplitContentType === MainSplitContentType.Call ? "video_room" : "maximised_widget"; } + // Blap: work out what actually goes in the resizable side panel. + // - Normally the call column (or, during a screen share, the chat column). + // - But if the user opens a right-panel card (e.g. Room info) while a call + // column is showing, reveal that card instead — keeping the call mounted but + // hidden so it doesn't disconnect. Without this the info panel never renders, + // because the call column would otherwise always own the panel slot. + let panelNode: JSX.Element | undefined = callColumn ?? chatColumn ?? rightPanel; + let panelSizeKey = callColumn ? "blap-call" : chatColumn ? "blap-chat" : sizeKey; + let panelDefaultSize = callColumn ? 360 : chatColumn ? 400 : defaultSize; + if ((callColumn || chatColumn) && showRightPanel && rightPanel) { + panelNode = ( + <> +
{callColumn ?? chatColumn}
+ {rightPanel} + + ); + panelSizeKey = undefined; + panelDefaultSize = undefined; + } + const extraButtons: JSX.Element[] = []; for (const cb of ModuleApi.instance.extras.roomHeaderButtonsCallbacks) { const b = cb(this.state.room.roomId); @@ -2734,9 +2823,9 @@ export class RoomView extends React.Component { )}
{extraButtons} @@ -331,21 +310,8 @@ function RoomHeaderButtons({ )} - {showChatButton && } - - - { - evt.stopPropagation(); - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel); - PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt); - }} - aria-label={_t("common|threads")} - > - - - + {/* Blap: chat button is redundant (chat is always in the main pane), and the + threads button isn't wanted. Both removed. */} {notificationsEnabled && ( - {!isDirectMessage && ( - - { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); - e.stopPropagation(); - }} - aria-label={_t("common|n_members", { count: memberCount })} - > - {formatCount(memberCount)} - - - )} + {/* Blap: the member facepile ("who's in the room") is removed from the header. */} ); } diff --git a/apps/web/src/components/views/voip/BlapCallColumnHeader.tsx b/apps/web/src/components/views/voip/BlapCallColumnHeader.tsx new file mode 100644 index 0000000000..70dc0ed22c --- /dev/null +++ b/apps/web/src/components/views/voip/BlapCallColumnHeader.tsx @@ -0,0 +1,32 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { useCall, useParticipatingMembers } from "../../../hooks/useCall"; + +interface Props { + room: Room; +} + +/** + * Blap: the "IN THE CALL" header shown at the top of the call column in the room + * view, with a live participant count. Reuses the same MatrixRTC participation data + * as the room-list voice-member rows, so it reflects who is actually in the call. + */ +export function BlapCallColumnHeader({ room }: Props): JSX.Element | null { + const call = useCall(room.roomId); + const members = useParticipatingMembers(call); + if (members.length === 0) return null; + return ( +
+ In the call + {members.length} +
+ ); +} diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 38feaedc1c..74ab8edc5e 100644 --- a/apps/web/src/models/Call.ts +++ b/apps/web/src/models/Call.ts @@ -764,6 +764,15 @@ export class ElementCall extends Call { if (typeof opts.skipLobby === "boolean") { params.set("skipLobby", opts.skipLobby.toString()); } + // Blap: drop Element Call's own header — element-web draws its own room header, + // so EC's is redundant chrome. Step toward the compact call-column layout. + params.set("header", "none"); + // Blap: tell our Element Call fork to start with the camera off when the user + // has opted in (Settings -> Voice & Video). Read by calculateInitialMuteState + // in the EC build; the widget-action mute channel is unreliable across versions. + if (SettingsStore.getValue("blapJoinCameraOff")) { + params.set("blapVideoMuted", "true"); + } const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url"); if (rageshakeSubmitUrl && rageshakeSubmitUrl !== BugReportEndpointURLLocal) { @@ -894,6 +903,7 @@ export class ElementCall extends Call { // Some parameters may only be set once the user has chosen to interact with the call, regenerate the URL // at this point in case any of the parameters have changed. this.widgetGenerationParameters = { ...this.widgetGenerationParameters, ...widgetGenerationParameters }; + this.widget.url = ElementCall.generateWidgetUrl( this.client, this.roomId, @@ -984,14 +994,8 @@ export class ElementCall extends Call { ev.preventDefault(); this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setConnected(); - // Blap: optionally join with the camera muted. EC accepts a toWidget - // device_mute request ({ audio_enabled?, video_enabled? }); undefined - // fields are left as-is, so this only forces video off. - if (SettingsStore.getValue("blapJoinCameraOff")) { - this.widgetApi!.transport.send(ElementWidgetActions.DeviceMute, { video_enabled: false }).catch((e) => { - console.warn("Blap: failed to mute camera on join", e); - }); - } + // Blap: joining with the camera off is handled inside our Element Call fork + // (calculateInitialMuteState, via the blapVideoMuted URL flag). Nothing to do here. }; private readonly onHangup = async (ev: CustomEvent): Promise => { diff --git a/apps/web/src/shouldHideEvent.ts b/apps/web/src/shouldHideEvent.ts index 64a0e1f0f9..3b9d34b94c 100644 --- a/apps/web/src/shouldHideEvent.ts +++ b/apps/web/src/shouldHideEvent.ts @@ -53,6 +53,11 @@ export default function shouldHideEvent(ev: MatrixEvent, ctx?: IRoomState): bool // Hide all poll end events if (M_POLL_END.matches(ev.getType())) return true; + // Blap: the call is always visible as a right-hand column, so the per-call + // "Video call" notification pings (MSC4075 rtc.notification) in the timeline are + // redundant noise — hide them. + if (ev.getType() === EventType.RTCNotification) return true; + // Accessing the settings store directly can be expensive if done frequently, // so we should prefer using cached values if a RoomContext is available const isEnabled = ctx diff --git a/apps/web/webpack.config.ts b/apps/web/webpack.config.ts index 3017e1c6e8..4129786045 100644 --- a/apps/web/webpack.config.ts +++ b/apps/web/webpack.config.ts @@ -722,10 +722,16 @@ export default (env: string, argv: Record): webpack.Configuration = { from: "decoder-ring/**", context: path.resolve(__dirname, "res") }, { from: "media/**", context: path.resolve(__dirname, "res/") }, { from: "config.json", noErrorOnMissing: true }, - // Element Call embedded widget + // Element Call embedded widget. + // Blap: use our own Element Call fork build (sibling checkout) instead of the + // stock @element-hq/element-call-embedded package, so we control the call UI + // (reskin) and behaviour (e.g. join-with-camera-off). Falls back to the npm + // package if the fork build isn't present. { from: "**", - context: path.join(getPackageRoot("@element-hq/element-call-embedded"), "dist"), + context: fs.existsSync(path.resolve(__dirname, "../../../element-call/dist")) + ? path.resolve(__dirname, "../../../element-call/dist") + : path.join(getPackageRoot("@element-hq/element-call-embedded"), "dist"), to: path.join(__dirname, "webapp", "widgets", "element-call"), }, // Mobile guide assets