From 33d0e98eb004b3789d1e0dfde15f669d8ab8abf0 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:45:34 -0400 Subject: [PATCH] lotus(#6): render decoration in MediaView; fix store lifecycle Review found in-call tiles use MediaView->Avatar, not TileAvatar, so the decoration never rendered in-call (CRITICAL). Move the overlay into MediaView, gated on the avatar's own visibility (!(video && videoEnabled)) so it never floats over live video; revert the TileAvatar changes. Also ref-count the io.lotus.decorations registration (one shared handler, no double-reply) and stop clearing the map on teardown so a transient remount doesn't drop decorations (HIGH/MED). Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusDecorations.ts | 57 ++++++++++++++++++++++------------ src/tile/MediaView.module.css | 18 +++++++++++ src/tile/MediaView.tsx | 13 ++++++++ src/tile/TileAvatar.module.css | 17 ---------- src/tile/TileAvatar.tsx | 15 +-------- 5 files changed, 69 insertions(+), 51 deletions(-) diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts index 128bf102..6dbb8d6d 100644 --- a/src/lotus/lotusDecorations.ts +++ b/src/lotus/lotusDecorations.ts @@ -47,34 +47,51 @@ function safeImageUrl(raw: unknown): string | 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. No effect unless the host sends - * the action. Returns a teardown function. + * 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; - 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; + if (registrations === 0) { + 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(); - }; + decorations = next; + emit(); + }; + w.lazyActions.on(LotusWidgetActions.Decorations, handler); + unregister = () => + w.lazyActions.off(LotusWidgetActions.Decorations, handler); + } + registrations += 1; - w.lazyActions.on(LotusWidgetActions.Decorations, handler); return () => { - w.lazyActions.off(LotusWidgetActions.Decorations, handler); - decorations = {}; - emit(); + registrations -= 1; + if (registrations <= 0) { + registrations = 0; + unregister?.(); + unregister = null; + // Intentionally keep the last decorations map: a transient remount must + // not drop decorations until the host next pushes an update. + } }; } diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 240f14d1..cf7c6b87 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -61,6 +61,24 @@ Please see LICENSE in the repository root for full details. mix-blend-mode: multiply; } +/* [lotus #6] Profile decoration overlaid on the tile avatar. Shares the +avatar's centred box and size so frame-style decorations sit around it. */ +.lotusDecoration { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + object-fit: contain; +} + +@container mediaView (width > 0) { + .lotusDecoration { + inline-size: 50cqmin; + block-size: 50cqmin; + } +} + /* CSS makes us put a condition here, even though all we want to do is unconditionally select the container so we can use cqmin units */ @container mediaView (width > 0) { diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 6ff97f7a..2baa6c6e 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -22,6 +22,7 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; +import { useLotusDecoration } from "../lotus/lotusDecorations"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { showConnectionStats as showConnectionStatsSetting, @@ -91,6 +92,7 @@ export const MediaView: FC = ({ ...props }) => { const { t } = useTranslation(); + const decoration = useLotusDecoration(userId); const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); const [showConnectionStats] = useSetting(showConnectionStatsSetting); @@ -137,6 +139,17 @@ export const MediaView: FC = ({ })} style={{ display: video && videoEnabled ? "none" : "initial" }} /> + {decoration && !(video && videoEnabled) && ( + // [lotus #6] Profile decoration overlay, shown only when the avatar + // is visible (i.e. not when live video is showing). Pushed by the + // host via io.lotus.decorations; undefined unless opted in. + + )} {video?.publication !== undefined && ( = ({ 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 && ( - - )}
); };