Admin star badge on admin avatars across the app

Show a small gold "verified admin" star in the corner opposite the red
notification dot whenever a rendered profile belongs to a bitvid admin
(the super admin or a current list editor).

- adminBadge.js (new): isAdminActor(hex|npub) sync check against
  accessControl, and applyAdminStar(container, id) which adds/removes a
  single `.admin-star` in a non-clipped, positioned container. Idempotent.
- CSS: `.admin-star` (top-left gold disc, mirrors the top-right dot),
  `.dm-avatar-wrap`, and position:relative on the comment avatar wrapper.
- Profile button: app.refreshAdminStar() lights the logged-in user's star;
  re-runs on login/logout and on accessControl.onEditorsChange (so a
  late-loading editor list still resolves).
- Comments: star on comment-author avatars (commentNodeFactory).
- Channel page: wrap the clipped avatar and star the channel owner.
- DMs: Avatar gains an opt-in adminId — only admin avatars get wrapped so
  non-admin rows are byte-identical; ContactRow passes the peer pubkey.
- tests: admin-badge unit coverage (recognition, idempotency, removal).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-07 10:10:19 -04:00
1 parent 2803f6b56f
commit 610935ac03
10 files changed
+233 -10

No files matched your search

+37
View File
@@ -252,6 +252,34 @@
display: block;
}
/* Admin "verified" star — sits in the corner opposite the notification dot.
A gold star on a page-colored disc so it stays legible on any avatar, and
pairs visually with the red dot. Positioned top-left by default (the dot is
top-right); per-surface rules below tune the inset for larger avatars. */
.admin-star {
position: absolute;
top: 0;
left: 0;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--color-page);
background-color: var(--status-warning);
color: var(--color-warning-on);
font-size: var(--font-size-3xs);
line-height: 1;
pointer-events: none;
}
#profileButton .admin-star {
top: var(--space-2xs);
left: var(--space-2xs);
}
.header-icon-toggle[data-theme-state="light"]:hover,
.header-icon-toggle[data-theme-state="light"]:focus-visible {
color: var(--color-text);
@@ -2706,6 +2734,7 @@
}
.comment-thread__avatar {
position: relative;
inline-size: var(--comment-avatar-size);
block-size: var(--comment-avatar-size);
}
@@ -5731,6 +5760,14 @@
font-size: var(--font-size-3xs);
}
/* Non-clipped relative shell used only when a DM avatar needs an admin star
in its corner (the .dm-avatar itself clips to a circle). */
.dm-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.dm-avatar {
display: inline-flex;
align-items: center;
+6
View File
@@ -240,6 +240,9 @@ export function createAuthSessionCoordinator(deps) {
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-login" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
const currentView = getHashViewName();
const normalizedView =
@@ -1049,6 +1052,9 @@ export function createAuthSessionCoordinator(deps) {
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-logout" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
const logoutView = getHashViewName();
if (
+2
View File
@@ -10,6 +10,7 @@ import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
import { applyAdminStar } from "./ui/adminBadge.js";
import { getApplication } from "./applicationContext.js";
import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
@@ -5159,6 +5160,7 @@ function applyChannelProfileMetadata({
}
const blurPubkey = pubkey || currentChannelHex || "";
applyAdminStar(document.getElementById("channelAvatarWrap"), blurPubkey);
updateChannelModerationVisuals({
bannerEl,
avatarEl,
+85
View File
@@ -0,0 +1,85 @@
// 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.
//
// `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.
import { accessControl } from "../accessControl.js";
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
export const ADMIN_STAR_CLASS = "admin-star";
/**
* Is the given actor (hex pubkey or npub) a bitvid admin?
* @param {string} pubkeyOrNpub
* @returns {boolean}
*/
export function isAdminActor(pubkeyOrNpub) {
const npub = safeEncodeNpub(pubkeyOrNpub);
if (!npub) {
return false;
}
try {
return Boolean(accessControl?.isAdminEditor?.(npub));
} catch (error) {
return false;
}
}
function findDirectStar(container) {
for (const child of Array.from(container.children || [])) {
if (child.classList?.contains(ADMIN_STAR_CLASS)) {
return child;
}
}
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" } = {},
) {
if (!container || typeof container.querySelector !== "function" || !doc) {
return false;
}
const show = isAdminActor(pubkeyOrNpub);
const existing = findDirectStar(container);
if (show) {
if (!existing) {
const star = doc.createElement("span");
star.className = ADMIN_STAR_CLASS;
star.setAttribute("aria-hidden", "true");
star.title = label;
star.textContent = "★";
container.appendChild(star);
}
return true;
}
if (existing) {
existing.remove();
}
return false;
}
export default { ADMIN_STAR_CLASS, isAdminActor, applyAdminStar };
+22
View File
@@ -35,6 +35,7 @@ import { relayManager } from "../relayManager.js";
import { userBlocks } from "../userBlocks.js";
import { subscriptions } from "../subscriptions.js";
import { accessControl } from "../accessControl.js";
import { applyAdminStar } from "./adminBadge.js";
import moderationService from "../services/moderationService.js";
import ProfileIdentityController from "./profileIdentityController.js";
import VideoListViewController from "./videoListViewController.js";
@@ -977,6 +978,21 @@ export default class ApplicationBootstrap {
app.applyProfileNotificationDot();
};
// The profile button shows the logged-in user; light its admin star when
// that user is a bitvid admin. Re-runs on editor-list load so a moderator
// added after startup still gets the star.
app.refreshAdminStar = () => {
try {
const npub =
typeof app.getCurrentUserNpub === "function"
? app.getCurrentUserNpub()
: "";
applyAdminStar(app.profileButton, npub || "");
} catch (error) {
devLogger.warn("[Application] Failed to refresh admin star", error);
}
};
const nostrUnsubscribes = [];
nostrUnsubscribes.push(
app.nostrService.on("subscription:changed", ({ subscription }) => {
@@ -1050,6 +1066,12 @@ export default class ApplicationBootstrap {
}
nostrUnsubscribes.push(() => clearInterval(submissionsPollId));
void app.refreshSubmissionsIndicator({ reason: "startup" });
if (typeof accessControl.onEditorsChange === "function") {
nostrUnsubscribes.push(
accessControl.onEditorsChange(() => app.refreshAdminStar?.()),
);
}
app.refreshAdminStar();
app.unsubscribeFromNostrService = () => {
while (nostrUnsubscribes.length) {
const unsubscribe = nostrUnsubscribes.pop();
@@ -1,6 +1,7 @@
import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js";
import { formatAbsoluteTimestamp, formatTimeAgo } from "../../../utils/formatters.js";
import { registerCommentAvatarFailure as registerCommentAvatarFailureUtil } from "./utils/commentAvatar.js";
import { applyAdminStar } from "../../adminBadge.js";
export class CommentNodeFactory {
constructor({ document, window, logger, dispatch, DEFAULT_PROFILE_AVATAR }) {
@@ -45,6 +46,13 @@ export class CommentNodeFactory {
const avatarLabel = this.document.createElement("span");
avatarWrapper.appendChild(avatarLabel);
// Gold admin star for comments authored by a bitvid admin.
if (typeof event.pubkey === "string" && event.pubkey.trim()) {
applyAdminStar(avatarWrapper, event.pubkey.trim(), {
doc: this.document,
});
}
const content = this.document.createElement("div");
content.classList.add("comment-thread__content");
+15
View File
@@ -1,3 +1,5 @@
import { applyAdminStar, isAdminActor } from "../adminBadge.js";
const DEFAULT_AVATAR_ALT = "Avatar";
function getInitials(label = "") {
@@ -19,6 +21,7 @@ export function Avatar({
size = "md",
initials = "",
status = "",
adminId = "",
} = {}) {
if (!doc) {
throw new Error("Avatar requires a document reference.");
@@ -53,5 +56,17 @@ 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.
if (adminId && isAdminActor(adminId)) {
const wrap = doc.createElement("span");
wrap.className = "dm-avatar-wrap";
wrap.appendChild(avatar);
applyAdminStar(wrap, adminId, { doc });
return wrap;
}
return avatar;
}
+2
View File
@@ -32,6 +32,7 @@ export function ContactRow({
avatarSrc = "",
status = "",
zapTotalSats = null,
pubkey = "",
} = contact;
const button = createElement(doc, "button", "dm-contact-row");
@@ -51,6 +52,7 @@ export function ContactRow({
size: "md",
initials: name,
status,
adminId: pubkey || id,
});
button.appendChild(avatar);
+44
View File
@@ -0,0 +1,44 @@
import { test } from "node:test";
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";
const dom = new JSDOM("<!doctype html><body></body>");
const doc = dom.window.document;
// A validly-prefixed but non-admin npub. isAdminActor compares against the
// admin lists, so it doesn't need to be a real bech32 payload.
const STRANGER_NPUB = "npub1" + "q".repeat(58);
test("isAdminActor recognizes the super admin and rejects a stranger", () => {
assert.equal(isAdminActor(ADMIN_SUPER_NPUB), true);
assert.equal(isAdminActor(STRANGER_NPUB), false);
assert.equal(isAdminActor(""), false);
assert.equal(isAdminActor(null), false);
});
test("applyAdminStar adds exactly one star for an admin and is idempotent", () => {
const el = doc.createElement("div");
assert.equal(applyAdminStar(el, ADMIN_SUPER_NPUB, { doc }), true);
// second call must not duplicate the badge
applyAdminStar(el, ADMIN_SUPER_NPUB, { doc });
const stars = el.querySelectorAll(".admin-star");
assert.equal(stars.length, 1);
assert.equal(stars[0].getAttribute("aria-hidden"), "true");
assert.equal(stars[0].textContent, "★");
});
test("applyAdminStar renders nothing for a non-admin and clears a stale star", () => {
const el = doc.createElement("div");
applyAdminStar(el, ADMIN_SUPER_NPUB, { doc });
assert.equal(el.querySelectorAll(".admin-star").length, 1);
// the same node later shows a non-admin → the star must be removed
assert.equal(applyAdminStar(el, STRANGER_NPUB, { doc }), false);
assert.equal(el.querySelectorAll(".admin-star").length, 0);
});
test("applyAdminStar is a no-op with no container", () => {
assert.equal(applyAdminStar(null, ADMIN_SUPER_NPUB, { doc }), false);
});
+12 -10
View File
@@ -23,16 +23,18 @@
aria-live="polite"
></div>
<div class="flex items-center">
<div
class="h-16 w-16 overflow-hidden rounded-full border-2 border-border-translucent-bright max-sm:h-12 max-sm:w-12"
>
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="h-full w-full object-cover"
/>
</div>
<div id="channelAvatarWrap" class="relative">
<div
class="h-16 w-16 overflow-hidden rounded-full border-2 border-border-translucent-bright max-sm:h-12 max-sm:w-12"
>
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="h-full w-full object-cover"
/>
</div>
</div>
<div class="ml-4 text-text">
<h2 id="channelName" class="text-xl font-bold max-sm:text-base">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80 max-sm:text-2xs">npub...</p>