From 70358d442bee04c58426de8876e0f576afbc1a41 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:38:45 -0400 Subject: [PATCH] lotus(#6): render avatar decorations on in-call tiles Adds io.lotus.decorations (toWidget): the host pushes a userId->image-URL map and EC overlays the profile decoration on each tile avatar (TileAvatar), keyed by userId, with a useSyncExternalStore-backed store. Makes A6 first-class in-call instead of absent. URLs are validated https/blob. Additive: no-op unless the host sends decorations. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusActions.ts | 3 ++ src/lotus/lotusDecorations.ts | 80 ++++++++++++++++++++++++++++++++++ src/room/InCallView.tsx | 4 ++ src/tile/TileAvatar.module.css | 17 ++++++++ src/tile/TileAvatar.tsx | 15 ++++++- 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/lotus/lotusDecorations.ts diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index b7468619..de94ed00 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -25,6 +25,8 @@ export enum LotusWidgetActions { InjectAudio = "io.lotus.inject_audio", /** toWidget: set audio/screenshare encoding quality (bitrate/framerate). */ SetQuality = "io.lotus.set_quality", + /** toWidget: per-user avatar-decoration image URLs for in-call tiles. */ + Decorations = "io.lotus.decorations", } /** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ @@ -32,4 +34,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ LotusWidgetActions.FocusParticipant, LotusWidgetActions.InjectAudio, LotusWidgetActions.SetQuality, + LotusWidgetActions.Decorations, ]; diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts new file mode 100644 index 00000000..128bf102 --- /dev/null +++ b/src/lotus/lotusDecorations.ts @@ -0,0 +1,80 @@ +/* +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> = {}; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const l of listeners) l(); +} + +/** Subscribe a tile avatar to its participant's decoration URL (or undefined). */ +export function useLotusDecoration(userId: string): string | undefined { + return useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => 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; + } +} + +/** + * Register the `io.lotus.decorations` handler. No effect unless the host sends + * the action. Returns a teardown function. + */ +export function startLotusDecorations(): () => void { + const w = widget; + if (!w) return () => undefined; + + const handler = (ev: CustomEvent): void => { + void w.api.transport.reply(ev.detail, {}); + const data = ev.detail.data as + | { decorations?: Record } + | undefined; + const next: Record = {}; + if (data?.decorations && typeof data.decorations === "object") { + for (const [userId, url] of Object.entries(data.decorations)) { + const safe = safeImageUrl(url); + if (safe) next[userId] = safe; + } + } + decorations = next; + emit(); + }; + + w.lazyActions.on(LotusWidgetActions.Decorations, handler); + return () => { + w.lazyActions.off(LotusWidgetActions.Decorations, handler); + decorations = {}; + emit(); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d902433d..fd1078b9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -33,6 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState"; import { startLotusFocus } from "../lotus/lotusFocus"; import { startLotusAudioInject } from "../lotus/lotusAudioInject"; import { startLotusQuality } from "../lotus/lotusQuality"; +import { startLotusDecorations } from "../lotus/lotusDecorations"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -295,6 +296,9 @@ export const InCallView: FC = ({ // [lotus] Handle the host's io.lotus.set_quality action to cap audio/ // screenshare encoding bitrate/framerate (#7). No-op unless the host sends it. useEffect(() => startLotusQuality(vm), [vm]); + // [lotus] Receive per-user avatar-decoration URLs from the host and render + // them on in-call tile avatars (#6). No-op unless the host sends them. + useEffect(() => startLotusDecorations(), []); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary diff --git a/src/tile/TileAvatar.module.css b/src/tile/TileAvatar.module.css index e4f5bbd5..d7babec6 100644 --- a/src/tile/TileAvatar.module.css +++ b/src/tile/TileAvatar.module.css @@ -18,3 +18,20 @@ Please see LICENSE in the repository root for full details. /* TODO: make this --cpd-color-fg-primary when available. */ color: var(--cpd-color-text-primary); } + +/* [lotus #6] Wrap so the decoration can overlay the avatar precisely. */ +.container { + position: relative; + display: inline-flex; +} + +/* [lotus #6] APNG/PNG profile decoration drawn over the in-call tile avatar. +The decoration art shares the avatar's box (transparent padding, ring at the +edges), matching how the host renders it elsewhere. */ +.lotusDecoration { + position: absolute; + inset: 0; + margin: auto; + pointer-events: none; + object-fit: contain; +} diff --git a/src/tile/TileAvatar.tsx b/src/tile/TileAvatar.tsx index 910a031d..ebcaa6a6 100644 --- a/src/tile/TileAvatar.tsx +++ b/src/tile/TileAvatar.tsx @@ -10,6 +10,7 @@ import { InlineSpinner } from "@vector-im/compound-web"; import styles from "./TileAvatar.module.css"; import { Avatar, type Props as AvatarProps } from "../Avatar"; +import { useLotusDecoration } from "../lotus/lotusDecorations"; interface Props extends AvatarProps { size: number; @@ -17,14 +18,26 @@ interface Props extends AvatarProps { } export const TileAvatar: FC = ({ size, loading, ...props }) => { + // [lotus #6] Profile decoration overlay for this participant, pushed by the + // host via io.lotus.decorations. undefined unless the host opted in. + const decoration = useLotusDecoration(props.id); return ( -
+
{loading && (
)} + {decoration && ( + + )}
); };