Compare commits

6 Commits

Author SHA1 Message Date
6cb1e14632 Fix What's New dialog layout and improve dev styling/debugging.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 1s
Portal the updates dialog to document.body with stable data attributes,
fix max-width (invalid S800 token), bundle update images for Capacitor,
and add readable vanilla-extract class names plus portalContainer tap fixes.
2026-08-23 16:54:59 +10:00
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
14 changed files with 420 additions and 64 deletions

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,31 +3,64 @@ import { color, config, toRem } from 'folds';
const MOBILE_BREAKPOINT = '480px'; const MOBILE_BREAKPOINT = '480px';
/** Dialog height cap — 85% of the viewport, minus safe areas. */ /** Above folds overlays (9999) and Settings modals. */
const UPDATES_DIALOG_Z = 10001;
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))';
export const OverlayFrame = style({ /** [data-updates-dialog] — full-screen host portaled to document.body */
export const Root = style({
position: 'fixed',
inset: 0,
zIndex: UPDATES_DIALOG_Z,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'auto',
});
/** [data-updates-dialog-backdrop] */
export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 0,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
});
/** [data-updates-dialog-frame] */
export const Frame = style({
position: 'relative',
zIndex: 1,
boxSizing: 'border-box', boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxHeight: '100vh', maxHeight: '100vh',
maxWidth: '100vw', maxWidth: '100vw',
padding: padding:
'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)', 'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)',
pointerEvents: 'none',
}); });
export const DialogShell = style({ /** [data-updates-dialog-panel] */
export const Panel = style({
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: '100%', width: '100%',
maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`, maxWidth: toRem(560),
maxHeight: DIALOG_MAX_HEIGHT, maxHeight: DIALOG_MAX_HEIGHT,
overflow: 'hidden', overflow: 'hidden',
pointerEvents: 'auto',
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
boxShadow: config.shadow.E400,
'@media': { '@media': {
[`(max-width: ${MOBILE_BREAKPOINT})`]: { [`(max-width: ${MOBILE_BREAKPOINT})`]: {
maxWidth: `calc(100vw - ${config.space.S400})`, maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
}, },
}, },
}); });
@@ -96,7 +129,7 @@ export const BodyContent = style({
}); });
export const BodyLoading = style({ export const BodyLoading = style({
padding: config.space.S800, padding: config.space.S600,
}); });
export const Markdown = style({ export const Markdown = style({
@@ -159,7 +192,7 @@ globalStyle(`${Markdown} code`, {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: toRem(13), fontSize: toRem(13),
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
borderRadius: config.radii.R200, borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
}); });

View File

@@ -1,17 +1,7 @@
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, Button, IconButton, Scroll, Spinner, Text } from 'folds';
Box,
Button,
Dialog,
IconButton,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
} from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes'; import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes';
@@ -46,24 +36,35 @@ export function UpdatesDialog({
setShowOlderList(false); setShowOlderList(false);
}; };
return ( return createPortal(
<Overlay open backdrop={<OverlayBackdrop />}> <div
<OverlayCenter className={css.OverlayFrame}> data-updates-dialog=""
className={css.Root}
data-disable-swipe-back="true"
data-disable-swipe-reply="true"
>
<div data-updates-dialog-backdrop="" className={css.Backdrop} aria-hidden="true" />
<div data-updates-dialog-frame="" className={css.Frame}>
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
onDeactivate: onClose, clickOutsideDeactivates: false,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<Dialog variant="Surface" className={css.DialogShell}> <div
role="dialog"
aria-modal="true"
aria-labelledby="updates-dialog-title"
data-updates-dialog-panel=""
className={css.Panel}
>
<Box className={css.Hero}> <Box className={css.Hero}>
<Box className={css.HeroRow}> <Box className={css.HeroRow}>
<img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} /> <img className={css.HeroLogo} src={PaarrotSVG} alt="" draggable={false} />
<Box className={css.HeroTitleWrap}> <Box className={css.HeroTitleWrap}>
<Text size="L400" priority="300">What&apos;s new</Text> <Text size="L400" priority="300">What&apos;s new</Text>
<Text size="H4" truncate> <Text id="updates-dialog-title" size="H4" truncate>
{displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'} {displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'}
</Text> </Text>
</Box> </Box>
@@ -173,9 +174,10 @@ export function UpdatesDialog({
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
</Dialog> </div>
</FocusTrap> </FocusTrap>
</OverlayCenter> </div>
</Overlay> </div>,
document.body
); );
} }

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(() => {
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]); }, [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,27 @@
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>;
const bundledImageByFile = import.meta.glob('../../../public/update/images/*', {
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 +76,66 @@ 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]);
}
function resolveBundledImageUrl(normalizedPath: string): string | undefined {
const needle = normalizedPath.replace(/^\/+/, '');
if (!needle) return undefined;
const entry = Object.entries(bundledImageByFile).find(([path]) => {
const normalized = path.replace(/\\/g, '/');
return normalized.endsWith(`/${needle}`) || normalized.endsWith(`/${needle.split('/').pop() ?? ''}`);
});
return 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;
const normalized = src.replace(/^\.\//, '').replace(/^\/+/, '');
const bundled = resolveBundledImageUrl(normalized);
if (bundled) return bundled;
if (src.startsWith('/')) return src; if (src.startsWith('/')) return src;
const normalized = src.replace(/^\.\//, ''); return new URL(normalized, `${getUpdateBaseUrl()}/`).href;
return `${UPDATE_BASE_PATH}/${normalized}`;
} }
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;
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; try {
if (!data?.current || !Array.isArray(data.older)) return null; return loadBundledManifest();
return {
current: data.current,
older: data.older.filter((entry) => entry?.file),
};
} catch { } catch {
return null; return null;
} }
@@ -86,14 +146,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 { } catch {
return null; // 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

@@ -147,6 +147,27 @@ body.stationery-dark-theme {
background-color: #262626; background-color: #262626;
} }
/*
* Folds Overlay / PopOut portals mount here. The container must not steal taps;
* only its children should. Empty portal shells (e.g. closed overlays) must not
* block dialogs portaled elsewhere.
*/
#portalContainer {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
overflow: hidden;
}
#portalContainer > * {
pointer-events: auto;
}
#portalContainer > *:empty {
pointer-events: none !important;
}
.twilight-theme #root { .twilight-theme #root {
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%); background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
} }

View File

@@ -0,0 +1,101 @@
import path from 'path';
import { transformAsync } from '@babel/core';
import vanillaBabelPlugin from '@vanilla-extract/babel-plugin-debug-ids';
import typescriptSyntax from '@babel/plugin-syntax-typescript';
const CSS_TS_FILTER = /\.css\.(js|cjs|mjs|jsx|ts|tsx)(\?.*)?$/;
/** Slug for vanilla-extract class names (letters, digits, _, -). */
function sanitizeIdentifierPart(value) {
return String(value)
.replace(/\s/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
}
function fileScopeSlug(filePath, projectRoot, packageName) {
const absolute = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
const normalized = absolute.replace(/\\/g, '/');
const srcRoot = path.join(projectRoot, 'src').replace(/\\/g, '/');
let rel;
if (normalized.startsWith(srcRoot)) {
rel = path.relative(path.join(projectRoot, 'src'), absolute);
} else if (normalized.includes('/src/')) {
rel = normalized.split('/src/').pop();
} else if (packageName) {
rel = path.join(packageName, path.basename(absolute));
} else {
rel = path.relative(projectRoot, absolute);
}
return sanitizeIdentifierPart(
String(rel)
.replace(/\\/g, '/')
.replace(/\.css\.(ts|tsx|js|cjs|mjs|jsx)$/i, '')
.replace(/\//g, '_')
);
}
function finalizeIdentifier(parts) {
let name = parts.filter(Boolean).join('_');
if (!name) {
name = 've_style';
}
if (/^[0-9]/.test(name)) {
name = `_${name}`;
}
if (!/^[A-Z_][0-9A-Z_-]+$/i.test(name)) {
name = `ve_${name}`;
}
return name;
}
/**
* Human-readable vanilla-extract class names without hash suffixes.
* Example: app_components_updates_dialog_UpdatesDialog_Root
*/
export function createReadableVanillaExtractIdentifiers(projectRoot) {
return function readableVanillaExtractIdentifier({ debugId, filePath, packageName, hash }) {
const scope = fileScopeSlug(filePath, projectRoot, packageName);
const exportName = debugId ? sanitizeIdentifierPart(debugId) : '';
if (exportName) {
return finalizeIdentifier([scope, exportName]);
}
// Unnamed styles (rare): keep a short disambiguator from the scoped hash.
const suffix = sanitizeIdentifierPart(String(hash).replace(/^_/, ''));
return finalizeIdentifier([scope, suffix]);
};
}
/**
* Injects export names into style() calls so readable identifiers can use them.
* Required when identifiers is a function — vanilla-extract only runs this for identOption === 'debug'.
*/
export function vanillaExtractDebugIdsPlugin() {
return {
name: 'vanilla-extract-debug-ids',
enforce: 'pre',
async transform(code, id) {
if (!CSS_TS_FILTER.test(id)) {
return null;
}
const result = await transformAsync(code, {
filename: id,
plugins: [vanillaBabelPlugin, typescriptSyntax],
configFile: false,
babelrc: false,
});
if (!result?.code) {
return null;
}
return { code: result.code, map: result.map };
},
};
}

View File

@@ -11,6 +11,10 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import buildConfig from './build.config'; import buildConfig from './build.config';
import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin'; import { liveTsxPlugin, readDefaultLiveSource } from './playground-liveTsxPlugin';
import {
createReadableVanillaExtractIdentifiers,
vanillaExtractDebugIdsPlugin,
} from './vanillaExtractIdentifiers.js';
const projectRoot = path.resolve(); const projectRoot = path.resolve();
@@ -56,7 +60,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 },
}, },
], ],
}; };
@@ -251,7 +256,7 @@ function corsProxyMiddleware() {
}; };
} }
export default defineConfig({ export default defineConfig(() => ({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: false,
base: buildConfig.base, base: buildConfig.base,
@@ -303,7 +308,15 @@ export default defineConfig({
promiseImportName: (i) => `__tla_${i}`, promiseImportName: (i) => `__tla_${i}`,
}), }),
viteStaticCopy(copyFiles), viteStaticCopy(copyFiles),
vanillaExtractPlugin(), ...(process.env.VITE_VE_IDENTIFIERS === 'short' ? [] : [vanillaExtractDebugIdsPlugin()]),
vanillaExtractPlugin({
unstable_pluginFilter: ({ name }) =>
name === 'vite-tsconfig-paths' || name === 'vanilla-extract-debug-ids',
identifiers:
process.env.VITE_VE_IDENTIFIERS === 'short'
? 'short'
: createReadableVanillaExtractIdentifiers(projectRoot),
}),
wasm(), wasm(),
react(), react(),
VitePWA({ VitePWA({
@@ -358,4 +371,4 @@ export default defineConfig({
plugins: [inject({ Buffer: ['buffer', 'Buffer'] })], plugins: [inject({ Buffer: ['buffer', 'Buffer'] })],
}, },
}, },
}); }));