fix/polish: wave 1+2 improvements across six features
CI / Build & Quality Checks (push) Successful in 10m25s

B1 - GIF upload progress: spinner on GIF button + disabled state while
     fetch+upload is in flight; clears on success or error
B2 - PiP position persistence: drag end saves left/top to localStorage;
     entering PiP restores saved position (clamped to current viewport)
B3 - PiP snap-to-corner: double-click the PiP overlay snaps to nearest
     corner with a 180ms CSS transition; saves new position
B4 - Device sessions loading state: useOtherUserDevices now returns
     {status:'loading'|'error'|'success', devices} instead of bare
     array; UserDeviceSessions shows spinner while loading
B5 - Device sessions error state: catch in hook sets status:'error';
     panel shows warning icon + 'Could not load sessions' message
B6 - Screenshare fullscreen Safari guard: hide button when
     document.fullscreenEnabled is false (iOS Safari, some mobile)
B7 - Status save error: show critical-coloured error text below Save
     button when saveState.status === AsyncStatus.Error
B9 - Encrypted search coverage counter: 'X / Y cached' badge next to
     'Encrypted Rooms' heading using existing localResult fields
D2 - PiP screenshare spotlight: track auto-spotlight in a ref; release
     spotlight when screenshare ends while in PiP mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:13:54 -04:00
parent e2b8e162e3
commit 403ec3d80c
7 changed files with 132 additions and 38 deletions
+55 -8
View File
@@ -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);
}}
@@ -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 (
<Box alignItems="Center" gap="200" style={{ padding: config.space.S200, opacity: 0.6 }}>
<Spinner size="100" variant="Secondary" />
<Text size="T200">Loading sessions</Text>
</Box>
);
}
if (devicesState.status === 'error') {
return (
<Box
className={ContainerColor({ variant: 'Critical' })}
alignItems="Center"
gap="200"
style={{ padding: config.space.S200, borderRadius: config.radii.R300 }}
>
<Icon size="100" src={Icons.Warning} />
<Text size="T200">Could not load sessions.</Text>
</Box>
);
}
const devices = devicesState.devices;
if (devices.length === 0) return null;
return (
<Box