fix/polish: wave 1+2 improvements across six features
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:
@@ -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
|
||||
|
||||
@@ -360,7 +360,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
{screenshare && (
|
||||
{screenshare && !!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -508,6 +508,9 @@ export function MessageSearch({
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Lock} />
|
||||
<Text size="H5">Encrypted Rooms</Text>
|
||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
||||
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{localResult.groups.length > 0
|
||||
|
||||
@@ -299,6 +299,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
||||
const [gifError, setGifError] = React.useState<string | null>(null);
|
||||
const [gifUploading, setGifUploading] = React.useState(false);
|
||||
|
||||
const isComposing = useComposingCheck();
|
||||
|
||||
@@ -519,6 +520,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
|
||||
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<HTMLDivElement, RoomInputProps>(
|
||||
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<HTMLDivElement, RoomInputProps>(
|
||||
ref={gifBtnRef}
|
||||
aria-label="Insert GIF"
|
||||
aria-pressed={gifOpen}
|
||||
onClick={() => setGifOpen(!gifOpen)}
|
||||
onClick={() => !gifUploading && setGifOpen(!gifOpen)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
disabled={gifUploading}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.04em',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
GIF
|
||||
</Text>
|
||||
{gifUploading ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.04em',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
GIF
|
||||
</Text>
|
||||
)}
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
|
||||
@@ -508,6 +508,11 @@ function ProfileStatus() {
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save status — server may be rate limiting. Try again.
|
||||
</Text>
|
||||
)}
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
Auto-clear after:
|
||||
|
||||
@@ -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<UserDevice[] | undefined>(undefined);
|
||||
const [state, setState] = useState<UserDevicesState>({ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user