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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
70358d442b
commit
33d0e98eb0
@@ -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
|
* Register the `io.lotus.decorations` handler (ref-counted). No effect unless
|
||||||
* the action. Returns a teardown function.
|
* the host sends the action. Returns a teardown function.
|
||||||
*/
|
*/
|
||||||
export function startLotusDecorations(): () => void {
|
export function startLotusDecorations(): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
if (!w) return () => undefined;
|
if (!w) return () => undefined;
|
||||||
|
|
||||||
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
if (registrations === 0) {
|
||||||
void w.api.transport.reply(ev.detail, {});
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
const data = ev.detail.data as
|
void w.api.transport.reply(ev.detail, {});
|
||||||
| { decorations?: Record<string, unknown> }
|
const data = ev.detail.data as
|
||||||
| undefined;
|
| { decorations?: Record<string, unknown> }
|
||||||
const next: Record<string, string> = {};
|
| undefined;
|
||||||
if (data?.decorations && typeof data.decorations === "object") {
|
const next: Record<string, string> = {};
|
||||||
for (const [userId, url] of Object.entries(data.decorations)) {
|
if (data?.decorations && typeof data.decorations === "object") {
|
||||||
const safe = safeImageUrl(url);
|
for (const [userId, url] of Object.entries(data.decorations)) {
|
||||||
if (safe) next[userId] = safe;
|
const safe = safeImageUrl(url);
|
||||||
|
if (safe) next[userId] = safe;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
decorations = next;
|
||||||
decorations = next;
|
emit();
|
||||||
emit();
|
};
|
||||||
};
|
w.lazyActions.on(LotusWidgetActions.Decorations, handler);
|
||||||
|
unregister = () =>
|
||||||
|
w.lazyActions.off(LotusWidgetActions.Decorations, handler);
|
||||||
|
}
|
||||||
|
registrations += 1;
|
||||||
|
|
||||||
w.lazyActions.on(LotusWidgetActions.Decorations, handler);
|
|
||||||
return () => {
|
return () => {
|
||||||
w.lazyActions.off(LotusWidgetActions.Decorations, handler);
|
registrations -= 1;
|
||||||
decorations = {};
|
if (registrations <= 0) {
|
||||||
emit();
|
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.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ Please see LICENSE in the repository root for full details.
|
|||||||
mix-blend-mode: multiply;
|
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
|
/* 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 */
|
unconditionally select the container so we can use cqmin units */
|
||||||
@container mediaView (width > 0) {
|
@container mediaView (width > 0) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico
|
|||||||
|
|
||||||
import styles from "./MediaView.module.css";
|
import styles from "./MediaView.module.css";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
|
import { useLotusDecoration } from "../lotus/lotusDecorations";
|
||||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||||
import {
|
import {
|
||||||
showConnectionStats as showConnectionStatsSetting,
|
showConnectionStats as showConnectionStatsSetting,
|
||||||
@@ -91,6 +92,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const decoration = useLotusDecoration(userId);
|
||||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||||
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
||||||
|
|
||||||
@@ -137,6 +139,17 @@ export const MediaView: FC<Props> = ({
|
|||||||
})}
|
})}
|
||||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
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.
|
||||||
|
<img
|
||||||
|
className={styles.lotusDecoration}
|
||||||
|
src={decoration}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{video?.publication !== undefined && (
|
{video?.publication !== undefined && (
|
||||||
<VideoTrack
|
<VideoTrack
|
||||||
trackRef={video}
|
trackRef={video}
|
||||||
|
|||||||
@@ -18,20 +18,3 @@ Please see LICENSE in the repository root for full details.
|
|||||||
/* TODO: make this --cpd-color-fg-primary when available. */
|
/* TODO: make this --cpd-color-fg-primary when available. */
|
||||||
color: var(--cpd-color-text-primary);
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-14
@@ -10,7 +10,6 @@ import { InlineSpinner } from "@vector-im/compound-web";
|
|||||||
|
|
||||||
import styles from "./TileAvatar.module.css";
|
import styles from "./TileAvatar.module.css";
|
||||||
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
||||||
import { useLotusDecoration } from "../lotus/lotusDecorations";
|
|
||||||
|
|
||||||
interface Props extends AvatarProps {
|
interface Props extends AvatarProps {
|
||||||
size: number;
|
size: number;
|
||||||
@@ -18,26 +17,14 @@ interface Props extends AvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
|
export const TileAvatar: FC<Props> = ({ 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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className={styles.loading}>
|
<div className={styles.loading}>
|
||||||
<InlineSpinner size={size / 3} />
|
<InlineSpinner size={size / 3} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Avatar size={size} {...props} />
|
<Avatar size={size} {...props} />
|
||||||
{decoration && (
|
|
||||||
<img
|
|
||||||
className={styles.lotusDecoration}
|
|
||||||
src={decoration}
|
|
||||||
alt=""
|
|
||||||
aria-hidden
|
|
||||||
style={{ width: size, height: size }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user