diff --git a/.gitleaks.toml b/.gitleaks.toml index 4b2c445c1..606887140 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -12,6 +12,9 @@ regexes = [ '''https?://matrix\.lotusguild\.org''', '''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''', '''matrix\.lotusguild\.org''', + # Lotus Chat desktop updater's minisign PUBLIC key (published in the + # cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it. + '''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''', ] paths = [ '''config\.json''', diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts index 7dcbd6f63..bae50395d 100644 --- a/e2e/local-homeserver.spec.ts +++ b/e2e/local-homeserver.spec.ts @@ -265,6 +265,46 @@ test.describe('local homeserver regression', () => { await expect.poll(threadUnread).toBe(false); }); + test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => { + const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] }); + await joinRoom(bob, room); + const inThread = (root: string) => ({ + 'm.relates_to': { + rel_type: 'm.thread', + event_id: root, + is_falling_back: true, + 'm.in_reply_to': { event_id: root }, + }, + }); + const root = await sendText(bob, room, "bob's thread root"); + await loginUI(page, alice); + await openRoom(page, room); + await sendText(bob, room, 'reply in bob thread', inThread(root)); + await sendText(bob, room, 'newer main message 314'); + await expect(page.getByText('newer main message 314')).toBeVisible(); + const threadUnread = async () => { + const filter = { + room: { + rooms: [room], + timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] }, + }, + }; + const sync = await api<{ + rooms: { + join: Record }>; + }; + }>( + 'GET', + `/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`, + alice.token, + ); + return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {}); + }; + await page.waitForTimeout(2000); // let any receipt the room view would send go out + // Viewing the room used to read every thread you don't follow (#314). + expect(await threadUnread()).toBe(true); + }); + test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => { const room = await createRoom(alice, 'Lightbox Room'); const png = Buffer.from( diff --git a/scripts/bundle-budget.json b/scripts/bundle-budget.json index 4c8289c91..6bf27aceb 100644 --- a/scripts/bundle-budget.json +++ b/scripts/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.", - "totalGzipBytes": 1731120, - "largestChunkGzipBytes": 358317 + "$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin. Re-seeded 2026-10-18 from lotus d7bc6d94 (total 1666894, largest 360238 bytes gzip) after the October audit fix batch (~40 PRs) grew the entry chunk past the old budget.", + "totalGzipBytes": 1833583, + "largestChunkGzipBytes": 396262 } diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 4695a3079..aeb37870d 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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(() => { + 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', }} > -
- {document.fullscreenEnabled && ( +
+ {/* Leftmost: the top-right corner is a resize handle. */} + { + e.stopPropagation(); + togglePipMinimized(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + style={{ + background: 'rgba(0,0,0,0.65)', + backdropFilter: 'blur(4px)', + color: '#fff', + }} + > + + + {!pipMinimized && document.fullscreenEnabled && ( - ↗ Return to call + {pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
- - {(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => { + {!pipMinimized && } + {(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => { const s = corner.includes('s'); const e2 = corner.includes('e'); const dots = [ diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 1bdef048b..3d0305e2d 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -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.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 ( diff --git a/src/app/components/presence/PresenceRingAvatar.tsx b/src/app/components/presence/PresenceRingAvatar.tsx index 3fde97214..57ef58a6c 100644 --- a/src/app/components/presence/PresenceRingAvatar.tsx +++ b/src/app/components/presence/PresenceRingAvatar.tsx @@ -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; } diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 21163f023..1d3d7e0e5 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {