diff --git a/css/tailwind.source.css b/css/tailwind.source.css index eae3d05f..36437bb4 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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, diff --git a/js/ui/adminBadge.js b/js/ui/adminBadge.js index 7ff91333..2221e6e0 100644 --- a/js/ui/adminBadge.js +++ b/js/ui/adminBadge.js @@ -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 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, +}; diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 8c84bafa..39016da4 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -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; diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 4ea29c04..e2a2d772 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -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 || ""; diff --git a/js/ui/dm/Avatar.js b/js/ui/dm/Avatar.js index 8c11775e..a94c23c8 100644 --- a/js/ui/dm/Avatar.js +++ b/js/ui/dm/Avatar.js @@ -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; diff --git a/js/ui/dm/MessageBubble.js b/js/ui/dm/MessageBubble.js index 53155bbf..e3c782fc 100644 --- a/js/ui/dm/MessageBubble.js +++ b/js/ui/dm/MessageBubble.js @@ -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 ; 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 so existing layout is untouched. + // decorate it (ring + wrapped corner star). Non-admin bubbles keep the plain + // 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"); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 561d3a7f..01be1cfe 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -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` diff --git a/tests/admin-badge.test.mjs b/tests/admin-badge.test.mjs index 43378717..8cfb3b8b 100644 --- a/tests/admin-badge.test.mjs +++ b/tests/admin-badge.test.mjs @@ -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(""); 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); +});