Blap: Discord-style room+call rework and Element Call swap

Ship our own Element Call fork build instead of the stock embedded package
(webpack copy repointed to ../element-call/dist, with npm-package fallback), and
rework the in-call room layout to match the Blap design:

- Chat stays in the main pane with the call as a compact right-hand column;
  when someone shares a screen the call takes prominence in the main pane and
  chat moves to the side column (never hidden). Driven by a same-origin
  screenshare signal from the EC fork.
- Camera-off: pass the `blapVideoMuted` flag to EC and drop the old (unreliable)
  widget-action mute code.
- Room list already lists in-call members under voice channels; add an
  "IN THE CALL" header to the call column.
- Hide redundant RTC call-notification ("Video call") pings from the timeline.
- Room header: remove the now-redundant chat, threads and member-facepile
  buttons; keep info working over the call (call stays mounted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:52:02 -07:00
parent 5ccd85c8a9
commit fc31bc4080
7 changed files with 239 additions and 91 deletions
+64
View File
@@ -1362,4 +1362,68 @@
.mx_AuthFooter a {
color: var(--yap-accent);
}
/* ============================================================
Blap call layout — call as a compact right-hand column
(chat stays in the main pane; see RoomView.tsx callColumn).
============================================================ */
.mx_RoomView_callColumn {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--yap-surface-roomlist);
overflow: hidden;
}
.mx_RoomView_callColumn .mx_CallView {
flex: 1;
min-height: 0;
background: transparent;
}
/* "IN THE CALL 5" header atop the call column. */
.mx_BlapCallColumnHeader {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--yap-text-muted);
}
.mx_BlapCallColumnHeader_count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--yap-accent-soft);
color: var(--yap-accent);
font-size: 11px;
letter-spacing: 0;
}
/* Keep the call mounted (so it doesn't disconnect) but out of view while a
right-panel card such as Room info is open over the call column. */
.mx_RoomView_callColumn_hidden {
display: none;
}
/* When a screen share takes the main pane, chat moves here (never hidden).
It carries mx_RoomView_body too, so the timeline/composer layout is inherited;
these rules just frame it as a full-height column. */
.mx_RoomView_chatColumn.mx_RoomView_body {
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--yap-surface-roomlist);
border-left: 1px solid var(--yap-hairline);
}
/* Give the call column a subtle divider from the chat pane. */
.mx_MainSplit_callColumn + .mx_RightPanel_ResizeWrapper,
.mx_RoomView_callColumn {
border-left: 1px solid var(--yap-hairline);
}
}
+115 -26
View File
@@ -87,6 +87,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from "../../effects/utils";
import { CHAT_EFFECTS } from "../../effects";
import { CallView } from "../views/voip/CallView";
import { BlapCallColumnHeader } from "../views/voip/BlapCallColumnHeader";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
@@ -234,6 +235,9 @@ export interface IRoomState {
*/
search?: SearchInfo;
callState?: CallState;
// Blap: true when someone in this room's call is screen sharing (signalled from
// our Element Call fork via localStorage). Drives the call-takes-prominence layout.
callScreenSharing?: boolean;
canPeek: boolean;
canSelfRedact: boolean;
showApps: boolean;
@@ -478,6 +482,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
membersLoaded: !llMembers,
numUnreadMessages: 0,
callState: undefined,
callScreenSharing: false,
canPeek: false,
canSelfRedact: false,
showApps: false,
@@ -647,6 +652,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const newState: Partial<IRoomState> = {
roomId: roomId ?? undefined,
// Blap: pick up any in-progress screenshare for the room we're switching to
// (storage events only fire on change, so read the current value directly).
callScreenSharing: this.readBlapScreenSharing(roomId),
roomAlias: roomAlias,
roomLoading: roomLoading,
roomLoadError,
@@ -1032,8 +1040,31 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.context.legacyCallHandler.on(LegacyCallHandlerEvent.CallState, this.onCallState);
window.addEventListener("beforeunload", this.onPageUnload);
// Blap: listen for screenshare state broadcast from our Element Call fork.
window.addEventListener("storage", this.onBlapScreenShareStorage);
}
// Blap: read whether someone is screen sharing in the given room's call.
private readBlapScreenSharing(roomId?: string | null): boolean {
if (!roomId) return false;
try {
return localStorage.getItem(`blap:screenshare:${roomId}`) === "1";
} catch {
return false;
}
}
private onBlapScreenShareStorage = (e: StorageEvent): void => {
const roomId = this.state.roomId;
if (!roomId) return;
// e.key is null when storage is cleared wholesale; otherwise filter to our key.
if (e.key !== null && e.key !== `blap:screenshare:${roomId}`) return;
const sharing = this.readBlapScreenSharing(roomId);
if (sharing !== Boolean(this.state.callScreenSharing)) {
this.setState({ callScreenSharing: sharing });
}
};
public shouldComponentUpdate(nextProps: IRoomProps, nextState: IRoomState): boolean {
const hasPropsDiff = objectHasDiff(this.props, nextProps);
@@ -1097,6 +1128,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
window.removeEventListener("beforeunload", this.onPageUnload);
window.removeEventListener("storage", this.onBlapScreenShareStorage);
this.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
@@ -2648,29 +2680,39 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const showChatEffects = SettingsStore.getValue("showChatEffects");
// Blap: the chat timeline body, reused both for normal Timeline mode and for
// the call layout (where chat stays in the main pane and the call moves to a
// compact right-hand column).
const timelineBody = (
<RoomUploadContextProvider>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses} data-testid="timeline">
<FileDropTarget parent={this.roomView.current} />
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
{searchResultsPanel}
</main>
{statusBarArea}
{previewBar}
{messageComposer}
</RoomUploadContextProvider>
);
let mainSplitBody: JSX.Element | undefined;
let mainSplitContentClassName: string | undefined;
// Blap: the call and chat swap between the main pane and a right-hand column
// depending on screenshare prominence — but chat is never hidden, it just moves
// to the column. Exactly one of these is set in call mode.
let callColumn: JSX.Element | undefined;
let chatColumn: JSX.Element | undefined;
// Decide what to show in the main split
switch (mainSplitContentType) {
case MainSplitContentType.Timeline:
mainSplitContentClassName = "mx_MainSplit_timeline";
mainSplitBody = (
<RoomUploadContextProvider>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses} data-testid="timeline">
<FileDropTarget parent={this.roomView.current} />
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
{searchResultsPanel}
</main>
{statusBarArea}
{previewBar}
{messageComposer}
</RoomUploadContextProvider>
);
mainSplitBody = timelineBody;
break;
case MainSplitContentType.MaximisedWidget:
mainSplitContentClassName = "mx_MainSplit_maximisedWidget";
@@ -2687,18 +2729,45 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
break;
case MainSplitContentType.Call: {
mainSplitContentClassName = "mx_MainSplit_call";
mainSplitBody = (
<>
// Blap: give the call the main pane while someone is sharing a screen;
// otherwise it stays a compact column so the chat keeps the main pane.
// (Our Element Call fork reliably clears the screenshare flag when the
// call is left, so no separate connection check is needed.)
if (this.state.callScreenSharing) {
// Blap: someone is screen sharing → the call takes prominence in the
// main pane, and the chat moves to the right-hand column (never hidden).
mainSplitContentClassName = "mx_MainSplit_call";
mainSplitBody = (
<CallView
room={this.state.room}
resizing={this.state.resizing}
role="main"
onClose={this.onCallClose}
/>
{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 = (
<div className="mx_RoomView_body mx_RoomView_chatColumn">{timelineBody}</div>
);
} 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 = (
<div className="mx_RoomView_callColumn">
<BlapCallColumnHeader room={this.state.room} />
<CallView
room={this.state.room}
resizing={this.state.resizing}
role="complementary"
onClose={this.onCallClose}
/>
</div>
);
}
}
}
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
@@ -2714,6 +2783,26 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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 = (
<>
<div className="mx_RoomView_callColumn_hidden">{callColumn ?? chatColumn}</div>
{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<IRoomProps, IRoomState> {
)}
<ErrorBoundary>
<MainSplit
panel={rightPanel}
sizeKey={sizeKey}
defaultSize={defaultSize}
panel={panelNode}
sizeKey={panelSizeKey}
defaultSize={panelDefaultSize}
analyticsRoomType={analyticsRoomType}
>
<div
@@ -12,7 +12,6 @@ import { Text, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/co
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
import CloseCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import RoomInfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info-solid";
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
@@ -28,32 +27,23 @@ import { useRoomName } from "../../../../hooks/useRoomName.ts";
import { useTopic } from "../../../../hooks/room/useTopic.ts";
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
import { _t } from "../../../../languageHandler.tsx";
import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx";
import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts";
import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts";
import { useFeatureEnabled } from "../../../../hooks/useSettings.ts";
import { useEncryptionStatus } from "../../../../hooks/useEncryptionStatus.ts";
import { E2EStatus } from "../../../../utils/ShieldUtils.ts";
import FacePile from "../../elements/FacePile.tsx";
import { useRoomState } from "../../../../hooks/useRoomState.ts";
import RoomAvatar from "../../avatars/RoomAvatar.tsx";
import { formatCount } from "../../../../utils/FormattingUtils.ts";
import RightPanelStore from "../../../../stores/right-panel/RightPanelStore.ts";
import PosthogTrackers from "../../../../PosthogTrackers.ts";
import { VideoRoomChatButton } from "./VideoRoomChatButton.tsx";
import { RoomKnocksBar } from "../RoomKnocksBar.tsx";
import { isVideoRoom as calcIsVideoRoom } from "../../../../utils/video-rooms.ts";
import { notificationLevelToIndicator } from "../../../../utils/notifications.ts";
import { CallGuestLinkButton } from "./CallGuestLinkButton.tsx";
import { type ButtonEvent } from "../../elements/AccessibleButton.tsx";
import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIndicator.tsx";
import { type IOOBData } from "../../../../stores/ThreepidInviteStore.ts";
import { MainSplitContentType } from "../../../structures/RoomView.tsx";
import defaultDispatcher from "../../../../dispatcher/dispatcher.ts";
import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx";
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx";
import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx";
import { LocalRoom } from "../../../../models/LocalRoom.ts";
@@ -68,9 +58,6 @@ function RoomHeaderButtons({
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
extraButtons?: JSX.Element;
}): JSX.Element {
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500, includeInvited: true });
const {
voiceCallDisabledReason,
voiceCallClick,
@@ -84,11 +71,8 @@ function RoomHeaderButtons({
showVoiceCallButton,
showVideoCallButton,
} = useRoomCall(room);
const threadNotifications = useRoomThreadNotifications(room);
const globalNotificationState = useGlobalNotificationState();
const dmMember = useDmMember(room);
const isDirectMessage = !!dmMember;
const notificationsEnabled = useFeatureEnabled("feature_notifications");
@@ -292,12 +276,7 @@ function RoomHeaderButtons({
voiceCallButton = undefined;
}
const roomContext = useScopedRoomContext("mainSplitContentType");
const isVideoRoom = calcIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
return (
<>
{extraButtons}
@@ -331,21 +310,8 @@ function RoomHeaderButtons({
</>
)}
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}
onClick={(evt) => {
evt.stopPropagation();
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.ThreadPanel);
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", evt);
}}
aria-label={_t("common|threads")}
>
<ToggleableIcon Icon={ThreadsIcon} phase={RightPanelPhases.ThreadPanel} />
</IconButton>
</Tooltip>
{/* Blap: chat button is redundant (chat is always in the main pane), and the
threads button isn't wanted. Both removed. */}
{notificationsEnabled && (
<Tooltip label={_t("notifications|enable_prompt_toast_title")}>
<IconButton
@@ -373,25 +339,7 @@ function RoomHeaderButtons({
</IconButton>
</Tooltip>
{!isDirectMessage && (
<Text as="div" size="sm" weight="medium">
<FacePile
className="mx_RoomHeader_members"
members={members.slice(0, 3)}
size="20px"
overflow={false}
viewUserOnClick={false}
tooltipLabel={_t("room|header_face_pile_tooltip")}
onClick={(e: ButtonEvent) => {
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList);
e.stopPropagation();
}}
aria-label={_t("common|n_members", { count: memberCount })}
>
{formatCount(memberCount)}
</FacePile>
</Text>
)}
{/* Blap: the member facepile ("who's in the room") is removed from the header. */}
</>
);
}
@@ -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 (
<div className="mx_BlapCallColumnHeader">
<span className="mx_BlapCallColumnHeader_label">In the call</span>
<span className="mx_BlapCallColumnHeader_count">{members.length}</span>
</div>
);
}
+12 -8
View File
@@ -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<IWidgetApiRequest>): Promise<void> => {
+5
View File
@@ -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
+8 -2
View File
@@ -722,10 +722,16 @@ export default (env: string, argv: Record<string, any>): 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