Admin ring + star across avatars (identity, switcher, cards, player, DMs)

Add a reusable decorateAdminAvatar(avatarEl, id) that applies BOTH the gold
ring (box-shadow, not clipped by the avatar's overflow) and the corner star
(hung off a non-clipped .admin-avatar-wrap it ensures around the avatar).
Idempotent; works on attached or freshly-built detached avatars.

Wired onto:
- profile modal "Acting as" identity pill
- account-switcher saved-profile avatars
- video card creator avatars
- video player creator avatar
- DM conversation list + message bubbles (upgraded from star-only to ring+star)

adminBadge.js: split the star decision from its DOM (setAdminStar) so the
ring and star share one isAdminActor check; added ADMIN_RING_CLASS /
ADMIN_WRAP_CLASS. tests: decorateAdminAvatar (wrap/ring/star, idempotency,
non-admin, detached).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-07 14:24:16 -04:00
1 parent fdddbc2bb4
commit 37edbd18d2
8 files changed
+189 -53

No files matched your search

+17
View File
@@ -280,6 +280,23 @@
left: var(--space-2xs);
}
/* Non-clipped relative shell that decorateAdminAvatar wraps around a circular
avatar so the corner star isn't clipped. Sizes to the avatar it holds. */
.admin-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
/* Gold ring hugging an admin avatar. A thin page-colored gap makes it read as
a ring, not a border. box-shadow isn't clipped by the avatar's own
overflow, so it works on clipped circular avatars. */
.admin-ring {
box-shadow:
0 0 0 var(--border-width-hairline) var(--color-page),
0 0 0 calc(var(--border-width-hairline) * 3) var(--status-warning);
}
/* Gold ring around the avatar when the logged-in user is an admin — the
unmistakable "you're an admin" signal on your own profile button. A thin
page-colored gap separates the avatar from the ring so it reads as a ring,
+90 -33
View File
@@ -1,20 +1,25 @@
// js/ui/adminBadge.js
//
// Shared "this profile belongs to a bitvid admin" star badge. An admin is the
// super admin OR a current list editor (moderator). The star is rendered as a
// small gold marker in the corner opposite the red notification dot, so a
// profile can show both at once. Styling lives in the `.admin-star` CSS rules.
// Shared "this profile belongs to a bitvid admin" badges. An admin is the super
// admin OR a current list editor (moderator). Two visual treatments, driven off
// one synchronous check (`isAdminActor`):
// • a small gold STAR in the corner opposite the red notification dot
// (`.admin-star`), and
// • a gold RING around the avatar (`.admin-ring`).
// Styling lives in the matching CSS rules.
//
// `isAdminActor` is a synchronous check, so editors added to the admin lists
// only show the star once those lists have loaded from relays; the super admin
// (known from config) always resolves immediately. Surfaces that persist across
// a session (e.g. the profile button) should re-run applyAdminStar on
// accessControl.onEditorsChange so a late-loading editor list still lights up.
// `isAdminActor` is synchronous, so editors added to the admin lists only show
// once those lists have loaded from relays; the super admin (known from config)
// always resolves immediately. Surfaces that persist across a session (e.g. the
// profile button) should re-decorate on accessControl.onEditorsChange so a
// late-loading editor list still lights up.
import { accessControl } from "../accessControl.js";
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
export const ADMIN_STAR_CLASS = "admin-star";
export const ADMIN_RING_CLASS = "admin-ring";
export const ADMIN_WRAP_CLASS = "admin-avatar-wrap";
/**
* Is the given actor (hex pubkey or npub) a bitvid admin?
@@ -42,28 +47,12 @@ function findDirectStar(container) {
return null;
}
/**
* Show or hide the admin star inside `container`. The container MUST be a
* position:relative, non-clipped element (the star is absolutely positioned in
* its corner) — for avatars that clip to a circle, pass a wrapper around the
* clipped element, not the clipped element itself.
*
* Idempotent: safe to call repeatedly; it reuses/removes its own star only.
*
* @param {Element|null} container
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {boolean} whether a star is present after the call
*/
export function applyAdminStar(
container,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
// Pure DOM: add/remove the star in `container` given an already-computed
// decision. `container` MUST be positioned + non-clipped.
function setAdminStar(container, show, { doc, label }) {
if (!container || typeof container.querySelector !== "function" || !doc) {
return false;
}
const show = isAdminActor(pubkeyOrNpub);
const existing = findDirectStar(container);
if (show) {
if (!existing) {
@@ -74,12 +63,80 @@ export function applyAdminStar(
star.textContent = "★";
container.appendChild(star);
}
return true;
}
if (existing) {
} else if (existing) {
existing.remove();
}
return false;
return show;
}
export default { ADMIN_STAR_CLASS, isAdminActor, applyAdminStar };
/**
* Show or hide the admin star inside `container`. The container MUST be a
* position:relative, non-clipped element (the star is absolutely positioned in
* its corner) — for avatars that clip to a circle, pass a wrapper around the
* clipped element, not the clipped element itself.
*
* Idempotent. Returns whether a star is present after the call.
*
* @param {Element|null} container
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {boolean}
*/
export function applyAdminStar(
container,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
return setAdminStar(container, isAdminActor(pubkeyOrNpub), { doc, label });
}
/**
* Decorate a circular avatar element with BOTH the admin ring and the corner
* star. `avatarEl` is the circular element (an <img> or a clip container). The
* ring is a box-shadow on `avatarEl` itself (not clipped by its own overflow);
* the star needs a non-clipped, positioned wrapper, so this ensures `avatarEl`
* is wrapped in a `.admin-avatar-wrap` span and hangs the star off that.
*
* Idempotent — reuses an existing wrapper. Works whether `avatarEl` is already
* attached to the DOM (wraps it in place) or freshly built and detached (wraps
* it and returns the wrapper for the caller to append).
*
* @param {Element|null} avatarEl
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {Element|null} the wrapper node (append THIS, not the bare avatar)
*/
export function decorateAdminAvatar(
avatarEl,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
if (!avatarEl || !avatarEl.classList || !doc) {
return avatarEl || null;
}
const isAdmin = isAdminActor(pubkeyOrNpub);
avatarEl.classList.toggle(ADMIN_RING_CLASS, isAdmin);
let wrap = avatarEl.parentElement;
const alreadyWrapped =
wrap && wrap.classList && wrap.classList.contains(ADMIN_WRAP_CLASS);
if (!alreadyWrapped) {
wrap = doc.createElement("span");
wrap.className = ADMIN_WRAP_CLASS;
if (avatarEl.parentElement) {
avatarEl.parentElement.insertBefore(wrap, avatarEl);
}
wrap.appendChild(avatarEl);
}
setAdminStar(wrap, isAdmin, { doc, label });
return wrap;
}
export default {
ADMIN_STAR_CLASS,
ADMIN_RING_CLASS,
ADMIN_WRAP_CLASS,
isAdminActor,
applyAdminStar,
decorateAdminAvatar,
};
+6 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../moderationUiHelpers.js";
import { buildModerationBadgeText } from "../moderationCopy.js";
import { HEX64_REGEX } from "../../utils/hex.js";
import { decorateAdminAvatar } from "../adminBadge.js";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
@@ -1004,7 +1005,11 @@ export class VideoCard {
authorMeta.appendChild(authorName);
authorMeta.appendChild(metadata);
wrapper.appendChild(avatarWrapper);
// Ring + star on the creator avatar when the author is a bitvid admin.
const avatarNode = decorateAdminAvatar(avatarWrapper, this.video.pubkey || "", {
doc: this.document,
});
wrapper.appendChild(avatarNode);
wrapper.appendChild(authorMeta);
this.authorPicEl = avatar;
+5
View File
@@ -6,6 +6,7 @@ import {
} from "./videoMenuRenderers.js";
import { attachAmbientBackground } from "../ambientBackground.js";
import { applyDesignSystemAttributes } from "../../designSystem.js";
import { decorateAdminAvatar } from "../adminBadge.js";
import { devLogger } from "../../utils/logger.js";
import { CommentsController } from "./video-modal/commentsController.js";
import { ReactionsController } from "./video-modal/reactionsController.js";
@@ -2464,6 +2465,10 @@ export class VideoModal {
if (this.creatorAvatar) {
this.creatorAvatar.src = avatarUrl || "assets/svg/default-profile.svg";
this.creatorAvatar.alt = name || "Unknown";
// Ring + star on the player's creator avatar when the author is admin.
if (this.creatorAvatar.parentElement) {
decorateAdminAvatar(this.creatorAvatar.parentElement, npub || "");
}
}
if (this.creatorNpub) {
this.creatorNpub.textContent = npub || "";
+4 -10
View File
@@ -1,4 +1,4 @@
import { applyAdminStar, isAdminActor } from "../adminBadge.js";
import { decorateAdminAvatar, isAdminActor } from "../adminBadge.js";
const DEFAULT_AVATAR_ALT = "Avatar";
@@ -56,16 +56,10 @@ export function Avatar({
avatar.appendChild(fallback);
}
// The circular avatar clips its contents, so an admin star can't live inside
// it. Only when the actor is an admin do we wrap the avatar in a non-clipped
// relative container and hang the star off that — non-admin avatars are
// returned exactly as before to keep existing layouts untouched.
// Only admin avatars get decorated (ring + wrapped corner star); non-admin
// avatars are returned exactly as before to keep existing layouts untouched.
if (adminId && isAdminActor(adminId)) {
const wrap = doc.createElement("span");
wrap.className = "dm-avatar-wrap";
wrap.appendChild(avatar);
applyAdminStar(wrap, adminId, { doc });
return wrap;
return decorateAdminAvatar(avatar, adminId, { doc });
}
return avatar;
+4 -7
View File
@@ -1,4 +1,4 @@
import { applyAdminStar, isAdminActor } from "../adminBadge.js";
import { decorateAdminAvatar, isAdminActor } from "../adminBadge.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
@@ -36,14 +36,11 @@ export function MessageBubble({
avatar.alt = variant === "outgoing" ? "Me" : "Sender";
// The message avatar is a bare circular <img>; when its author is an admin,
// wrap it in a non-clipped relative shell so the star can sit in the corner.
// Non-admin bubbles keep the plain <img> so existing layout is untouched.
// decorate it (ring + wrapped corner star). Non-admin bubbles keep the plain
// <img> so existing layout is untouched.
let avatarNode = avatar;
if (adminId && isAdminActor(adminId)) {
const wrap = createElement(doc, "span", "dm-message-avatar-wrap");
wrap.appendChild(avatar);
applyAdminStar(wrap, adminId, { doc });
avatarNode = wrap;
avatarNode = decorateAdminAvatar(avatar, adminId, { doc });
}
const bubble = createElement(doc, "div", "dm-message-bubble");
+11 -1
View File
@@ -55,6 +55,7 @@ import { ProfileModerationController } from "./profileModal/ProfileModerationCon
import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js";
import { ProfileEditController } from "./profileModal/ProfileEditController.js";
import { isInStackedModal } from "./focusTrapStacking.js";
import { decorateAdminAvatar } from "./adminBadge.js";
const noop = () => {};
@@ -2606,6 +2607,13 @@ export class ProfileModalController {
if (this.identityCardAvatar.src !== activeAvatarSrc) {
this.identityCardAvatar.src = activeAvatarSrc;
}
// Ring + star on the "Acting as" avatar when the active profile is admin.
if (this.identityCardAvatar.parentElement) {
decorateAdminAvatar(
this.identityCardAvatar.parentElement,
hasActiveProfile ? activeMeta?.npub || "" : "",
);
}
}
const listEl = this.switcherList;
@@ -2744,7 +2752,9 @@ export class ProfileModalController {
}
metaSpan.append(topLine, nameSpan, npubSpan);
button.append(avatarSpan, metaSpan);
// Ring + star on a saved profile's avatar when that account is admin.
const avatarNode = decorateAdminAvatar(avatarSpan, entry.pubkey);
button.append(avatarNode, metaSpan);
const ariaLabel = isSelected
? `${cardDisplayName} selected`
+52 -1
View File
@@ -3,7 +3,11 @@ import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { ADMIN_SUPER_NPUB } from "../js/config.js";
import { applyAdminStar, isAdminActor } from "../js/ui/adminBadge.js";
import {
applyAdminStar,
decorateAdminAvatar,
isAdminActor,
} from "../js/ui/adminBadge.js";
const dom = new JSDOM("<!doctype html><body></body>");
const doc = dom.window.document;
@@ -42,3 +46,50 @@ test("applyAdminStar renders nothing for a non-admin and clears a stale star", (
test("applyAdminStar is a no-op with no container", () => {
assert.equal(applyAdminStar(null, ADMIN_SUPER_NPUB, { doc }), false);
});
test("decorateAdminAvatar wraps, rings, and stars an admin avatar (attached)", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
const wrap = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
// wrapped in place, ring on the avatar, star on the wrap
assert.ok(wrap.classList.contains("admin-avatar-wrap"));
assert.equal(avatar.parentElement, wrap);
assert.equal(wrap.parentElement, parent, "wrapper takes the avatar's place");
assert.ok(avatar.classList.contains("admin-ring"));
assert.equal(wrap.querySelectorAll(".admin-star").length, 1);
});
test("decorateAdminAvatar is idempotent and reuses the wrapper", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
const first = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
const second = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
assert.equal(first, second, "same wrapper reused");
assert.equal(parent.querySelectorAll(".admin-avatar-wrap").length, 1);
assert.equal(first.querySelectorAll(".admin-star").length, 1);
});
test("decorateAdminAvatar adds no ring/star for a non-admin", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
decorateAdminAvatar(avatar, STRANGER_NPUB, { doc });
assert.equal(avatar.classList.contains("admin-ring"), false);
// wrapper may exist but must carry no star
assert.equal(parent.querySelectorAll(".admin-star").length, 0);
});
test("decorateAdminAvatar handles a detached avatar (returns the wrapper to append)", () => {
const avatar = doc.createElement("span");
const wrap = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
assert.ok(wrap.classList.contains("admin-avatar-wrap"));
assert.equal(avatar.parentElement, wrap);
assert.ok(avatar.classList.contains("admin-ring"));
assert.equal(wrap.querySelectorAll(".admin-star").length, 1);
});