Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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(', ')}`
);
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;
}

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