diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index ea2af9929..aac473ed4 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -430,11 +430,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}, [pipMode, callEmbed]);
// When entering pip with screenshare active (or screenshare starts while in pip),
- // enable spotlight so the screenshare fills the pip window
+ // enable spotlight so the screenshare fills the pip window.
+ // When screenshare ends, release the spotlight we auto-enabled.
+ const pipAutoSpotlightRef = React.useRef(false);
useEffect(() => {
- if (!pipMode || !callEmbed || !pipScreenshare) return;
- if (!callEmbed.control.spotlight) {
- callEmbed.control.toggleSpotlight();
+ if (!pipMode || !callEmbed) return;
+ if (pipScreenshare) {
+ if (!callEmbed.control.spotlight) {
+ callEmbed.control.toggleSpotlight();
+ pipAutoSpotlightRef.current = true;
+ }
+ } else if (pipAutoSpotlightRef.current) {
+ if (callEmbed.control.spotlight) callEmbed.control.toggleSpotlight();
+ pipAutoSpotlightRef.current = false;
}
}, [pipMode, pipScreenshare, callEmbed]);
@@ -471,10 +479,17 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
prevPipModeRef.current = !!pipMode;
if (pipMode) {
if (!wasInPip) {
- el.style.top = 'auto';
- el.style.left = 'auto';
- el.style.bottom = '72px';
- el.style.right = '16px';
+ const saved = localStorage.getItem('pip-position');
+ const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
+ el.style.right = 'auto';
+ el.style.bottom = 'auto';
+ if (savedPos) {
+ el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
+ el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
+ } else {
+ el.style.left = `${window.innerWidth - 280 - 16}px`;
+ el.style.top = `${window.innerHeight - 158 - 72}px`;
+ }
el.style.width = '280px';
el.style.height = '158px';
el.style.borderRadius = '12px';
@@ -522,6 +537,29 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return () => window.removeEventListener('resize', onPipWindowResize);
}, [pipMode, callEmbedRef]);
+ const handlePipDoubleClick = (e: React.MouseEvent) => {
+ const el = callEmbedRef.current;
+ if (!el) return;
+ e.stopPropagation();
+ const margin = 16;
+ const w = el.offsetWidth;
+ const h = el.offsetHeight;
+ const rect = el.getBoundingClientRect();
+ const cx = rect.left + w / 2;
+ const cy = rect.top + h / 2;
+ const snapLeft = cx < window.innerWidth / 2 ? margin : window.innerWidth - w - margin;
+ const snapTop = cy < window.innerHeight / 2 ? margin : window.innerHeight - h - margin;
+ el.style.left = `${snapLeft}px`;
+ el.style.top = `${snapTop}px`;
+ el.style.right = 'auto';
+ el.style.bottom = 'auto';
+ el.style.transition = 'left 0.18s ease, top 0.18s ease';
+ setTimeout(() => {
+ if (el) el.style.transition = '';
+ }, 200);
+ localStorage.setItem('pip-position', JSON.stringify({ left: snapLeft, top: snapTop }));
+ };
+
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current;
if (!el) return;
@@ -561,6 +599,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
activeDragCleanupRef.current = null;
+ if (el && pipDragRef.current?.dragged) {
+ const rect = el.getBoundingClientRect();
+ localStorage.setItem('pip-position', JSON.stringify({ left: rect.left, top: rect.top }));
+ }
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
@@ -612,6 +654,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
activeDragCleanupRef.current = null;
+ if (el && pipDragRef.current?.dragged) {
+ const rect = el.getBoundingClientRect();
+ localStorage.setItem('pip-position', JSON.stringify({ left: rect.left, top: rect.top }));
+ }
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
@@ -719,6 +765,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
+ onDoubleClick={handlePipDoubleClick}
onClick={() => {
if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId);
}}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
index 2157e456e..0e7351751 100644
--- a/src/app/components/user-profile/UserRoomProfile.tsx
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -7,6 +7,7 @@ import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus';
import { DeviceVerificationStatus } from '../DeviceVerificationStatus';
import { DeviceVerification } from '../DeviceVerification';
import { useOtherUserDevices, UserDevice } from '../../hooks/useOtherUserDevices';
+import { ContainerColor } from '../../styles/ContainerColor.css';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
@@ -122,10 +123,34 @@ type UserDeviceSessionsProps = {
userId: string;
};
function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
- const devices = useOtherUserDevices(userId);
+ const devicesState = useOtherUserDevices(userId);
const [expanded, setExpanded] = useState(false);
- if (!devices || devices.length === 0) return null;
+ if (devicesState.status === 'loading') {
+ return (
+
+
+ Loading sessions…
+
+ );
+ }
+
+ if (devicesState.status === 'error') {
+ return (
+
+
+ Could not load sessions.
+
+ );
+ }
+
+ const devices = devicesState.devices;
+ if (devices.length === 0) return null;
return (
- {screenshare && (
+ {screenshare && !!document.fullscreenEnabled && (
)}
diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 9f6079a7c..773e588b6 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -508,6 +508,9 @@ export function MessageSearch({
Encrypted Rooms
+
+ {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
+
{localResult.groups.length > 0
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 72362a832..4c6c8d32f 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -299,6 +299,7 @@ export const RoomInput = forwardRef(
const gifBtnRef = useRef(null);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
const [gifError, setGifError] = React.useState(null);
+ const [gifUploading, setGifUploading] = React.useState(false);
const isComposing = useComposingCheck();
@@ -519,6 +520,7 @@ export const RoomInput = forwardRef(
const handleGifSelect = useCallback(
async (gifUrl: string, w: number, h: number) => {
+ setGifUploading(true);
try {
// Only fetch from trusted Giphy CDN domains
const allowed = [
@@ -562,6 +564,8 @@ export const RoomInput = forwardRef(
if (!alive()) return;
setGifError('Failed to send GIF. Please try again.');
setTimeout(() => setGifError(null), 4000);
+ } finally {
+ if (alive()) setGifUploading(false);
}
},
[mx, roomId, alive],
@@ -840,22 +844,27 @@ export const RoomInput = forwardRef(
ref={gifBtnRef}
aria-label="Insert GIF"
aria-pressed={gifOpen}
- onClick={() => setGifOpen(!gifOpen)}
+ onClick={() => !gifUploading && setGifOpen(!gifOpen)}
variant="SurfaceVariant"
size="300"
radii="300"
+ disabled={gifUploading}
>
-
- GIF
-
+ {gifUploading ? (
+
+ ) : (
+
+ GIF
+
+ )}
)}
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index d952c1e1c..c2117e0b0 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -508,6 +508,11 @@ function ProfileStatus() {
Save
+ {saveState.status === AsyncStatus.Error && (
+
+ Failed to save status — server may be rate limiting. Try again.
+
+ )}
Auto-clear after:
diff --git a/src/app/hooks/useOtherUserDevices.ts b/src/app/hooks/useOtherUserDevices.ts
index 4273c0d95..f659cd7e1 100644
--- a/src/app/hooks/useOtherUserDevices.ts
+++ b/src/app/hooks/useOtherUserDevices.ts
@@ -8,32 +8,37 @@ export type UserDevice = {
displayName?: string;
};
-export function useOtherUserDevices(userId: string): UserDevice[] | undefined {
+export type UserDevicesState =
+ | { status: 'loading' }
+ | { status: 'error' }
+ | { status: 'success'; devices: UserDevice[] };
+
+export function useOtherUserDevices(userId: string): UserDevicesState {
const mx = useMatrixClient();
const crossSigningActive = useCrossSigningActive();
- const [devices, setDevices] = useState(undefined);
+ const [state, setState] = useState({ status: 'loading' });
const fetchDevices = useCallback(async () => {
const crypto = mx.getCrypto();
if (!crypto || !crossSigningActive) {
- setDevices(undefined);
+ setState({ status: 'success', devices: [] });
return;
}
+ setState({ status: 'loading' });
try {
const deviceMap = await crypto.getUserDeviceInfo([userId], true);
const userDevices = deviceMap.get(userId);
- if (!userDevices) {
- setDevices([]);
- return;
- }
- setDevices(
- Array.from(userDevices.values()).map((device) => ({
- deviceId: device.deviceId,
- displayName: device.displayName,
- })),
- );
+ setState({
+ status: 'success',
+ devices: userDevices
+ ? Array.from(userDevices.values()).map((device) => ({
+ deviceId: device.deviceId,
+ displayName: device.displayName,
+ }))
+ : [],
+ });
} catch {
- setDevices(undefined);
+ setState({ status: 'error' });
}
}, [mx, userId, crossSigningActive]);
@@ -50,5 +55,5 @@ export function useOtherUserDevices(userId: string): UserDevice[] | undefined {
),
);
- return devices;
+ return state;
}