Compare commits

5 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
b6f3f5c0aa Move update manifest generator into cinny for standalone Android builds.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:26:27 +10:00
25be638c5e Fix auto-show of What's New on first launch after an app update.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
2026-08-23 08:23:15 +10:00
a20c871726 Fix updates dialog on Android by portaling above Settings and resolving Capacitor asset URLs.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
2026-08-23 08:19:51 +10:00
13 changed files with 233 additions and 44 deletions

View File

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

View File

@@ -9,7 +9,7 @@
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "node scripts/generate-update-manifest.mjs && vite build",
"lint": "yarn check:eslint && yarn check:prettier", "lint": "yarn check:eslint && yarn check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Regenerates public/update/manifest.json from markdown files.
*
* - currentupdate.md is always the "current" entry
* - Other *.md files matching X.Y.Z.md become "older" (sorted newest first)
* - title, date, version come from YAML frontmatter when present
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const UPDATE_DIR = path.join(ROOT, 'public', 'update');
const MANIFEST_PATH = path.join(UPDATE_DIR, 'manifest.json');
const CURRENT_FILE = 'currentupdate.md';
const VERSION_FILE_RE = /^(\d+\.\d+\.\d+)\.md$/;
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
function parseFrontmatter(source) {
const match = FRONTMATTER_RE.exec(source.trimStart());
if (!match) return {};
const frontmatter = {};
match[1].split('\n').forEach((line) => {
const colon = line.indexOf(':');
if (colon <= 0) return;
const key = line.slice(0, colon).trim();
const raw = line.slice(colon + 1).trim();
frontmatter[key] = raw.replace(/^['"]|['"]$/g, '');
});
return frontmatter;
}
function compareVersions(a, b) {
const pa = a.split('.').map((part) => Number.parseInt(part, 10));
const pb = b.split('.').map((part) => Number.parseInt(part, 10));
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na !== nb) return nb - na;
}
return 0;
}
function readMarkdownMeta(fileName) {
const filePath = path.join(UPDATE_DIR, fileName);
const source = fs.readFileSync(filePath, 'utf8');
const frontmatter = parseFrontmatter(source);
const versionFromName = VERSION_FILE_RE.exec(fileName)?.[1];
return {
file: fileName,
version: frontmatter.version ?? versionFromName,
title: frontmatter.title,
date: frontmatter.date,
};
}
function buildManifest() {
if (!fs.existsSync(UPDATE_DIR)) {
throw new Error(`Update directory not found: ${UPDATE_DIR}`);
}
const currentPath = path.join(UPDATE_DIR, CURRENT_FILE);
if (!fs.existsSync(currentPath)) {
throw new Error(`Missing ${CURRENT_FILE} in ${UPDATE_DIR}`);
}
const older = fs
.readdirSync(UPDATE_DIR, { withFileTypes: true })
.filter((entry) => entry.isFile() && VERSION_FILE_RE.test(entry.name))
.map((entry) => readMarkdownMeta(entry.name))
.filter((entry) => entry.version)
.sort((a, b) => compareVersions(a.version, b.version));
return {
current: CURRENT_FILE,
older: older.map(({ file, version, title, date }) => {
const item = { file };
if (version) item.version = version;
if (title) item.title = title;
if (date) item.date = date;
return item;
}),
};
}
function main() {
const manifest = buildManifest();
const json = `${JSON.stringify(manifest, null, 2)}\n`;
let previous = null;
if (fs.existsSync(MANIFEST_PATH)) {
previous = fs.readFileSync(MANIFEST_PATH, 'utf8');
}
if (previous === json) {
console.log('[update-manifest] manifest.json is already up to date');
return;
}
fs.writeFileSync(MANIFEST_PATH, json, 'utf8');
console.log(
`[update-manifest] wrote manifest.json (${manifest.older.length} older version(s))`
);
}
main();

View File

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

View File

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

View File

@@ -3,7 +3,14 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px'; const MOBILE_BREAKPOINT = '480px';
/** Dialog height cap — 85% of the viewport, minus safe areas. */ export const PortalLayer = style({
position: 'fixed',
inset: 0,
zIndex: config.zIndex.Max,
pointerEvents: 'auto',
});
/** Dialog + overlay padding must never exceed the viewport height. */
const DIALOG_MAX_HEIGHT = const DIALOG_MAX_HEIGHT =
'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))'; 'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))';

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Box, Box,
@@ -11,6 +12,7 @@ import {
Scroll, Scroll,
Spinner, Spinner,
Text, Text,
usePopOutContainer,
} from 'folds'; } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@@ -35,6 +37,7 @@ export function UpdatesDialog({
onClose, onClose,
}: UpdatesDialogProps) { }: UpdatesDialogProps) {
const [showOlderList, setShowOlderList] = useState(false); const [showOlderList, setShowOlderList] = useState(false);
const popOutContainer = usePopOutContainer();
const displayVersion = activeDoc?.version; const displayVersion = activeDoc?.version;
const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes'); const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes');
@@ -46,14 +49,19 @@ export function UpdatesDialog({
setShowOlderList(false); setShowOlderList(false);
}; };
return ( const portalTarget =
popOutContainer ??
document.getElementById('portalContainer') ??
document.body;
return createPortal(
<div className={css.PortalLayer}>
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter className={css.OverlayFrame}> <OverlayCenter className={css.OverlayFrame}>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: onClose, clickOutsideDeactivates: false,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
@@ -177,5 +185,7 @@ export function UpdatesDialog({
</FocusTrap> </FocusTrap>
</OverlayCenter> </OverlayCenter>
</Overlay> </Overlay>
</div>,
portalTarget
); );
} }

View File

@@ -26,7 +26,10 @@ type UpdaterInfo = {
export function useOpenReleaseNotesDialog(): () => void { export function useOpenReleaseNotesDialog(): () => void {
const setDialogState = useSetAtom(releaseNotesDialogAtom); const setDialogState = useSetAtom(releaseNotesDialogAtom);
return useCallback(() => { return useCallback(() => {
// Defer so the opening tap does not trip FocusTrap click-outside on Android.
window.requestAnimationFrame(() => {
setDialogState({ open: true, manual: true }); setDialogState({ open: true, manual: true });
});
}, [setDialogState]); }, [setDialogState]);
} }
@@ -41,6 +44,11 @@ export function UpdatesDialogHost() {
setManifest(null); setManifest(null);
setActiveDoc(null); setActiveDoc(null);
setLoading(false); setLoading(false);
getAppVersion().then((version) => {
if (version !== 'unknown') {
setLastSeenAppVersion(version);
}
});
}, [setDialogState]); }, [setDialogState]);
const selectFile = useCallback(async (file: string) => { const selectFile = useCallback(async (file: string) => {
@@ -73,7 +81,7 @@ export function UpdatesDialogHost() {
if (cancelled || currentVersion === 'unknown') return; if (cancelled || currentVersion === 'unknown') return;
const lastSeen = getLastSeenAppVersion(); const lastSeen = getLastSeenAppVersion();
const shouldAutoShow = Boolean(lastSeen && lastSeen !== currentVersion); const shouldAutoShow = lastSeen !== currentVersion;
if (shouldAutoShow) { if (shouldAutoShow) {
const loadedManifest = await loadUpdateManifest(); const loadedManifest = await loadUpdateManifest();
@@ -87,10 +95,6 @@ export function UpdatesDialogHost() {
setDialogState({ open: true, manual: false }); setDialogState({ open: true, manual: false });
clearPendingReleaseNotes(); clearPendingReleaseNotes();
} }
if (!lastSeen || lastSeen !== currentVersion) {
setLastSeenAppVersion(currentVersion);
}
}; };
const timer = window.setTimeout(checkVersion, 1500); const timer = window.setTimeout(checkVersion, 1500);

View File

@@ -1,6 +1,22 @@
import bundledManifest from '../../../public/update/manifest.json';
import { trimTrailingSlash } from '../utils/common'; import { trimTrailingSlash } from '../utils/common';
const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`; 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 =
basePath === '.' || basePath === '' ? 'update/' : `${basePath}/update/`;
return new URL(relative, window.location.href).href.replace(/\/$/, '');
}
function getUpdateFileUrl(file: string): string {
return new URL(file, `${getUpdateBaseUrl()}/`).href;
}
export type UpdateManifestEntry = { export type UpdateManifestEntry = {
file: string; file: string;
@@ -55,27 +71,50 @@ 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 { export function resolveUpdateAssetUrl(src: string | undefined): string | undefined {
if (!src) return 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; if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, ''); const normalized = src.replace(/^\.\//, '');
return `${UPDATE_BASE_PATH}/${normalized}`; return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
} }
export async function loadUpdateManifest(): Promise<UpdateManifest | null> { export async function loadUpdateManifest(): Promise<UpdateManifest | null> {
try { try {
const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' }); 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; const data = (await response.json()) as UpdateManifest;
if (!data?.current || !Array.isArray(data.older)) return null; if (data?.current && Array.isArray(data.older)) {
return { return {
current: data.current, current: data.current,
older: data.older.filter((entry) => entry?.file), older: data.older.filter((entry) => entry?.file),
}; };
}
}
} catch {
// fall through to bundled copy (Capacitor / offline)
}
try {
return loadBundledManifest();
} catch { } catch {
return null; return null;
} }
@@ -86,14 +125,16 @@ export async function loadUpdateDocument(file: string): Promise<ParsedUpdateDoc
if (!safeFile || safeFile.includes('..')) return null; if (!safeFile || safeFile.includes('..')) return null;
try { try {
const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' }); const response = await fetch(getUpdateFileUrl(safeFile), { cache: 'no-cache' });
if (!response.ok) return null; if (response.ok) {
const source = await response.text(); const source = await response.text();
return parseUpdateMarkdown(safeFile, source); return parseUpdateMarkdown(safeFile, source);
} catch {
return null;
} }
} catch {
// fall through to bundled copy
}
return loadBundledDocument(safeFile);
} }
export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> { export async function loadCurrentUpdateDocument(): Promise<ParsedUpdateDoc | null> {

View File

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

View File

@@ -699,10 +699,10 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<MessageNotifications /> <MessageNotifications />
<BackgroundSyncSetup /> <BackgroundSyncSetup />
<PaarrotAPIInitializer /> <PaarrotAPIInitializer />
<UpdatesDialogHost />
<TaskbarFlashStopper /> <TaskbarFlashStopper />
<AndroidShareIntentHandler /> <AndroidShareIntentHandler />
{children} {children}
<UpdatesDialogHost />
</> </>
); );
} }

View File

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

View File

@@ -56,7 +56,8 @@ const copyFiles = {
{ {
src: 'public/update/**/*', src: 'public/update/**/*',
dest: '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 },
}, },
], ],
}; };