diff --git a/css/tailwind.source.css b/css/tailwind.source.css index f1d9f539..45f2c793 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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; diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 36e4fc91..94986672 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -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 ( diff --git a/js/channelProfile.js b/js/channelProfile.js index 2da3d44a..e5bb0f87 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -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, diff --git a/js/ui/adminBadge.js b/js/ui/adminBadge.js new file mode 100644 index 00000000..7ff91333 --- /dev/null +++ b/js/ui/adminBadge.js @@ -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 }; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 69cd119e..7ab731a7 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -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(); diff --git a/js/ui/components/video-modal/commentNodeFactory.js b/js/ui/components/video-modal/commentNodeFactory.js index 9eadd4f7..600a9439 100644 --- a/js/ui/components/video-modal/commentNodeFactory.js +++ b/js/ui/components/video-modal/commentNodeFactory.js @@ -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"); diff --git a/js/ui/dm/Avatar.js b/js/ui/dm/Avatar.js index 5a92de38..8c11775e 100644 --- a/js/ui/dm/Avatar.js +++ b/js/ui/dm/Avatar.js @@ -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; } diff --git a/js/ui/dm/ContactRow.js b/js/ui/dm/ContactRow.js index 0a280b89..408930ae 100644 --- a/js/ui/dm/ContactRow.js +++ b/js/ui/dm/ContactRow.js @@ -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); diff --git a/tests/admin-badge.test.mjs b/tests/admin-badge.test.mjs new file mode 100644 index 00000000..43378717 --- /dev/null +++ b/tests/admin-badge.test.mjs @@ -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(""); +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); +}); diff --git a/views/channel-profile.html b/views/channel-profile.html index d73fcf3c..652233f2 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -23,16 +23,18 @@ aria-live="polite" >
-
- Avatar -
+
+
+ Avatar +
+

User Name

npub...