Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar

# Conflicts:
#	package-lock.json
#	package.json
#	src/app/plugins/call/CallControl.ts
#	src/app/plugins/call/CallEmbed.ts
This commit is contained in:
Lotus CI
2026-10-19 12:12:28 -04:00
36 changed files with 679 additions and 94 deletions
+92 -7
View File
@@ -91,6 +91,9 @@ import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
/** [Gitea #323] Minimised PiP bar size. */
const PIP_MINI_W = 220;
const PIP_MINI_H = 40;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
@@ -1005,6 +1008,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
);
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
// instead of the video. Remembered; phones start minimised.
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
try {
const v = localStorage.getItem('pip-minimized');
if (v !== null) return v === '1';
} catch {
/* storage unavailable */
}
return window.innerWidth < 600;
});
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
const togglePipMinimized = useCallback(() => {
setPipMinimized((m) => {
try {
localStorage.setItem('pip-minimized', m ? '0' : '1');
} catch {
/* storage unavailable */
}
return !m;
});
}, []);
useEffect(() => {
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
@@ -1105,6 +1130,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}
}, [pipMode, callVisible]);
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
// below has positioned and sized it). Keeps the bar on screen.
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el || !pipMode) return;
if (pipMinimized) {
if (el.offsetHeight > PIP_MINI_H) {
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
}
el.style.width = `${PIP_MINI_W}px`;
el.style.height = `${PIP_MINI_H}px`;
} else {
el.style.width = `${pipFullSizeRef.current.width}px`;
el.style.height = `${pipFullSizeRef.current.height}px`;
}
const rect = el.getBoundingClientRect();
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
}, [pipMode, pipMinimized, callEmbedRef]);
React.useEffect(() => {
if (!pipMode) return;
const onPipWindowResize = (): void => {
@@ -1423,19 +1468,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
// Minimised: cover the frame so only the bar shows.
background: pipMinimized ? color.Surface.Container : 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
alignItems: pipMinimized ? 'center' : 'flex-start',
padding: '6px',
}}
>
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<div
style={{
display: 'flex',
gap: config.space.S100,
alignItems: 'center',
...(pipMinimized ? { width: '100%' } : {}),
}}
>
{/* Leftmost: the top-right corner is a resize handle. */}
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
aria-pressed={pipMinimized}
onClick={(e) => {
e.stopPropagation();
togglePipMinimized();
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
color: '#fff',
}}
>
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
</IconButton>
{!pipMinimized && document.fullscreenEnabled && (
<IconButton
type="button"
size="300"
@@ -1467,14 +1543,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}}
>
↗ Return to call
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
</div>
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
+6 -2
View File
@@ -13,11 +13,13 @@ import {
import React, { ReactNode } from 'react';
import * as css from './styles.css';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
import { isDndPresence } from '../../utils/busyPresence';
const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
[Presence.Busy]: 'Critical',
};
type PresenceBadgeProps = {
@@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
// Disturb" to match PresenceRingAvatar and the settings picker (which both
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
const isDnd = presence === Presence.Unavailable && status === 'dnd';
// [Gitea #283] or MSC3026 busy, which keeps a real status line.
const isDnd = isDndPresence(presence, status);
const legacyDnd = isDnd && status === 'dnd';
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
const displayStatus = isDnd ? undefined : status;
const displayStatus = legacyDnd ? undefined : status;
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
return (
@@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
if (!presence || presence === Presence.Offline) return null;
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
if (presence === Presence.Unavailable) {
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
}
@@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
</Box>
<textarea
aria-label="Private note about this user"
aria-describedby="private-note-storage"
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
placeholder="Notes only visible to you…"
placeholder="Add a private note…"
rows={3}
style={{
width: '100%',
@@ -305,6 +306,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
outline: 'none',
}}
/>
{/* [Gitea #329] Account data isn't end-to-end encrypted: say so. */}
<Text id="private-note-storage" size="T200" priority="300">
Only you can see this in Lotus Chat. It&apos;s stored on your homeserver and isn&apos;t
end-to-end encrypted.
</Text>
</Box>
);
}