Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
186c81b
feat(dev-tools): add rotate-sessions developer tool
Just-Insane Mar 31, 2026
d5468cd
docs(changeset): clarify Megolm session rotation description
Just-Insane Apr 15, 2026
70c36b2
feat(flags): inject client config from GH environment variables at build
Just-Insane Mar 29, 2026
f96423f
feat(flags): add typed experiment bucketing helper with rollout perce…
Just-Insane Mar 29, 2026
ea9f286
feat(devtools): add Experiments panel to developer tools settings
Just-Insane Mar 29, 2026
a9fa515
test(flags): cover experiment bucketing and add changeset
Just-Insane Mar 29, 2026
7953f37
fix(security): block prototype-polluting keys in deepMerge
Just-Insane Apr 12, 2026
bafe036
fix: backport developer tools cleanup
Just-Insane May 14, 2026
a8625b2
fix(developer-tools): inject INJECTED_EXPERIMENT_FLAGS into Vite buil…
Just-Insane May 18, 2026
c869524
feat(developer-tools): add media cache stats and clear button
Just-Insane May 18, 2026
98462fb
style: apply oxfmt formatting
Just-Insane May 18, 2026
f849f17
fix(developer-tools): define INJECTED_EXPERIMENT_FLAGS in Vite build
Just-Insane May 19, 2026
86d7ead
fix(developer-tools): define INJECTED_EXPERIMENT_FLAGS in Vite build
Just-Insane May 19, 2026
91f20bb
feat(developer-tools): show all three cache types with individual cle…
Just-Insane May 19, 2026
ff38ef5
fix(dev-tools): async cache stats, add SW tile and descriptions
Just-Insane May 19, 2026
d571afe
chore: consolidate changeset
Just-Insane May 19, 2026
e20e261
fix(developer-tools): add onDelete prop to AccountDataEditorProps
Just-Insane May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/developer-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add build-time experiment flag injection, typed deterministic bucketing helpers, and a DevTools panel to force-rotate Megolm encryption sessions per room.
30 changes: 30 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,36 @@ runs:
env:
INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }}

- name: Inject runtime config overrides
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
shell: bash
working-directory: ${{ github.workspace }}
run: node scripts/inject-client-config.js
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }}

- name: Display injected config
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
shell: bash
working-directory: ${{ github.workspace }}
run: |
summary_file="${GITHUB_STEP_SUMMARY:-}"
echo "::group::Injected Client Config"
experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')"
echo "$experiments_json"
echo "::endgroup::"

if [[ -n "$summary_file" ]]; then
{
echo "### Injected client config"
echo
echo "\`\`\`json"
echo "$experiments_json"
echo "\`\`\`"
} >> "$summary_file"
fi

- name: Build app
if: ${{ inputs.build == 'true' }}
shell: bash
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cloudflare-web-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ jobs:
plan:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
pull-requests: write
Expand Down Expand Up @@ -73,6 +77,10 @@ jobs:
apply:
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: production
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
defaults:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/cloudflare-web-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ jobs:
deploy:
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
runs-on: ubuntu-latest
environment: preview
permissions:
contents: read
pull-requests: write
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
"entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"],
"ignore": ["oxlint.config.ts", "oxfmt.config.ts"],
"ignoreExportsUsedInFile": {
"interface": true,
Expand Down
75 changes: 75 additions & 0 deletions scripts/inject-client-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { readFile, writeFile } from 'node:fs/promises';
import process from 'node:process';
import { PrefixedLogger } from './utils/console-style.js';

const CONFIG_PATH = 'config.json';
const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON';
const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT';
const logger = new PrefixedLogger('[config-inject]');

const formatError = (error) => {
if (error instanceof Error) return error.stack ?? error.message;
return String(error);
};

const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);

// Keys that could trigger prototype pollution via bracket assignment.
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

const deepMerge = (target, source) => {
if (!isPlainObject(target) || !isPlainObject(source)) return source;

const merged = { ...target };
Object.entries(source).forEach(([key, value]) => {
if (UNSAFE_KEYS.has(key)) return;
const targetValue = merged[key];
merged[key] =
isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
});
return merged;
};

const failOnError = process.env[STRICT_ENV] === 'true';
const overridesRaw = process.env[OVERRIDES_ENV];

if (!overridesRaw) {
logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`);
process.exit(0);
}

let fileConfig;
let overrides;

try {
const file = await readFile(CONFIG_PATH, 'utf8');
fileConfig = JSON.parse(file);
} catch (error) {
logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`);
process.exit(1);
}

try {
overrides = JSON.parse(overridesRaw);
if (!isPlainObject(overrides)) {
throw new Error(`${OVERRIDES_ENV} must be a JSON object.`);
}
} catch (error) {
const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${
failOnError ? 'failing build' : 'skipping overrides'
}.`;
if (failOnError) {
logger.error(`${message} ${formatError(error)}`);
process.exit(1);
}
logger.info(`[warning] ${message} ${formatError(error)}`);
process.exit(0);
}

const mergedConfig = deepMerge(fileConfig, overrides);

await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8');
logger.info(
`Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}`
);
11 changes: 10 additions & 1 deletion src/app/components/AccountDataEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,9 @@ type AccountDataViewProps = {
type: string;
defaultContent: string;
onEdit: () => void;
onDelete?: () => void;
};
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
function AccountDataView({ type, defaultContent, onEdit, onDelete }: AccountDataViewProps) {
return (
<Box
direction="Column"
Expand All @@ -222,6 +223,11 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
<Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
<Text size="B400">Edit</Text>
</Button>
{onDelete && (
<Button variant="Critical" size="400" radii="300" onClick={onDelete}>
<Text size="B400">Delete</Text>
</Button>
)}
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">JSON Content</Text>
Expand All @@ -246,13 +252,15 @@ export type AccountDataEditorProps = {
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
onDelete?: () => void;
requestClose: () => void;
};

export function AccountDataEditor({
type,
content,
submitChange,
onDelete,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
Expand Down Expand Up @@ -315,6 +323,7 @@ export function AccountDataEditor({
type={data.type}
defaultContent={contentJSONStr}
onEdit={() => setEdit(true)}
onDelete={onDelete}
/>
)}
</Box>
Expand Down
30 changes: 24 additions & 6 deletions src/app/components/room-avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import * as css from './RoomAvatar.css';

// Module-level cache: maps a Matrix media URL → processed blob URL so that
// SVG processing only runs once per unique image, even as virtual-list items
// unmount and remount. MXC URLs are content-addressed and never change, so
// the mapping is stable for the lifetime of the page.
const svgBlobCache = new Map<string, string>();

/** Number of SVG blob URLs currently held in the module-level cache. */
export function getSvgCacheSize(): number {
return svgBlobCache.size;
}
Comment on lines +9 to +18

type AvatarImageProps = {
src: string;
alt?: string;
Expand All @@ -23,9 +34,15 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp

useEffect(() => {
let isMounted = true;
let objectUrl: string | null = null;

const processImage = async () => {
// Return the cached blob URL immediately — no network round-trip needed.
const cachedBlobUrl = svgBlobCache.get(src);
if (cachedBlobUrl) {
setProcessedSrc(cachedBlobUrl);
return;
}

try {
const res = await fetch(src, { mode: 'cors' });
const contentType = res.headers.get('content-type');
Expand All @@ -46,8 +63,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
const newSvgString = serializer.serializeToString(doc);
const blob = new Blob([newSvgString], { type: 'image/svg+xml' });

objectUrl = URL.createObjectURL(blob);
if (isMounted) setProcessedSrc(objectUrl);
const blobUrl = URL.createObjectURL(blob);
// Store in module cache so future remounts skip processing.
svgBlobCache.set(src, blobUrl);
if (isMounted) setProcessedSrc(blobUrl);
} else if (isMounted) setProcessedSrc(src);
} catch {
if (isMounted) setProcessedSrc(src);
Expand All @@ -58,9 +77,8 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp

return () => {
isMounted = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
// Blob URLs are retained in svgBlobCache — do not revoke them here so
// that subsequent remounts can use the cached result without re-fetching.
};
}, [src]);

Expand Down
Loading
Loading