mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
2803f6b56f
commit
610935ac03
10 files changed
+233
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user