mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
fdddbc2bb4
commit
37edbd18d2
8 files changed
+189
-53
No files matched your search
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user