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;
|
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"]:hover,
|
||||||
.header-icon-toggle[data-theme-state="light"]:focus-visible {
|
.header-icon-toggle[data-theme-state="light"]:focus-visible {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -2706,6 +2734,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-thread__avatar {
|
.comment-thread__avatar {
|
||||||
|
position: relative;
|
||||||
inline-size: var(--comment-avatar-size);
|
inline-size: var(--comment-avatar-size);
|
||||||
block-size: var(--comment-avatar-size);
|
block-size: var(--comment-avatar-size);
|
||||||
}
|
}
|
||||||
@@ -5731,6 +5760,14 @@
|
|||||||
font-size: var(--font-size-3xs);
|
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 {
|
.dm-avatar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -240,6 +240,9 @@ export function createAuthSessionCoordinator(deps) {
|
|||||||
if (typeof this.refreshSubmissionsIndicator === "function") {
|
if (typeof this.refreshSubmissionsIndicator === "function") {
|
||||||
void this.refreshSubmissionsIndicator({ reason: "auth-login" });
|
void this.refreshSubmissionsIndicator({ reason: "auth-login" });
|
||||||
}
|
}
|
||||||
|
if (typeof this.refreshAdminStar === "function") {
|
||||||
|
this.refreshAdminStar();
|
||||||
|
}
|
||||||
|
|
||||||
const currentView = getHashViewName();
|
const currentView = getHashViewName();
|
||||||
const normalizedView =
|
const normalizedView =
|
||||||
@@ -1049,6 +1052,9 @@ export function createAuthSessionCoordinator(deps) {
|
|||||||
if (typeof this.refreshSubmissionsIndicator === "function") {
|
if (typeof this.refreshSubmissionsIndicator === "function") {
|
||||||
void this.refreshSubmissionsIndicator({ reason: "auth-logout" });
|
void this.refreshSubmissionsIndicator({ reason: "auth-logout" });
|
||||||
}
|
}
|
||||||
|
if (typeof this.refreshAdminStar === "function") {
|
||||||
|
this.refreshAdminStar();
|
||||||
|
}
|
||||||
|
|
||||||
const logoutView = getHashViewName();
|
const logoutView = getHashViewName();
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { subscriptions } from "./subscriptions.js";
|
|||||||
import { attachHealthBadges } from "./gridHealth.js";
|
import { attachHealthBadges } from "./gridHealth.js";
|
||||||
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||||
import { accessControl } from "./accessControl.js";
|
import { accessControl } from "./accessControl.js";
|
||||||
|
import { applyAdminStar } from "./ui/adminBadge.js";
|
||||||
import { getApplication } from "./applicationContext.js";
|
import { getApplication } from "./applicationContext.js";
|
||||||
import { escapeHTML } from "./utils/domUtils.js";
|
import { escapeHTML } from "./utils/domUtils.js";
|
||||||
import { formatShortNpub } from "./utils/formatters.js";
|
import { formatShortNpub } from "./utils/formatters.js";
|
||||||
@@ -5159,6 +5160,7 @@ function applyChannelProfileMetadata({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blurPubkey = pubkey || currentChannelHex || "";
|
const blurPubkey = pubkey || currentChannelHex || "";
|
||||||
|
applyAdminStar(document.getElementById("channelAvatarWrap"), blurPubkey);
|
||||||
updateChannelModerationVisuals({
|
updateChannelModerationVisuals({
|
||||||
bannerEl,
|
bannerEl,
|
||||||
avatarEl,
|
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 { userBlocks } from "../userBlocks.js";
|
||||||
import { subscriptions } from "../subscriptions.js";
|
import { subscriptions } from "../subscriptions.js";
|
||||||
import { accessControl } from "../accessControl.js";
|
import { accessControl } from "../accessControl.js";
|
||||||
|
import { applyAdminStar } from "./adminBadge.js";
|
||||||
import moderationService from "../services/moderationService.js";
|
import moderationService from "../services/moderationService.js";
|
||||||
import ProfileIdentityController from "./profileIdentityController.js";
|
import ProfileIdentityController from "./profileIdentityController.js";
|
||||||
import VideoListViewController from "./videoListViewController.js";
|
import VideoListViewController from "./videoListViewController.js";
|
||||||
@@ -977,6 +978,21 @@ export default class ApplicationBootstrap {
|
|||||||
app.applyProfileNotificationDot();
|
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 = [];
|
const nostrUnsubscribes = [];
|
||||||
nostrUnsubscribes.push(
|
nostrUnsubscribes.push(
|
||||||
app.nostrService.on("subscription:changed", ({ subscription }) => {
|
app.nostrService.on("subscription:changed", ({ subscription }) => {
|
||||||
@@ -1050,6 +1066,12 @@ export default class ApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
nostrUnsubscribes.push(() => clearInterval(submissionsPollId));
|
nostrUnsubscribes.push(() => clearInterval(submissionsPollId));
|
||||||
void app.refreshSubmissionsIndicator({ reason: "startup" });
|
void app.refreshSubmissionsIndicator({ reason: "startup" });
|
||||||
|
if (typeof accessControl.onEditorsChange === "function") {
|
||||||
|
nostrUnsubscribes.push(
|
||||||
|
accessControl.onEditorsChange(() => app.refreshAdminStar?.()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.refreshAdminStar();
|
||||||
app.unsubscribeFromNostrService = () => {
|
app.unsubscribeFromNostrService = () => {
|
||||||
while (nostrUnsubscribes.length) {
|
while (nostrUnsubscribes.length) {
|
||||||
const unsubscribe = nostrUnsubscribes.pop();
|
const unsubscribe = nostrUnsubscribes.pop();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js";
|
import { UI_FEEDBACK_DELAY_MS } from "../../../constants.js";
|
||||||
import { formatAbsoluteTimestamp, formatTimeAgo } from "../../../utils/formatters.js";
|
import { formatAbsoluteTimestamp, formatTimeAgo } from "../../../utils/formatters.js";
|
||||||
import { registerCommentAvatarFailure as registerCommentAvatarFailureUtil } from "./utils/commentAvatar.js";
|
import { registerCommentAvatarFailure as registerCommentAvatarFailureUtil } from "./utils/commentAvatar.js";
|
||||||
|
import { applyAdminStar } from "../../adminBadge.js";
|
||||||
|
|
||||||
export class CommentNodeFactory {
|
export class CommentNodeFactory {
|
||||||
constructor({ document, window, logger, dispatch, DEFAULT_PROFILE_AVATAR }) {
|
constructor({ document, window, logger, dispatch, DEFAULT_PROFILE_AVATAR }) {
|
||||||
@@ -45,6 +46,13 @@ export class CommentNodeFactory {
|
|||||||
const avatarLabel = this.document.createElement("span");
|
const avatarLabel = this.document.createElement("span");
|
||||||
avatarWrapper.appendChild(avatarLabel);
|
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");
|
const content = this.document.createElement("div");
|
||||||
content.classList.add("comment-thread__content");
|
content.classList.add("comment-thread__content");
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { applyAdminStar, isAdminActor } from "../adminBadge.js";
|
||||||
|
|
||||||
const DEFAULT_AVATAR_ALT = "Avatar";
|
const DEFAULT_AVATAR_ALT = "Avatar";
|
||||||
|
|
||||||
function getInitials(label = "") {
|
function getInitials(label = "") {
|
||||||
@@ -19,6 +21,7 @@ export function Avatar({
|
|||||||
size = "md",
|
size = "md",
|
||||||
initials = "",
|
initials = "",
|
||||||
status = "",
|
status = "",
|
||||||
|
adminId = "",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error("Avatar requires a document reference.");
|
throw new Error("Avatar requires a document reference.");
|
||||||
@@ -53,5 +56,17 @@ export function Avatar({
|
|||||||
avatar.appendChild(fallback);
|
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;
|
return avatar;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ export function ContactRow({
|
|||||||
avatarSrc = "",
|
avatarSrc = "",
|
||||||
status = "",
|
status = "",
|
||||||
zapTotalSats = null,
|
zapTotalSats = null,
|
||||||
|
pubkey = "",
|
||||||
} = contact;
|
} = contact;
|
||||||
|
|
||||||
const button = createElement(doc, "button", "dm-contact-row");
|
const button = createElement(doc, "button", "dm-contact-row");
|
||||||
@@ -51,6 +52,7 @@ export function ContactRow({
|
|||||||
size: "md",
|
size: "md",
|
||||||
initials: name,
|
initials: name,
|
||||||
status,
|
status,
|
||||||
|
adminId: pubkey || id,
|
||||||
});
|
});
|
||||||
button.appendChild(avatar);
|
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"
|
aria-live="polite"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div id="channelAvatarWrap" class="relative">
|
||||||
class="h-16 w-16 overflow-hidden rounded-full border-2 border-border-translucent-bright max-sm:h-12 max-sm:w-12"
|
<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"
|
<img
|
||||||
src="assets/svg/default-profile.svg"
|
id="channelAvatar"
|
||||||
alt="Avatar"
|
src="assets/svg/default-profile.svg"
|
||||||
class="h-full w-full object-cover"
|
alt="Avatar"
|
||||||
/>
|
class="h-full w-full object-cover"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="ml-4 text-text">
|
<div class="ml-4 text-text">
|
||||||
<h2 id="channelName" class="text-xl font-bold max-sm:text-base">User Name</h2>
|
<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>
|
<p id="channelNpub" class="text-sm opacity-80 max-sm:text-2xs">npub...</p>
|
||||||
|
|||||||
Reference in new issue
Block a user