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; }