Files
element-call/src/lotus/lotusDecorations.ts
T
Lotus CIandClaude Opus 5 9c52146312
CI / Build embedded bundle (push) Failing after 2m18s
CI / Publish to Gitea npm registry (push) Skipped
ci(lotus): hard-gate lint, format and unit tests before build; add concurrency
The workflow only built and smoke-checked that dist/index.html existed,
so a type error, lint violation or failing test could be tagged straight
to a published npm release. Run `pnpm lint` (tsc + oxlint + knip),
`pnpm format:check` (oxfmt) and `pnpm test:unit --run` before the build,
all hard. Add the same cancel-in-progress concurrency group cinny's CI
uses so rapid pushes to lotus don't queue redundant full builds.

Also fixes the one tsc error the v0.25.0 merge surfaced in
lotusDecorations (lazyActions.off now returns the emitter), so the new
gate is green from its first run.

Fixes #5
Fixes #34

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 11:55:33 -04:00

108 lines
3.5 KiB
TypeScript

/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useSyncExternalStore } from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { widget } from "../widget";
import { LotusWidgetActions } from "./lotusActions";
/**
* Avatar decorations (#6 / A6). The Lotus host (cinny) owns the decoration
* roster (MSC4133 profile decorations) and can't draw them on EC's in-call
* video tiles from outside the iframe. So it pushes a `userId -> image URL`
* map via the `io.lotus.decorations` widget action, and the tile avatar
* component renders the overlay natively.
*/
let decorations: Readonly<Record<string, string>> = {};
const listeners = new Set<() => void>();
function emit(): void {
for (const l of listeners) l();
}
// Stable module-scope subscribe reference, so `useSyncExternalStore` doesn't
// re-subscribe (add/remove the listener) on every render of a tile.
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/** Subscribe a tile avatar to its participant's decoration URL (or undefined). */
export function useLotusDecoration(userId: string): string | undefined {
return useSyncExternalStore(subscribe, () => decorations[userId]);
}
function safeImageUrl(raw: unknown): string | null {
if (typeof raw !== "string") return null;
try {
const u = new URL(raw, window.location.href);
return u.protocol === "https:" || u.protocol === "blob:" ? u.href : null;
} catch {
return null;
}
}
// Ref-counted single registration: the decoration roster is app-wide (keyed by
// userId), so multiple tile/InCallView mounts must share ONE handler — otherwise
// each would reply to the same widget request (double-reply) and a transient
// remount would tear it down.
let registrations = 0;
let unregister: (() => void) | null = null;
/**
* Register the `io.lotus.decorations` handler (ref-counted). No effect unless
* the host sends the action. Returns a teardown function.
*/
export function startLotusDecorations(): () => void {
const w = widget;
if (!w) return () => undefined;
if (registrations === 0) {
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
w.api.transport.reply(ev.detail, {});
const data = ev.detail.data as
| { decorations?: Record<string, unknown> }
| undefined;
const next: Record<string, string> = {};
if (data?.decorations && typeof data.decorations === "object") {
// Cap the roster so a pathological map can't spawn unbounded overlays.
for (const [userId, url] of Object.entries(data.decorations).slice(
0,
512,
)) {
const safe = safeImageUrl(url);
if (safe) next[userId] = safe;
}
}
decorations = next;
emit();
};
w.lazyActions.on(LotusWidgetActions.Decorations, handler);
unregister = (): void => {
w.lazyActions.off(LotusWidgetActions.Decorations, handler);
};
}
registrations += 1;
return () => {
registrations -= 1;
if (registrations <= 0) {
registrations = 0;
unregister?.();
unregister = null;
// Reset the roster once the last registration goes away, so a decoration
// pushed in call A can't leak onto a shared user in call B before the
// host re-pushes. Notify listeners so any still-mounted tile drops the
// now-stale overlay via the render path.
decorations = {};
emit();
}
};
}