Compare commits

2 Commits

Author SHA1 Message Date
7d866404b3 Fix Android updates dialog, mobile About page, and user color crash guard.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Use stripBase for update static copy paths, show Settings detail on mobile
when nav is hidden, pause Settings focus trap during release notes, and guard
extractMemberColorPreference when room is not a Matrix Room instance.
2026-08-23 16:18:35 +10:00
c264de53b5 Fix View updates on Android: bundled notes, portal stack, tap handling.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Bundle update markdown at build time for Capacitor, portal into
portalContainer above Settings, defer open to avoid FocusTrap closing
on the same tap, and disable click-outside dismiss.
2026-08-23 08:38:23 +10:00
10 changed files with 78 additions and 24 deletions

View File

@@ -96,6 +96,12 @@
</script>
<div id="root"></div>
<div id="portalContainer"></div>
<style>
#portalContainer {
position: relative;
z-index: 10000;
}
</style>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,9 @@
import React, { ReactNode, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter, PopOutContainerProvider } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { releaseNotesDialogAtom } from '../state/releaseNotes';
type Modal500Props = {
requestClose: () => void;
@@ -10,11 +12,13 @@ type Modal500Props = {
export function Modal500({ requestClose, children }: Modal500Props) {
const [modalEl, setModalEl] = useState<HTMLDivElement | null>(null);
const modalRef = useCallback((el: HTMLDivElement | null) => setModalEl(el), []);
const releaseNotesOpen = useAtomValue(releaseNotesDialogAtom).open;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
active={!releaseNotesOpen}
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,

View File

@@ -16,6 +16,7 @@ type PageRootProps = {
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const showCompactMaster = useShowCompactMasterView();
const showDetail = !showCompactMaster || nav == null;
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
@@ -23,7 +24,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
{screenSize !== ScreenSize.Mobile && (
<Line variant="Background" size="300" direction="Vertical" />
)}
{!showCompactMaster && children}
{showDetail && children}
</Box>
);
}

View File

@@ -7,6 +7,7 @@ export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
pointerEvents: 'auto',
});
/** Dialog + overlay padding must never exceed the viewport height. */

View File

@@ -4,7 +4,6 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
config,
Dialog,
IconButton,
Overlay,
@@ -13,6 +12,7 @@ import {
Scroll,
Spinner,
Text,
usePopOutContainer,
} from 'folds';
import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard';
@@ -37,6 +37,7 @@ export function UpdatesDialog({
onClose,
}: UpdatesDialogProps) {
const [showOlderList, setShowOlderList] = useState(false);
const popOutContainer = usePopOutContainer();
const displayVersion = activeDoc?.version;
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
@@ -48,6 +49,11 @@ export function UpdatesDialog({
setShowOlderList(false);
};
const portalTarget =
popOutContainer ??
document.getElementById('portalContainer') ??
document.body;
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -55,8 +61,7 @@ export function UpdatesDialog({
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
clickOutsideDeactivates: false,
escapeDeactivates: stopPropagation,
}}
>
@@ -181,6 +186,6 @@ export function UpdatesDialog({
</OverlayCenter>
</Overlay>
</div>,
document.body
portalTarget
);
}

View File

@@ -26,7 +26,10 @@ type UpdaterInfo = {
export function useOpenReleaseNotesDialog(): () => void {
const setDialogState = useSetAtom(releaseNotesDialogAtom);
return useCallback(() => {
setDialogState({ open: true, manual: true });
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
window.requestAnimationFrame(() => {
setDialogState({ open: true, manual: true });
});
}, [setDialogState]);
}

View File

@@ -1,5 +1,12 @@
import bundledManifest from '../../../public/update/manifest.json';
import { trimTrailingSlash } from '../utils/common';
const bundledMarkdownByFile = import.meta.glob('../../../public/update/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
function getUpdateBaseUrl(): string {
const basePath = trimTrailingSlash(import.meta.env.BASE_URL || './');
const relative =
@@ -64,9 +71,26 @@ function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc {
};
}
function loadBundledManifest(): UpdateManifest {
const manifest = bundledManifest as UpdateManifest;
return {
current: manifest.current,
older: (manifest.older ?? []).filter((entry) => entry?.file),
};
}
function loadBundledDocument(file: string): ParsedUpdateDoc | null {
const safeFile = file.replace(/^\/+/, '');
const entry = Object.entries(bundledMarkdownByFile).find(([path]) =>
path.endsWith(`/${safeFile}`)
);
if (!entry) return null;
return parseUpdateMarkdown(safeFile, entry[1]);
}
export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return undefined;
if (/^(https?:|data:|blob:)/i.test(src)) return src;
if (/^(https?:|data:|blob:|capacitor:)/i.test(src)) return src;
if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, '');
@@ -76,15 +100,21 @@ export function resolveUpdateAssetUrl(src: string | undefined): string | undefin
export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try {
const response = await fetch(getUpdateFileUrl('manifest.json'), { cache: 'no-cache' });
if (!response.ok) return null;
if (response.ok) {
const data = (await response.json()) as UpdateManifest;
if (data?.current && Array.isArray(data.older)) {
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
}
}
} catch {
// fall through to bundled copy (Capacitor / offline)
}
const data = (await response.json()) as UpdateManifest;
if (!data?.current || !Array.isArray(data.older)) return null;
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
try {
return loadBundledManifest();
} catch {
return null;
}
@@ -96,13 +126,15 @@ export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc
try {
const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (!response.ok) return null;
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
if (response.ok) {
const source = await response.text();
return parseUpdateMarkdown(safeFile, source);
}
} catch {
return null;
// fall through to bundled copy
}
return loadBundledDocument(safeFile);
}
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {

View File

@@ -21,6 +21,9 @@ export function About({ requestClose }: AboutProps) {
const [version, setVersion] = useState<string>('');
const [protocolStatus, setProtocolStatus] = useState<string>('Checking desktop protocol integration...');
const [protocolBusy, setProtocolBusy] = useState<boolean>(false);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(
null
);
const formatProtocolStatus = useCallback((data: {
scheme: string;
@@ -101,8 +104,6 @@ export function About({ requestClose }: AboutProps) {
getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null));
}, [refreshProtocolStatus]);
const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null);
return (
<Page>
<PageHeader outlined={false}>

View File

@@ -152,7 +152,7 @@ export async function loadColorPreference(
}
export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined {
if (!room) return undefined;
if (!room || typeof (room as Room).getMember !== 'function' || !userId) return undefined;
const member = room.getMember(userId);
const content = member?.events.member?.getContent();
if (!content) return undefined;

View File

@@ -56,7 +56,8 @@ const copyFiles = {
{
src: 'public/update/**/*',
dest: 'update',
rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''),
// stripBase removes public/update from the matched path; rename alone only changes the filename.
rename: { stripBase: 2 },
},
],
};