From bc6caddcc8bfe7948c93db1f0662295976891c97 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 9 Mar 2026 14:04:48 +1100
Subject: [PATCH 01/14] Add own control buttons for element-call (#2744)
* add mutation observer hok
* add hook to read speaking member by observing iframe content
* display speaking member name in call status bar and improve layout
* fix shrining
* add joined call control bar
* remove chat toggle from room header
* change member speaking icon to mic
* fix joined call control appear in other
* show spinner on end call button
* hide call statusbar for mobile view when room is selected
* make call statusbar more mobile friendly
* fix call status bar item align
---
src/app/components/CallEmbedProvider.tsx | 1 +
src/app/features/call-status/CallControl.tsx | 54 +++--
src/app/features/call-status/CallStatus.tsx | 30 +--
src/app/features/call-status/LiveChip.tsx | 2 +-
.../features/call-status/MemberSpeaking.tsx | 2 +-
src/app/features/call/CallControls.tsx | 203 ++++++++++++++++++
src/app/features/call/CallView.tsx | 95 ++++----
src/app/features/call/Controls.tsx | 32 +++
src/app/features/call/PrescreenControls.tsx | 3 +-
src/app/features/call/styles.css.ts | 4 +
src/app/features/room-nav/RoomNavItem.tsx | 3 +-
src/app/features/room/RoomViewHeader.tsx | 22 --
src/app/hooks/useCallEmbed.ts | 9 +-
src/app/pages/CallStatusRenderer.tsx | 7 +
src/app/plugins/call/CallControl.ts | 124 ++++++++++-
src/app/plugins/call/CallControlState.ts | 14 +-
src/app/plugins/call/CallEmbed.ts | 16 ++
17 files changed, 521 insertions(+), 100 deletions(-)
create mode 100644 src/app/features/call/CallControls.tsx
diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index a78c210b5..b50b1f504 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -1,5 +1,6 @@
import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
+import { config } from 'folds';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 097d00c9a..2f2bac7fb 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -1,7 +1,8 @@
-import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
-import React, { useState } from 'react';
+import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
+import React, { useCallback } from 'react';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -104,9 +105,11 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
-function ScreenShareButton() {
- const [enabled, setEnabled] = useState(false);
-
+type ScreenShareButtonProps = {
+ enabled: boolean;
+ onToggle: () => void;
+};
+function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
setEnabled(!enabled)}
+ onClick={onToggle}
outlined
>
@@ -133,8 +136,14 @@ function ScreenShareButton() {
);
}
-export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
- const { microphone, video, sound } = useCallControlState(callEmbed.control);
+export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) {
+ const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
+
+ const [hangupState, hangup] = useAsyncCallback(
+ useCallback(() => callEmbed.hangup(), [callEmbed])
+ );
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
return (
@@ -144,21 +153,36 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
callEmbed.control.toggleSound()} />
+ {!compact && }
callEmbed.control.toggleVideo()} />
- {false && }
+ {!compact && (
+ callEmbed.control.toggleScreenshare()}
+ />
+ )}
}
+ before={
+ exiting ? (
+
+ ) : (
+
+ )
+ }
+ disabled={exiting}
outlined
- onClick={() => callEmbed.hangup()}
+ onClick={hangup}
>
-
- End
-
+ {!compact && (
+
+ End
+
+ )}
);
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index 2b8b809fb..5d2182c2c 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -47,18 +47,17 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
) : (
)}
-
-
- {speakers.size > 0 && !compact && (
+
+ {!compact && (
<>
-
-
-
+
+ {speakers.size > 0 && (
+ <>
+
+
+
+ >
+ )}
>
)}
@@ -69,8 +68,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
)}
{memberVisible && !compact && }
-
-
+
+ {compact && (
+
+
+
+ )}
+
);
diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx
index 34167fb60..a5d00a553 100644
--- a/src/app/features/call-status/LiveChip.tsx
+++ b/src/app/features/call-status/LiveChip.tsx
@@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
radii="Pill"
onClick={handleOpenMenu}
>
-
+
{count} Live
diff --git a/src/app/features/call-status/MemberSpeaking.tsx b/src/app/features/call-status/MemberSpeaking.tsx
index ddde7e6e7..27e272f21 100644
--- a/src/app/features/call-status/MemberSpeaking.tsx
+++ b/src/app/features/call-status/MemberSpeaking.tsx
@@ -14,7 +14,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
);
return (
-
+
{speakingNames.length === 1 && (
<>
diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx
new file mode 100644
index 000000000..72edc57f3
--- /dev/null
+++ b/src/app/features/call/CallControls.tsx
@@ -0,0 +1,203 @@
+import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
+import {
+ Box,
+ Button,
+ config,
+ Icon,
+ IconButton,
+ Icons,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../components/sequence-card';
+import * as css from './styles.css';
+import {
+ ChatButton,
+ ControlDivider,
+ MicrophoneButton,
+ ScreenShareButton,
+ SoundButton,
+ VideoButton,
+} from './Controls';
+import { CallEmbed, useCallControlState } from '../../plugins/call';
+import { useResizeObserver } from '../../hooks/useResizeObserver';
+import { stopPropagation } from '../../utils/keyboard';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type CallControlsProps = {
+ callEmbed: CallEmbed;
+};
+export function CallControls({ callEmbed }: CallControlsProps) {
+ const controlRef = useRef(null);
+ const [compact, setCompact] = useState(document.body.clientWidth < 500);
+
+ useResizeObserver(
+ useCallback(() => {
+ const element = controlRef.current;
+ if (!element) return;
+ setCompact(element.clientWidth < 500);
+ }, []),
+ useCallback(() => controlRef.current, [])
+ );
+
+ const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
+ callEmbed.control
+ );
+
+ const [cords, setCords] = useState();
+
+ const handleOpenMenu: MouseEventHandler = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleSpotlightClick = () => {
+ callEmbed.control.toggleSpotlight();
+ setCords(undefined);
+ };
+
+ const handleReactionsClick = () => {
+ callEmbed.control.toggleReactions();
+ setCords(undefined);
+ };
+
+ const handleSettingsClick = () => {
+ callEmbed.control.toggleSettings();
+ setCords(undefined);
+ };
+
+ const [hangupState, hangup] = useAsyncCallback(
+ useCallback(() => callEmbed.hangup(), [callEmbed])
+ );
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
+
+ return (
+
+
+
+
+ callEmbed.control.toggleMicrophone()}
+ />
+ callEmbed.control.toggleSound()} />
+
+ {!compact && }
+
+ callEmbed.control.toggleVideo()} />
+ callEmbed.control.toggleScreenshare()}
+ />
+
+
+ {!compact && }
+
+
+
+ setCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ >
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={exiting}
+ >
+ End
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index a8923d933..0cddd2be2 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from 'react';
+import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -12,6 +12,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
+import { CallControls } from './CallControls';
function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) {
if (hasParticipant) return null;
@@ -39,13 +40,10 @@ function AlreadyInCallMessage() {
);
}
-export function CallView() {
+function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
- const callViewRef = useRef(null);
- useCallEmbedPlacementSync(callViewRef);
-
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -57,49 +55,70 @@ export function CallView() {
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
- const callJoined = useCallJoined(callEmbed);
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
+ return (
+
+
+
+ {hasParticipant && (
+
+
+ Participant
+
+
+
+ {callMembers.length} Live
+
+
+
+ )}
+
+
+
+ {!inOtherCall &&
+ (canJoin ? : )}
+ {inOtherCall && }
+
+
+
+
+ );
+}
+
+type CallJoinedProps = {
+ containerRef: RefObject;
+ joined: boolean;
+};
+function CallJoined({ joined, containerRef }: CallJoinedProps) {
+ const callEmbed = useCallEmbed();
+
+ return (
+
+
+ {callEmbed && joined && }
+
+ );
+}
+
+export function CallView() {
+ const room = useRoom();
+ const callContainerRef = useRef(null);
+ useCallEmbedPlacementSync(callContainerRef);
+
+ const callEmbed = useCallEmbed();
+ const callJoined = useCallJoined(callEmbed);
+
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
- {!currentJoined && (
-
-
-
- {hasParticipant && (
-
-
- Participant
-
-
-
- {callMembers.length} Live
-
-
-
- )}
-
-
-
- {!inOtherCall &&
- (canJoin ? (
-
- ) : (
-
- ))}
- {inOtherCall && }
-
-
-
-
- )}
+ {!currentJoined && }
+
);
}
diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx
index 39c5d87d9..143a80226 100644
--- a/src/app/features/call/Controls.tsx
+++ b/src/app/features/call/Controls.tsx
@@ -114,6 +114,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
+type ScreenShareButtonProps = {
+ enabled: boolean;
+ onToggle: () => void;
+};
+export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
+ return (
+
+ {enabled ? 'Stop Screenshare' : 'Start Screenshare'}
+
+ }
+ >
+ {(anchorRef) => (
+ onToggle()}
+ outlined
+ >
+
+
+ )}
+
+ );
+}
+
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);
diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx
index 4782c9b67..1174bbf1c 100644
--- a/src/app/features/call/PrescreenControls.tsx
+++ b/src/app/features/call/PrescreenControls.tsx
@@ -6,7 +6,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
-import { CallControlState } from '../../plugins/call/CallControlState';
type PrescreenControlsProps = {
canJoin?: boolean;
@@ -50,7 +49,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
)}
- {callView && (
-
- Chat
-
- }
- >
- {(triggerRef) => (
- setChat(!chat)}>
-
-
- )}
-
- )}
-
(undefined);
@@ -41,7 +42,7 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
- controlState?: CallControlState
+ pref?: CallPreferences
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
@@ -49,6 +50,8 @@ export const createCallEmbed = (
const intent = CallEmbed.getIntent(dm, ongoing);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
+ const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
+
const embed = new CallEmbed(mx, room, widget, container, controlState);
return embed;
@@ -61,12 +64,12 @@ export const useCallStart = (dm = false) => {
const callEmbedRef = useCallEmbedRef();
const startCall = useCallback(
- (room: Room, controlState?: CallControlState) => {
+ (room: Room, pref?: CallPreferences) => {
const container = callEmbedRef.current;
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
- const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, controlState);
+ const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
setCallEmbed(callEmbed);
},
diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx
index f8e38054c..2836886e9 100644
--- a/src/app/pages/CallStatusRenderer.tsx
+++ b/src/app/pages/CallStatusRenderer.tsx
@@ -1,11 +1,18 @@
import React from 'react';
import { useCallEmbed } from '../hooks/useCallEmbed';
import { CallStatus } from '../features/call-status';
+import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
+import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
export function CallStatusRenderer() {
const callEmbed = useCallEmbed();
+ const selectedRoom = useSelectedRoom();
+
+ const screenSize = useScreenSizeContext();
if (!callEmbed) return null;
+ if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
+
return ;
}
diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts
index 44dcacd7f..f4162d73b 100644
--- a/src/app/plugins/call/CallControl.ts
+++ b/src/app/plugins/call/CallControl.ts
@@ -14,12 +14,58 @@ export class CallControl extends EventEmitter implements CallControlState {
private iframe: HTMLIFrameElement;
+ private controlMutationObserver: MutationObserver;
+
+ private get document(): Document | undefined {
+ return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
+ }
+
+ private get screenshareButton(): HTMLElement | undefined {
+ const screenshareBtn = this.document?.querySelector(
+ '[data-testid="incall_screenshare"]'
+ ) as HTMLElement | null;
+
+ return screenshareBtn ?? undefined;
+ }
+
+ private get settingsButton(): HTMLElement | undefined {
+ const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
+
+ const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
+
+ return settingsButton ?? undefined;
+ }
+
+ private get reactionsButton(): HTMLElement | undefined {
+ const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
+
+ return reactionsButton ?? undefined;
+ }
+
+ private get spotlightButton(): HTMLInputElement | undefined {
+ const spotlightButton = this.document?.querySelector(
+ 'input[value="spotlight"]'
+ ) as HTMLInputElement | null;
+
+ return spotlightButton ?? undefined;
+ }
+
+ private get gridButton(): HTMLInputElement | undefined {
+ const gridButton = this.document?.querySelector(
+ 'input[value="grid"]'
+ ) as HTMLInputElement | null;
+
+ return gridButton ?? undefined;
+ }
+
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super();
this.state = state;
this.call = call;
this.iframe = iframe;
+
+ this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
}
public getState(): CallControlState {
@@ -38,6 +84,14 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.state.sound;
}
+ public get screenshare(): boolean {
+ return this.state.screenshare;
+ }
+
+ public get spotlight(): boolean {
+ return this.state.spotlight;
+ }
+
public async applyState() {
await this.setMediaState({
audio_enabled: this.microphone,
@@ -47,6 +101,26 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate();
}
+ public startObserving() {
+ this.controlMutationObserver.disconnect();
+
+ const screenshareBtn = this.screenshareButton;
+ if (screenshareBtn) {
+ this.controlMutationObserver.observe(screenshareBtn, {
+ attributes: true,
+ attributeFilter: ['data-kind'],
+ });
+ }
+ const spotlightBtn = this.spotlightButton;
+ if (spotlightBtn) {
+ this.controlMutationObserver.observe(spotlightBtn, {
+ attributes: true,
+ });
+ }
+
+ this.onControlMutation();
+ }
+
public applySound() {
this.setSound(this.sound);
}
@@ -72,7 +146,9 @@ export class CallControl extends EventEmitter implements CallControlState {
const state = new CallControlState(
data.audio_enabled ?? this.microphone,
data.video_enabled ?? this.video,
- this.sound
+ this.sound,
+ this.screenshare,
+ this.spotlight
);
this.state = state;
@@ -83,6 +159,20 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
+ public onControlMutation() {
+ const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
+ const spotlight: boolean = this.spotlightButton?.checked ?? false;
+
+ this.state = new CallControlState(
+ this.microphone,
+ this.video,
+ this.sound,
+ screenshare,
+ spotlight
+ );
+ this.emitStateUpdate();
+ }
+
public toggleMicrophone() {
const payload: ElementMediaStatePayload = {
audio_enabled: !this.microphone,
@@ -104,7 +194,13 @@ export class CallControl extends EventEmitter implements CallControlState {
this.setSound(sound);
- const state = new CallControlState(this.microphone, this.video, sound);
+ const state = new CallControlState(
+ this.microphone,
+ this.video,
+ sound,
+ this.screenshare,
+ this.spotlight
+ );
this.state = state;
this.emitStateUpdate();
@@ -113,6 +209,30 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
+ public toggleScreenshare() {
+ this.screenshareButton?.click();
+ }
+
+ public toggleSpotlight() {
+ if (this.spotlight) {
+ this.gridButton?.click();
+ return;
+ }
+ this.spotlightButton?.click();
+ }
+
+ public toggleReactions() {
+ this.reactionsButton?.click();
+ }
+
+ public toggleSettings() {
+ this.settingsButton?.click();
+ }
+
+ public dispose() {
+ this.controlMutationObserver.disconnect();
+ }
+
private emitStateUpdate() {
this.emit(CallControlEvent.StateUpdate);
}
diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts
index 42f0b1965..fc7e3f22b 100644
--- a/src/app/plugins/call/CallControlState.ts
+++ b/src/app/plugins/call/CallControlState.ts
@@ -5,9 +5,21 @@ export class CallControlState {
public readonly sound: boolean;
- constructor(microphone: boolean, video: boolean, sound: boolean) {
+ public readonly screenshare: boolean;
+
+ public readonly spotlight: boolean;
+
+ constructor(
+ microphone: boolean,
+ video: boolean,
+ sound: boolean,
+ screenshare = false,
+ spotlight = false
+ ) {
this.microphone = microphone;
this.video = video;
this.sound = sound;
+ this.screenshare = screenshare;
+ this.spotlight = spotlight;
}
}
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index f01cd685e..aeb28e360 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -220,6 +220,7 @@ export class CallEmbed {
});
this.call.stop();
this.container.removeChild(this.iframe);
+ this.control.dispose();
this.mx.off(ClientEvent.Event, this.onEvent.bind(this));
this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this));
@@ -233,6 +234,21 @@ export class CallEmbed {
private onCallJoined(): void {
this.joined = true;
+ this.applyStyles();
+ this.control.startObserving();
+ }
+
+ private applyStyles(): void {
+ const doc = this.document;
+ if (!doc) return;
+
+ doc.body.style.setProperty('background', 'none', 'important');
+ const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement
+ ?.parentElement;
+ if (controls) {
+ controls.style.setProperty('position', 'absolute');
+ controls.style.setProperty('visibility', 'hidden');
+ }
}
private onEvent(ev: MatrixEvent): void {
From d679e68501aef1d731555311091a515ff5b8362d Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 9 Mar 2026 17:34:44 +1100
Subject: [PATCH 02/14] Fix recent emoji does not persist (#2722)
Fix recent emoji are not getting saved
Refactor recent emoji retrieval to ensure structured cloning and proper type checking. The sdk was not updating account data because we are mutating the original and it compare and early return if found same.
---
src/app/plugins/recent-emoji.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/app/plugins/recent-emoji.ts b/src/app/plugins/recent-emoji.ts
index 3634538fb..811ed9d5a 100644
--- a/src/app/plugins/recent-emoji.ts
+++ b/src/app/plugins/recent-emoji.ts
@@ -27,7 +27,11 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
export function addRecentEmoji(mx: MatrixClient, unicode: string) {
const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji);
- const recentEmoji = recentEmojiEvent?.getContent().recent_emoji ?? [];
+ const recentEmojiContent = recentEmojiEvent?.getContent();
+ const recentEmoji =
+ recentEmojiContent && Array.isArray(recentEmojiContent.recent_emoji)
+ ? structuredClone(recentEmojiContent.recent_emoji)
+ : [];
const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode);
let entry: [EmojiUnicode, EmojiUsageCount];
From 2eb5a9a616a7de6f0637dcfa2660629ff6d57433 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 9 Mar 2026 18:17:15 +1100
Subject: [PATCH 03/14] Fix crash with bad location uri (#2746)
fix crash with bad location uri
---
.../components/message/MsgTypeRenderers.tsx | 2 ++
src/app/utils/common.ts | 22 +++++++++++++------
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index a40ecae1e..abbf354de 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -389,6 +389,8 @@ export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return ;
const location = parseGeoUri(geoUri);
+ if (!location) return ;
+
return (
{geoUri}
diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts
index 678f1b6ef..6bda28021 100644
--- a/src/app/utils/common.ts
+++ b/src/app/utils/common.ts
@@ -87,13 +87,21 @@ export const scaleYDimension = (x: number, scaledX: number, y: number): number =
};
export const parseGeoUri = (location: string) => {
- const [, data] = location.split(':');
- const [cords] = data.split(';');
- const [latitude, longitude] = cords.split(',');
- return {
- latitude,
- longitude,
- };
+ try {
+ const [, data] = location.split(':');
+ const [cords] = data.split(';');
+ const [latitude, longitude] = cords.split(',');
+
+ if (typeof latitude === 'string' && typeof longitude === 'string') {
+ return {
+ latitude,
+ longitude,
+ };
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ }
};
const START_SLASHES_REG = /^\/+/g;
From 4449e7c6e820efe7a50b5964fd77714a1bd93d28 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 9 Mar 2026 21:39:58 +1100
Subject: [PATCH 04/14] Show call support error and disable join button (#2748)
* allow user to end call if error when loading
* show call support missing error if livekit server is not provided
* prevent joining from nav item double click if no livekit support
---
src/app/cs-api.ts | 10 +++
src/app/features/call-status/CallControl.tsx | 53 +++++++++--
src/app/features/call-status/CallStatus.tsx | 2 +-
src/app/features/call/CallView.tsx | 32 ++++++-
src/app/features/room-nav/RoomNavItem.tsx | 8 ++
src/app/hooks/useLivekitSupport.ts | 16 ++++
src/app/pages/client/AutoDiscovery.tsx | 32 +++++++
src/app/pages/client/ClientRoot.tsx | 95 +++++++++++---------
src/app/plugins/call/CallEmbed.ts | 34 +++++--
9 files changed, 220 insertions(+), 62 deletions(-)
create mode 100644 src/app/hooks/useLivekitSupport.ts
create mode 100644 src/app/pages/client/AutoDiscovery.tsx
diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts
index b9aac06ab..95a131a85 100644
--- a/src/app/cs-api.ts
+++ b/src/app/cs-api.ts
@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record & {
'm.identity_server'?: {
base_url: string;
};
+ 'org.matrix.msc2965.authentication'?: {
+ account?: string;
+ issuer?: string;
+ };
+ 'org.matrix.msc4143.rtc_foci'?: [
+ {
+ livekit_service_url: string;
+ type: 'livekit';
+ }
+ ];
};
export const autoDiscovery = async (
diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 2f2bac7fb..6416fda52 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -1,14 +1,17 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
+import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { callEmbedAtom } from '../../state/callEmbed';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise;
+ disabled?: boolean;
};
-function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
+function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
return (
onToggle()}
outlined
+ disabled={disabled}
>
@@ -38,8 +42,9 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
+ disabled?: boolean;
};
-function SoundButton({ enabled, onToggle }: SoundButtonProps) {
+function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return (
onToggle()}
outlined
+ disabled={disabled}
>
Promise;
+ disabled?: boolean;
};
-function VideoButton({ enabled, onToggle }: VideoButtonProps) {
+function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
return (
onToggle()}
outlined
+ disabled={disabled}
>
void;
+ disabled?: boolean;
};
-function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
+function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return (
@@ -136,8 +146,17 @@ function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
);
}
-export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) {
+export function CallControl({
+ callEmbed,
+ compact,
+ callJoined,
+}: {
+ callEmbed: CallEmbed;
+ compact: boolean;
+ callJoined: boolean;
+}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
+ const setCallEmbed = useSetAtom(callEmbedAtom);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
@@ -145,20 +164,38 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
+ const handleHangup = () => {
+ if (!callJoined) {
+ setCallEmbed(undefined);
+ return;
+ }
+ hangup();
+ };
+
return (
callEmbed.control.toggleMicrophone()}
+ disabled={!callJoined}
+ />
+ callEmbed.control.toggleSound()}
+ disabled={!callJoined}
/>
- callEmbed.control.toggleSound()} />
{!compact && }
- callEmbed.control.toggleVideo()} />
+ callEmbed.control.toggleVideo()}
+ disabled={!callJoined}
+ />
{!compact && (
callEmbed.control.toggleScreenshare()}
+ disabled={!callJoined}
/>
)}
@@ -176,7 +213,7 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp
}
disabled={exiting}
outlined
- onClick={hangup}
+ onClick={handleHangup}
>
{!compact && (
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index 5d2182c2c..1d30d1b40 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -74,7 +74,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
)}
-
+
);
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 0cddd2be2..7a8c28a5e 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -13,10 +13,29 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
+import { useLivekitSupport } from '../../hooks/useLivekitSupport';
-function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) {
+function LivekitServerMissingMessage() {
+ return (
+
+ Your homeserver does not support calling. But you can still join call started by others.
+
+ );
+}
+
+function JoinMessage({
+ hasParticipant,
+ livekitSupported,
+}: {
+ hasParticipant?: boolean;
+ livekitSupported?: boolean;
+}) {
if (hasParticipant) return null;
+ if (livekitSupported === false) {
+ return ;
+ }
+
return (
Voice chat’s empty — Be the first to hop in!
@@ -43,12 +62,13 @@ function AlreadyInCallMessage() {
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
+ const livekitSupported = useLivekitSupport();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
- const canJoin = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
+ const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId());
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
@@ -57,6 +77,8 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
+ const canJoin = hasPermission && (livekitSupported || hasParticipant);
+
return (
@@ -77,7 +99,11 @@ function CallPrescreen() {
{!inOtherCall &&
- (canJoin ? : )}
+ (hasPermission ? (
+
+ ) : (
+
+ ))}
{inOtherCall && }
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 9c5af9388..b317b13ab 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -57,6 +57,8 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
+import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
+import { livekitSupport } from '../../hooks/useLivekitSupport';
type RoomNavItemMenuProps = {
room: Room;
@@ -282,8 +284,14 @@ export function RoomNavItem({
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
+ const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler = (evt) => {
+ // Do not join if no livekit support or call is not started by others
+ if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
+ return;
+ }
+
// Do not join if already in call
if (callEmbed) {
return;
diff --git a/src/app/hooks/useLivekitSupport.ts b/src/app/hooks/useLivekitSupport.ts
new file mode 100644
index 000000000..3cb2c1d88
--- /dev/null
+++ b/src/app/hooks/useLivekitSupport.ts
@@ -0,0 +1,16 @@
+import { AutoDiscoveryInfo } from '../cs-api';
+import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo';
+
+export const livekitSupport = (autoDiscoveryInfo: AutoDiscoveryInfo): boolean => {
+ const rtcFoci = autoDiscoveryInfo['org.matrix.msc4143.rtc_foci'];
+
+ return (
+ Array.isArray(rtcFoci) && rtcFoci.some((info) => typeof info.livekit_service_url === 'string')
+ );
+};
+
+export const useLivekitSupport = (): boolean => {
+ const autoDiscoveryInfo = useAutoDiscoveryInfo();
+
+ return livekitSupport(autoDiscoveryInfo);
+};
diff --git a/src/app/pages/client/AutoDiscovery.tsx b/src/app/pages/client/AutoDiscovery.tsx
new file mode 100644
index 000000000..76423477f
--- /dev/null
+++ b/src/app/pages/client/AutoDiscovery.tsx
@@ -0,0 +1,32 @@
+import React, { ReactNode, useCallback, useMemo } from 'react';
+import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
+import { AsyncStatus, useAsyncCallbackValue } from '../../hooks/useAsyncCallback';
+import { autoDiscovery, AutoDiscoveryInfo } from '../../cs-api';
+import { getMxIdServer } from '../../utils/matrix';
+
+type AutoDiscoveryProps = {
+ userId: string;
+ baseUrl: string;
+ children: ReactNode;
+};
+export function AutoDiscovery({ userId, baseUrl, children }: AutoDiscoveryProps) {
+ const [state] = useAsyncCallbackValue(
+ useCallback(async () => {
+ const server = getMxIdServer(userId);
+ return autoDiscovery(fetch, server ?? userId);
+ }, [userId])
+ );
+
+ const [, info] = state.status === AsyncStatus.Success ? state.data : [];
+
+ const fallback: AutoDiscoveryInfo = useMemo(
+ () => ({
+ 'm.homeserver': {
+ base_url: baseUrl,
+ },
+ }),
+ [baseUrl]
+ );
+
+ return {children};
+}
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index e1a5dc0c0..93f0526e3 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -35,6 +35,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
+import { AutoDiscovery } from './AutoDiscovery';
function ClientRootLoading() {
return (
@@ -143,7 +144,7 @@ type ClientRootProps = {
};
export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true);
- const { baseUrl } = getFallbackSession() ?? {};
+ const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback(
useCallback(() => {
@@ -183,47 +184,55 @@ export function ClientRoot({ children }: ClientRootProps) {
);
return (
-
- {mx && }
- {loading && }
- {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
-
-
-
-
-
- )}
- {loading || !mx ? (
-
- ) : (
-
-
- {(serverConfigs) => (
-
-
-
- {children}
-
-
-
- )}
-
-
- )}
-
+
+
+ {mx && }
+ {loading && }
+ {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
+
+
+
+
+
+ )}
+ {loading || !mx ? (
+
+ ) : (
+
+
+ {(serverConfigs) => (
+
+
+
+ {children}
+
+
+
+ )}
+
+
+ )}
+
+
);
}
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index aeb28e360..870466769 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -146,7 +146,7 @@ export class CallEmbed {
let initialMediaEvent = true;
this.disposables.push(
- this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => {
+ this.listenAction(ElementWidgetActions.DeviceMute, (evt) => {
if (initialMediaEvent) {
initialMediaEvent = false;
this.control.applyState();
@@ -177,18 +177,27 @@ export class CallEmbed {
return this.call.transport.send(ElementWidgetActions.HangupCall, {});
}
- public listenEvent(type: string, callback: (event: CustomEvent) => void) {
- this.call.on(`action:${type}`, callback);
- return () => {
- this.call.off(`action:${type}`, callback);
- };
+ public onPreparing(callback: () => void) {
+ return this.listenEvent('preparing', callback);
+ }
+
+ public onPreparingError(callback: (error: any) => void) {
+ return this.listenEvent('error:preparing', callback);
+ }
+
+ public onReady(callback: () => void) {
+ return this.listenEvent('ready', callback);
+ }
+
+ public onCapabilitiesNotified(callback: () => void) {
+ return this.listenEvent('capabilitiesNotified', callback);
}
private start() {
// Room widgets get locked to the room they were added in
this.call.setViewedRoomId(this.roomId);
this.disposables.push(
- this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this))
+ this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this))
);
// Populate the map of "read up to" events for this widget with the current event in every room.
@@ -375,4 +384,15 @@ export class CallEmbed {
}
}
}
+
+ public listenAction(type: string, callback: (event: CustomEvent) => void) {
+ return this.listenEvent(`action:${type}`, callback);
+ }
+
+ public listenEvent(type: string, callback: (event: T) => void) {
+ this.call.on(type, callback);
+ return () => {
+ this.call.off(type, callback);
+ };
+ }
}
From 296249de3245dd597b5711ff4a81eb568ebf9a83 Mon Sep 17 00:00:00 2001
From: Krishan <33421343+kfiven@users.noreply.github.com>
Date: Tue, 10 Mar 2026 12:26:25 +1100
Subject: [PATCH 05/14] chore: enable semantic check on PR title (#2447)
* Enable semantic check on PR title
* Update semantic pull request action version
* Update PR trigger types in workflow configuration
Removed 'reopened' and 'synchronize' types from pull request triggers.
---
.github/workflows/pr-title.yml | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 .github/workflows/pr-title.yml
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 000000000..a52ee8e6d
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,15 @@
+name: Check PR title
+
+on:
+ pull_request_target:
+ types:
+ - opened
+ - edited
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
From 37e0c2aaac26ed77ec0fac2c97d03dfa08362172 Mon Sep 17 00:00:00 2001
From: Krishan <33421343+kfiven@users.noreply.github.com>
Date: Tue, 10 Mar 2026 12:26:55 +1100
Subject: [PATCH 06/14] chore(deps): continue action if login fails (#2758)
chore(action): continue action if login fails
---
.github/workflows/docker-pr.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml
index 822b8f3fc..960aeb8f6 100644
--- a/.github/workflows/docker-pr.yml
+++ b/.github/workflows/docker-pr.yml
@@ -30,6 +30,7 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
+ continue-on-error: true
- name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
@@ -38,6 +39,7 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ continue-on-error: true
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
From 0cbfbab5ad4dec16c0c927bef6232662c6725aee Mon Sep 17 00:00:00 2001
From: Krishan <33421343+kfiven@users.noreply.github.com>
Date: Tue, 10 Mar 2026 14:57:20 +1100
Subject: [PATCH 07/14] chore: add semantic commits to renovate configuration
(#2760)
* Add semantic commits to Renovate configuration
* Fix formatting issue in renovate.json
---
.github/renovate.json | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/renovate.json b/.github/renovate.json
index 62b0cf2a9..2c6c653e0 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,6 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["config:recommended", ":dependencyDashboardApproval"],
+ "extends": [
+ "config:recommended",
+ ":dependencyDashboardApproval",
+ ":semanticCommits"
+ ],
"labels": ["Dependencies"],
"packageRules": [
{
From 0d1566977afc7d27795fff2ceebb7c46261e7395 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Tue, 10 Mar 2026 22:45:26 +1100
Subject: [PATCH 08/14] fix: call ui imorovements (#2749)
* fix member button tooltip in call room header
* hide sticker button in room input based on chat window width
* render camera on off data instead of duplicate join messages
* hide duplicate call member changes instead of rendering as video status
* fix prescreen message spacing
---
src/app/features/call/CallView.tsx | 4 ++--
src/app/features/call/styles.css.ts | 4 ++++
src/app/features/room/RoomInput.tsx | 2 +-
src/app/features/room/RoomTimeline.tsx | 8 +++++++-
src/app/features/room/RoomViewHeader.tsx | 6 +++++-
5 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 7a8c28a5e..7c7bec6cc 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -97,7 +97,7 @@ function CallPrescreen() {
)}
-
+
{!inOtherCall &&
(hasPermission ? (
@@ -105,7 +105,7 @@ function CallPrescreen() {
))}
{inOtherCall && }
-
+
diff --git a/src/app/features/call/styles.css.ts b/src/app/features/call/styles.css.ts
index 249edb08a..2b9f28ad6 100644
--- a/src/app/features/call/styles.css.ts
+++ b/src/app/features/call/styles.css.ts
@@ -22,3 +22,7 @@ export const CallMemberCard = style({
export const CallControlContainer = style({
padding: config.space.S400,
});
+
+export const PrescreenMessage = style({
+ padding: config.space.S200,
+});
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index ae46d2d09..f88ccf938 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -221,7 +221,7 @@ export const RoomInput = forwardRef(
const isComposing = useComposingCheck();
useElementSizeObserver(
- useCallback(() => document.body, []),
+ useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
useCallback((width) => setHideStickerBtn(width < 500), [])
);
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 640430540..39d7e50a6 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -1475,7 +1475,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
- const callJoined = mEvent.getContent().application;
+ const content = mEvent.getContent();
+ const prevContent = mEvent.getPrevContent();
+
+ const callJoined = content.application;
+ if (callJoined && 'application' in prevContent) {
+ return null;
+ }
const timeJSX = (
Twitter
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx
index 79630990e..8b0bc3c6f 100644
--- a/src/app/pages/client/WelcomePage.tsx
+++ b/src/app/pages/client/WelcomePage.tsx
@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
- v4.10.5
+ v4.11.0
}
From 919fe8381bbb69d371244cc67d40375948034ecc Mon Sep 17 00:00:00 2001
From: Krishan <33421343+kfiven@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:06:53 +1100
Subject: [PATCH 11/14] chore(deps): Update slate deps to 0.123.0 (#2764)
Update slate deps to latest
---
package-lock.json | 61 ++++++++++++++++-------------------------------
package.json | 8 +++----
2 files changed, 24 insertions(+), 45 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index a482aa058..b1ac60dbd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,10 +59,10 @@
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.12.1",
- "slate": "0.112.0",
- "slate-dom": "0.112.2",
- "slate-history": "0.110.3",
- "slate-react": "0.112.1",
+ "slate": "0.123.0",
+ "slate-dom": "0.123.0",
+ "slate-history": "0.113.1",
+ "slate-react": "0.123.0",
"ua-parser-js": "1.0.35"
},
"devDependencies": {
@@ -10291,20 +10291,15 @@
}
},
"node_modules/slate": {
- "version": "0.112.0",
- "resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz",
- "integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==",
- "license": "MIT",
- "dependencies": {
- "immer": "^10.0.3",
- "is-plain-object": "^5.0.0",
- "tiny-warning": "^1.0.3"
- }
+ "version": "0.123.0",
+ "resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
+ "integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==",
+ "license": "MIT"
},
"node_modules/slate-dom": {
- "version": "0.112.2",
- "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.112.2.tgz",
- "integrity": "sha512-cozITMlpcBxrov854reM6+TooiHiqpfM/nZPrnjpN1wSiDsAQmYbWUyftC+jlwcpFj80vywfDHzlG6hXIc5h6A==",
+ "version": "0.123.0",
+ "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
+ "integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
"license": "MIT",
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
@@ -10316,13 +10311,13 @@
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
- "slate": ">=0.99.0"
+ "slate": ">=0.121.0"
}
},
"node_modules/slate-history": {
- "version": "0.110.3",
- "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz",
- "integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==",
+ "version": "0.113.1",
+ "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
+ "integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
"license": "MIT",
"dependencies": {
"is-plain-object": "^5.0.0"
@@ -10332,15 +10327,14 @@
}
},
"node_modules/slate-react": {
- "version": "0.112.1",
- "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz",
- "integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==",
+ "version": "0.123.0",
+ "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
+ "integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
"license": "MIT",
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
- "is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
@@ -10348,18 +10342,8 @@
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
- "slate": ">=0.99.0",
- "slate-dom": ">=0.110.2"
- }
- },
- "node_modules/slate/node_modules/immer": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
- "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
- "license": "MIT",
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/immer"
+ "slate": ">=0.121.0",
+ "slate-dom": ">=0.119.1"
}
},
"node_modules/smob": {
@@ -10729,11 +10713,6 @@
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
"license": "MIT"
},
- "node_modules/tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
- },
"node_modules/tinyglobby": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
diff --git a/package.json b/package.json
index 9cc02e190..4549a8046 100644
--- a/package.json
+++ b/package.json
@@ -71,10 +71,10 @@
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.12.1",
- "slate": "0.112.0",
- "slate-dom": "0.112.2",
- "slate-history": "0.110.3",
- "slate-react": "0.112.1",
+ "slate": "0.123.0",
+ "slate-dom": "0.123.0",
+ "slate-history": "0.113.1",
+ "slate-react": "0.123.0",
"ua-parser-js": "1.0.35"
},
"devDependencies": {
From 6a05ff58406755d5613e48dc3cf4f39f1033764a Mon Sep 17 00:00:00 2001
From: Krishan <33421343+kfiven@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:07:37 +1100
Subject: [PATCH 12/14] chore(release): v4.11.1 [skip ci] (#2765)
* chore(release): 4.11.0 [skip ci]
* chore(release): 4.11.1 [skip ci]
---
package-lock.json | 4 ++--
package.json | 2 +-
src/app/features/settings/about/About.tsx | 2 +-
src/app/pages/auth/AuthFooter.tsx | 2 +-
src/app/pages/client/WelcomePage.tsx | 2 +-
5 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b1ac60dbd..399f03a92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cinny",
- "version": "4.11.0",
+ "version": "4.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
- "version": "4.11.0",
+ "version": "4.11.1",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
diff --git a/package.json b/package.json
index 4549a8046..ba2cd751e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cinny",
- "version": "4.11.0",
+ "version": "4.11.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx
index 3f8ac163a..f536d12ef 100644
--- a/src/app/features/settings/about/About.tsx
+++ b/src/app/features/settings/about/About.tsx
@@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
Cinny
- v4.11.0
+ v4.11.1
Yet another matrix client.
diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx
index d4c58058b..7e509d1ee 100644
--- a/src/app/pages/auth/AuthFooter.tsx
+++ b/src/app/pages/auth/AuthFooter.tsx
@@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank"
rel="noreferrer"
>
- v4.11.0
+ v4.11.1
Twitter
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx
index 8b0bc3c6f..b3ec2eb44 100644
--- a/src/app/pages/client/WelcomePage.tsx
+++ b/src/app/pages/client/WelcomePage.tsx
@@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank"
rel="noreferrer noopener"
>
- v4.11.0
+ v4.11.1
}
From 49af03743482442fb83920b25228efccaa7a6ff3 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Wed, 11 Mar 2026 14:06:20 -0500
Subject: [PATCH 13/14] Create aaa_syncupstream.md
---
.changeset/aaa_syncupstream.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/aaa_syncupstream.md
diff --git a/.changeset/aaa_syncupstream.md b/.changeset/aaa_syncupstream.md
new file mode 100644
index 000000000..af863df74
--- /dev/null
+++ b/.changeset/aaa_syncupstream.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Merge in upstream call things and remove the duplicate new voice room button.
From 4619911aaccdaa6044471bbce80bad38a0edc261 Mon Sep 17 00:00:00 2001
From: 7w1
Date: Wed, 11 Mar 2026 14:08:27 -0500
Subject: [PATCH 14/14] Delete .github/workflows/pr-title.yml
---
.github/workflows/pr-title.yml | 15 ---------------
1 file changed, 15 deletions(-)
delete mode 100644 .github/workflows/pr-title.yml
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
deleted file mode 100644
index a52ee8e6d..000000000
--- a/.github/workflows/pr-title.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Check PR title
-
-on:
- pull_request_target:
- types:
- - opened
- - edited
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}