Merge branch 'unstable' into codex/update-readme.md-for-video-upload-section

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 14:24:03 -04:00
commit bb78b440bc
12 files changed
+716 -54

No files matched your search

+11 -2
View File
@@ -15,7 +15,7 @@
- **Encrypted Watch History**: Sync viewing activity privately through the NIP-04 encrypted pipeline with local fallbacks.
- **Live View Counters**: Subscribe to view events and see totals update in real time on video cards and the video modal.
- **Lightning Zaps**: Tip creators with Lightning payments via the Zap controls in the video modal.
- **Private Video Listings**: Hide cards from shared grids so only the owner sees them.
- **Private Video Listings**: Hide cards from shared grids by flipping the visibility switch in the [Edit Video modal](components/edit-video-modal.html) after publishing.
- **Nostr Integration**: Use Nostr keys for login and interaction.
- **WebTorrent Streaming**: Stream videos directly through torrent technology.
- **Developer-Friendly**: Open source and customizable for your needs.
@@ -65,7 +65,16 @@ Click a card to open the video modal and use the **Zap** button (lightning bolt
Operators can tune thresholds and lists from the Profile modal:
- The **Moderation** tab manages blur and autoplay limits plus the relay-synced mute/ban lists.
- Each video card’s **More** menu surfaces per-video actions (report, mute author, or “show anyway”) that feed into the moderation service.
- Each video card’s **More** menu surfaces per-video actions powered by `videoMenuRenderers.js`, including:
- **Repost** to boost the note as a kind 6 event.
- **Mirror** to republish the hosted URL and magnet as a kind 1063 event.
- **Rebroadcast** to push the existing event to additional relays (see cooldown note below).
- **Remove from history** to clear the item from your encrypted watch log.
- **Mute/Unmute creator** to control local visibility without severing follows.
- **Blacklist creator** for operators who maintain shared deny lists.
- **Block creator** to drop their events entirely.
- **Report** to issue a NIP-56 moderation report.
- When a rebroadcast hits rate limits, the app surfaces cooldown guidance (e.g., “Rebroadcast is cooling down. Try again in 30s.”) so operators know when to retry without spamming relays.
---
+1 -1
View File
@@ -35,7 +35,7 @@ Ready to jump in? Here's everything you need to know to start watching and shari
- Keep WebTorrent Desktop running while sharing videos
- Add eye-catching thumbnails to attract viewers
- Write clear descriptions to help people find your content
- Use the "Private" option if you only want to share with specific people
- After posting, open a card’s **More → Edit** action and use the [visibility toggle in the Edit Video modal](../components/edit-video-modal.html) to keep it private until you’re ready to share
- Encrypted watch-history sync lives in the [Watch History view](history.html), where you can clear devices or pause tracking whenever you need.
## Need Help?
+2 -4
View File
@@ -5,6 +5,7 @@
- **Universal more/settings menus** – Video cards expose consistent “more” and owner settings menus with channel navigation, sharing, blocking, and reporting actions wired through dedicated renderers.
- **Watch History view** – A dedicated watch history page ships with privacy messaging, pagination, relay sync, and local cache controls.
- **Zap controls** – Channel profiles manage zap visibility, login gating, retry flows, and receipt rendering via the Lightning controller on the page.
- **Source visibility filtering** – Lists now hide cards whose hosted URLs are offline unless their WebTorrent fallback is healthy, reusing the shared guard in `js/utils/cardSourceVisibility.js`.
## Bug Fixes
@@ -44,10 +45,7 @@
- Introduce a subscription mechanism with notifications.
- Add zaps to videos, profiles, and comments.
- Create a VRR (View, Rating, and Retention) Penalty Scoring system. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/07_bitvid_Enhanced_View_Rating_%26_Retention_Penalty_Scoring.md)
- Implement visibility filtering for videos:
- Run lightweight URL-health probes so cards stay visible when the hosted playback link responds.
- When a URL is unreachable, fall back to WebTorrent and surface items only if their magnets can stream via peers or web seeds.
- Align the filtering logic with the player’s URL-first, WebTorrent-fallback behavior during list rendering.
- Expand visibility tooling around the shipped source guard to add operator overrides, richer status messaging, and better diagnostics when both URL and magnet sources fail.
- Dynamic Home Page and Video Tracking Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_%26_Video_Tracking_Specification.md)
- Add multi-language support for content and filtration. (v4?)
- Create a settings menu for local account preferences, including relay, adult content, theme, and language.
+8
View File
@@ -1618,6 +1618,14 @@
white-space: nowrap;
}
.profile-modal__remove-button {
@apply border-2;
}
.profile-modal__remove-button:focus-visible {
--tw-ring-color: var(--color-critical-strong);
}
.profile-modal__add-profile {
--tw-ring-color: var(--color-ring-alert);
@apply focus-ring relative flex w-full items-center justify-start gap-4 rounded-xl px-5 py-4 text-left text-text transition duration-base ease-in-out;
+10
View File
@@ -35,6 +35,16 @@ Thread new moderation behaviors through the same service → stage → app → U
> You can override all defaults in **Settings → Safety & Moderation**.
## Community blacklist federation
Operators can delegate hard-hide decisions to trusted curators without giving them full admin powers. The flow is:
1. The super admin maintains a `kind 30000` list with `d=${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`. Each `a` tag references a community curator’s blacklist as `30000:<curator hex pubkey>:${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:<slug>`.
2. Every curator publishes their own `kind 30000` list using the referenced `d` tag (`${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:<slug>`) and fills it with `p` tags for accounts they want hidden.
3. bitvid automatically fetches each referenced list, merges the entries into the global blacklist, and removes any duplicates or guard-protected accounts (super admin, editors, or whitelist members).
To add a community list, append a new `a` tag to the super-admin source list and ask the curator to publish their companion `p` list. To remove a list, delete the `a` tag or ask the curator to clear their `p` entries; the client stops ingesting it on the next refresh.
### Safety & Moderation controls
The profile modal now exposes the blur and autoplay thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values above. Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load.
+208 -19
View File
@@ -5,6 +5,8 @@ import {
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
ADMIN_EDITORS_NPUBS,
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
isDevMode,
} from "./config.js";
import {
@@ -358,6 +360,88 @@ function decodeNpubToHex(npub) {
throw createError("invalid npub", "Unable to decode npub.");
}
function parseCommunityBlacklistReferences(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
const references = [];
const seen = new Set();
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const tagName = typeof tag[0] === "string" ? tag[0] : "";
if (tagName !== "a") {
continue;
}
const rawValue = typeof tag[1] === "string" ? tag[1].trim() : "";
if (!rawValue) {
continue;
}
const [kindSegment, authorSegment, ...identifierSegments] = rawValue.split(":");
if (!identifierSegments.length) {
continue;
}
const kind = Number.parseInt(kindSegment, 10);
if (!Number.isFinite(kind) || kind !== 30000) {
continue;
}
const dTagValue = identifierSegments.join(":").trim();
if (!dTagValue) {
continue;
}
if (
!dTagValue.startsWith(
`${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}`
)
) {
continue;
}
const authorCandidate = typeof authorSegment === "string" ? authorSegment.trim() : "";
if (!authorCandidate) {
continue;
}
let authorHex = "";
if (isHexPubkey(authorCandidate)) {
authorHex = authorCandidate.toLowerCase();
} else if (authorCandidate.toLowerCase().startsWith("npub")) {
try {
authorHex = decodeNpubToHex(authorCandidate);
} catch (error) {
devLogger.warn(
`[adminListStore] Failed to decode community curator npub for ${dTagValue}:`,
error,
);
authorHex = "";
}
}
if (!authorHex) {
continue;
}
const key = `${authorHex}::${dTagValue}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
references.push({ authorHex, dTag: dTagValue });
}
return references;
}
function encodeHexToNpub(hex) {
const nip19 = getNip19Tools();
const encoder = nip19?.npubEncode;
@@ -442,25 +526,40 @@ function extractNpubsFromEvent(event) {
return dedupeNpubs(npubs);
}
async function loadNostrList(identifier) {
async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
const relays = ensureNostrReady();
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const filter = {
kinds: [30000],
"#d": [dTagValue],
limit: 50,
};
const normalizedFilter = {};
if (Array.isArray(filter?.kinds) && filter.kinds.length) {
normalizedFilter.kinds = [...filter.kinds];
} else {
normalizedFilter.kinds = [30000];
}
if (typeof filter?.limit === "number") {
normalizedFilter.limit = filter.limit;
} else {
normalizedFilter.limit = 50;
}
if (Array.isArray(filter?.["#d"]) && filter["#d"].length) {
normalizedFilter["#d"] = [...filter["#d"]];
}
if (Array.isArray(filter?.authors) && filter.authors.length) {
normalizedFilter.authors = [...filter.authors];
}
let events = [];
try {
const combined = await nostrClient.pool.list(relays, [filter]);
const combined = await nostrClient.pool.list(relays, [normalizedFilter]);
if (Array.isArray(combined)) {
events = combined;
}
} catch (error) {
devLogger.warn(
`[adminListStore] Combined relay fetch failed for ${identifier}:`,
error
`[adminListStore] Combined relay fetch failed for ${contextLabel}:`,
error,
);
}
@@ -468,12 +567,12 @@ async function loadNostrList(identifier) {
const perRelay = await Promise.all(
relays.map(async (url) => {
try {
const result = await nostrClient.pool.list([url], [filter]);
const result = await nostrClient.pool.list([url], [normalizedFilter]);
return Array.isArray(result) ? result : [];
} catch (error) {
devLogger.warn(
`[adminListStore] Relay fetch failed for ${identifier} on ${url}:`,
error
`[adminListStore] Relay fetch failed for ${contextLabel} on ${url}:`,
error,
);
return [];
}
@@ -483,10 +582,10 @@ async function loadNostrList(identifier) {
}
if (!events.length) {
return [];
return null;
}
const newest = events.reduce((latest, event) => {
return events.reduce((latest, event) => {
if (!latest) {
return event;
}
@@ -495,8 +594,12 @@ async function loadNostrList(identifier) {
}
return event.created_at > latest.created_at ? event : latest;
}, null);
}
return extractNpubsFromEvent(newest);
async function loadNostrList(identifier) {
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const event = await fetchLatestListEvent({ "#d": [dTagValue] }, identifier);
return event ? extractNpubsFromEvent(event) : [];
}
async function loadNostrState() {
@@ -509,6 +612,64 @@ async function loadNostrState() {
return sanitizeAdminState({ editors, whitelist, blacklist });
}
async function loadCommunityBlacklistEntries() {
let superAdminHex = "";
try {
superAdminHex = decodeNpubToHex(ADMIN_SUPER_NPUB);
} catch (error) {
devLogger.warn(
"[adminListStore] Failed to decode super admin npub for community blacklist lookup:",
error,
);
return [];
}
const sourceIdentifier = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`;
let sourceEvent = null;
try {
sourceEvent = await fetchLatestListEvent(
{ "#d": [sourceIdentifier], authors: [superAdminHex] },
"community-blacklist-sources",
);
} catch (error) {
devLogger.warn(
"[adminListStore] Failed to load community blacklist source list:",
error,
);
return [];
}
if (!sourceEvent) {
return [];
}
const references = parseCommunityBlacklistReferences(sourceEvent);
if (!references.length) {
return [];
}
const results = await Promise.all(
references.map(async (reference) => {
try {
const event = await fetchLatestListEvent(
{ "#d": [reference.dTag], authors: [reference.authorHex] },
reference.dTag,
);
return event ? extractNpubsFromEvent(event) : [];
} catch (error) {
devLogger.warn(
`[adminListStore] Failed to load community blacklist ${reference.dTag}:`,
error,
);
return [];
}
})
);
return results.flat();
}
function buildListEvent(listKey, npubs, actorHex) {
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
@@ -797,8 +958,36 @@ export async function loadAdminState() {
);
}
const state = await loadNostrState();
if (!hasAnyEntries(state)) {
const nostrState = await loadNostrState();
let mergedState = nostrState;
try {
const communityEntries = await loadCommunityBlacklistEntries();
if (Array.isArray(communityEntries) && communityEntries.length) {
const baseEditors = Array.isArray(nostrState?.editors)
? nostrState.editors
: [];
const baseWhitelist = Array.isArray(nostrState?.whitelist)
? nostrState.whitelist
: [];
const baseBlacklist = Array.isArray(nostrState?.blacklist)
? nostrState.blacklist
: [];
mergedState = sanitizeAdminState({
editors: baseEditors,
whitelist: baseWhitelist,
blacklist: [...baseBlacklist, ...communityEntries],
});
}
} catch (error) {
devLogger.warn(
"[adminListStore] Failed to merge community blacklist entries:",
error,
);
}
if (!hasAnyEntries(mergedState)) {
const legacy = loadLegacyAdminState();
if (legacy && isDevMode) {
userLogger.warn(
@@ -808,7 +997,7 @@ export async function loadAdminState() {
}
}
return state;
return mergedState;
}
export async function persistAdminState(actorNpub, updates) {
+4
View File
@@ -566,6 +566,10 @@ class Application {
authService: this.authService,
log: (...args) => this.log(...args),
closeAllMoreMenus: (options) => this.closeAllMoreMenus(options),
clipboard:
typeof navigator !== "undefined" && navigator?.clipboard
? navigator.clipboard
: null,
};
const profileModalState = {
+2
View File
@@ -61,6 +61,8 @@ export { PLATFORM_LUD16_OVERRIDE };
export { DEFAULT_RELAY_URLS_OVERRIDE };
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
export { ALLOW_NSFW_CONTENT };
const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY;
+6
View File
@@ -1,6 +1,8 @@
import { devLogger } from "./utils/logger.js";
import {
ADMIN_LIST_NAMESPACE,
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
@@ -30,8 +32,12 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
});
export { ADMIN_COMMUNITY_BLACKLIST_SOURCES };
export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
const DEFAULT_APPEND_TAGS = [];
let cachedUtf8Encoder = null;
+229 -25
View File
@@ -1640,6 +1640,173 @@ export class ProfileModalController {
return container;
}
createViewChannelButton({ targetNpub, displayNpub } = {}) {
const normalizedTarget =
typeof targetNpub === "string" && targetNpub.trim()
? targetNpub.trim()
: "";
if (!normalizedTarget) {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = "View channel";
button.dataset.targetNpub = normalizedTarget;
if (displayNpub && typeof displayNpub === "string") {
button.setAttribute(
"aria-label",
`View channel ${displayNpub.trim() || normalizedTarget}`,
);
button.title = `View channel ${displayNpub.trim() || normalizedTarget}`;
}
button.addEventListener("click", () => {
this.callbacks.onChannelLink(button, this);
});
return button;
}
createCopyNpubButton({ targetNpub, displayNpub } = {}) {
const normalizedTarget =
typeof targetNpub === "string" && targetNpub.trim()
? targetNpub.trim()
: "";
if (!normalizedTarget) {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = "Copy npub";
if (displayNpub && typeof displayNpub === "string") {
button.setAttribute(
"aria-label",
`Copy ${displayNpub.trim() || normalizedTarget}`,
);
button.title = `Copy ${displayNpub.trim() || normalizedTarget}`;
}
const handleCopy = async () => {
if (button.dataset.state === "loading") {
return;
}
button.dataset.state = "loading";
button.disabled = true;
button.setAttribute("aria-busy", "true");
try {
await this.copyNpubToClipboard(normalizedTarget, { displayNpub });
} finally {
button.disabled = false;
button.removeAttribute("aria-busy");
delete button.dataset.state;
}
};
button.addEventListener("click", () => {
void handleCopy();
});
return button;
}
createRemoveButton({
label = "Remove",
confirmMessage,
confirmValue,
onRemove,
} = {}) {
if (typeof onRemove !== "function") {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs profile-modal__remove-button";
button.dataset.variant = "critical";
button.dataset.role = "remove";
button.textContent = label;
const handleRemove = async () => {
if (confirmMessage) {
const replacement =
typeof confirmValue === "string" && confirmValue.trim()
? confirmValue.trim()
: "this entry";
const prompt = confirmMessage.replace("{npub}", replacement);
if (!window.confirm(prompt)) {
return;
}
}
button.disabled = true;
button.setAttribute("aria-busy", "true");
try {
await onRemove(button);
} catch (error) {
userLogger.error("Failed to remove entry:", error);
} finally {
button.disabled = false;
button.removeAttribute("aria-busy");
}
};
button.addEventListener("click", () => {
void handleRemove();
});
return button;
}
async copyNpubToClipboard(npub, { displayNpub } = {}) {
const normalized =
typeof npub === "string" && npub.trim() ? npub.trim() : "";
if (!normalized) {
this.showError("Unable to copy npub. Invalid value provided.");
return { copied: false, reason: "invalid" };
}
const clipboard = (() => {
if (
this.services?.clipboard &&
typeof this.services.clipboard.writeText === "function"
) {
return this.services.clipboard;
}
if (
typeof navigator !== "undefined" &&
navigator?.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
return navigator.clipboard;
}
return null;
})();
if (!clipboard) {
this.showError("Copy to clipboard is not supported in this browser.");
return { copied: false, reason: "unsupported" };
}
try {
await clipboard.writeText(normalized);
this.showSuccess("npub copied to clipboard!");
return { copied: true };
} catch (error) {
userLogger.error("Failed to copy npub:", error);
this.showError("Failed to copy npub. Please try again.");
return { copied: false, error };
}
}
setupLayoutBreakpointObserver() {
if (
typeof window === "undefined" ||
@@ -2439,18 +2606,38 @@ export class ProfileModalController {
avatarSrc,
});
const actionBtn = document.createElement("button");
actionBtn.type = "button";
actionBtn.className = "btn-ghost focus-ring text-xs";
actionBtn.dataset.variant = "danger";
actionBtn.textContent = "Remove";
actionBtn.dataset.blockedHex = hex;
actionBtn.addEventListener("click", () => {
void this.handleRemoveBlockedCreator(hex);
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
const removeButton = this.createRemoveButton({
label: "Remove",
onRemove: () => this.handleRemoveBlockedCreator(hex),
});
if (removeButton) {
removeButton.dataset.blockedHex = hex;
actions.appendChild(removeButton);
}
item.appendChild(summary);
item.appendChild(actionBtn);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
this.blockList.appendChild(item);
});
@@ -3636,25 +3823,42 @@ export class ProfileModalController {
displayNpub,
avatarSrc,
});
item.appendChild(summary);
const actions = document.createElement("div");
actions.className =
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
if (removable && typeof onRemove === "function") {
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn-ghost focus-ring text-xs";
removeBtn.textContent = removeLabel;
removeBtn.addEventListener("click", () => {
if (confirmMessage) {
const message = confirmMessage.replace("{npub}", displayNpub);
if (!window.confirm(message)) {
return;
}
}
removeBtn.disabled = true;
removeBtn.setAttribute("aria-busy", "true");
onRemove(npub, removeBtn);
const removeBtn = this.createRemoveButton({
label: removeLabel,
confirmMessage,
confirmValue: displayNpub,
onRemove: (button) => onRemove(npub, button),
});
item.appendChild(removeBtn);
if (removeBtn) {
actions.appendChild(removeBtn);
}
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
listEl.appendChild(item);
+232 -2
View File
@@ -44,11 +44,23 @@ process.on("exit", () => {
console.warn = originalConsoleWarn;
});
const { persistAdminState, __adminListStoreTestHooks } = await import(
const {
loadAdminState,
persistAdminState,
__adminListStoreTestHooks,
} = await import(
"../js/adminListStore.js"
);
const { nostrClient } = await import("../js/nostr.js");
const { ADMIN_SUPER_NPUB } = await import("../js/config.js");
const {
ADMIN_SUPER_NPUB,
ADMIN_LIST_NAMESPACE,
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
} = await import("../js/config.js");
const { ADMIN_LIST_IDENTIFIERS } = await import(
"../js/nostrEventSchemas.js"
);
const {
extractNpubsFromEvent,
@@ -137,11 +149,61 @@ nostrClient.writeRelays = [...relays];
let publishBehaviors = [];
let publishCallIndex = 0;
const superHex = mockNip19.decode(ADMIN_SUPER_NPUB).data;
const editorNpub = "npub1editormember";
const whitelistNpub = "npub1whitelistmember";
const directBlacklistNpub = "npub1directblacklist";
const communityMemberNpub = "npub1communityblacklist";
const communitySecondMemberNpub = "npub1communitytwo";
const communityCuratorOneNpub = "npub1communitycurator";
const communityCuratorTwoNpub = "npub1communitycurator2";
const communityCuratorOneHex = mockNip19.decode(communityCuratorOneNpub).data;
const communityCuratorTwoHex = mockNip19.decode(communityCuratorTwoNpub).data;
const communityCuratorOneAuthorHex = mockNip19.decode(
communityCuratorOneHex
).data;
const communityCuratorTwoAuthorHex = mockNip19.decode(
communityCuratorTwoHex
).data;
const editorsDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.editors}`;
const whitelistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`;
const blacklistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`;
const communitySourceDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`;
const communityListOneDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:crew-alpha`;
const communityListTwoDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:crew-beta`;
function setPublishBehaviors(behaviors) {
publishBehaviors = Array.isArray(behaviors) ? behaviors : [];
publishCallIndex = 0;
}
const listEventRegistry = new Map();
let listFailureMode = null;
function resetListRegistry() {
listEventRegistry.clear();
listFailureMode = null;
}
function registerListEvent({ dTag, authorHex = null, event }) {
const key = `${authorHex || "*"}::${dTag}`;
listEventRegistry.set(key, event);
}
function createListEvent({ dTag, tags, pubkey, createdAt }) {
return {
kind: 30000,
pubkey: pubkey || "",
created_at: typeof createdAt === "number" ? createdAt : Math.floor(Date.now() / 1000),
id: `${dTag}:${pubkey || "anon"}:${createdAt || 0}`,
tags: Array.isArray(tags) ? tags : [],
};
}
function setListFailureMode(mode) {
listFailureMode = typeof mode === "string" ? mode : null;
}
nostrClient.pool = {
publish(urls) {
const url = Array.isArray(urls) && urls.length > 0 ? urls[0] : "";
@@ -186,6 +248,27 @@ nostrClient.pool = {
},
};
},
async list(relayUrls, filters) {
const filter = Array.isArray(filters) && filters.length ? filters[0] : {};
const dValues = Array.isArray(filter?.["#d"]) ? filter["#d"] : [];
const authors = Array.isArray(filter?.authors) ? filter.authors : [];
const dTag = dValues.length ? dValues[0] : "";
const authorKey = authors.length ? authors[0] : "*";
if (listFailureMode === "throw-authors" && authors.length) {
throw new Error("list failure for authors");
}
const key = `${authorKey}::${dTag}`;
const fallbackKey = `*::${dTag}`;
const event = listEventRegistry.get(key) || listEventRegistry.get(fallbackKey);
if (!event) {
return [];
}
return [event];
},
};
if (!globalThis.window.nostr) {
@@ -197,6 +280,153 @@ globalThis.window.nostr.signEvent = async (event) => ({
id: "signed-event",
});
resetListRegistry();
registerListEvent({
dTag: editorsDTag,
event: createListEvent({
dTag: editorsDTag,
pubkey: superHex,
createdAt: 1,
tags: [["p", editorNpub]],
}),
});
registerListEvent({
dTag: whitelistDTag,
event: createListEvent({
dTag: whitelistDTag,
pubkey: superHex,
createdAt: 2,
tags: [["p", whitelistNpub]],
}),
});
registerListEvent({
dTag: blacklistDTag,
event: createListEvent({
dTag: blacklistDTag,
pubkey: superHex,
createdAt: 3,
tags: [["p", directBlacklistNpub]],
}),
});
registerListEvent({
dTag: communitySourceDTag,
authorHex: superHex,
event: createListEvent({
dTag: communitySourceDTag,
pubkey: superHex,
createdAt: 4,
tags: [
["a", `30000:${communityCuratorOneHex}:${communityListOneDTag}`],
["a", `30000:${communityCuratorOneHex}:${communityListOneDTag}`],
["a", `10000:${communityCuratorOneHex}:${communityListOneDTag}`],
["a", `30000:${communityCuratorTwoHex}:${communityListTwoDTag}`],
["p", editorNpub],
],
}),
});
registerListEvent({
dTag: communityListOneDTag,
authorHex: communityCuratorOneAuthorHex,
event: createListEvent({
dTag: communityListOneDTag,
pubkey: communityCuratorOneHex,
createdAt: 5,
tags: [
["p", communityMemberNpub],
["p", directBlacklistNpub],
["p", ADMIN_SUPER_NPUB],
["p", whitelistNpub],
["p", editorNpub],
],
}),
});
registerListEvent({
dTag: communityListTwoDTag,
authorHex: communityCuratorTwoAuthorHex,
event: createListEvent({
dTag: communityListTwoDTag,
pubkey: communityCuratorTwoHex,
createdAt: 6,
tags: [
["p", communitySecondMemberNpub],
["p", communitySecondMemberNpub],
],
}),
});
const adminState = await loadAdminState();
assert.deepEqual(
adminState.editors,
[editorNpub],
"should hydrate editors from the primary admin list",
);
assert.deepEqual(
adminState.whitelist,
[whitelistNpub],
"should hydrate whitelist entries from the primary admin list",
);
assert.deepEqual(
adminState.blacklist,
[directBlacklistNpub, communityMemberNpub, communitySecondMemberNpub],
"should merge community blacklists with base entries while deduping and respecting guards",
);
assert.ok(
!adminState.blacklist.includes(ADMIN_SUPER_NPUB),
"should exclude super admin from merged blacklist",
);
assert.ok(
!adminState.blacklist.includes(whitelistNpub),
"should exclude whitelist members from merged blacklist",
);
assert.ok(
!adminState.blacklist.includes(editorNpub),
"should exclude editor members from merged blacklist",
);
resetListRegistry();
setListFailureMode("throw-authors");
registerListEvent({
dTag: editorsDTag,
event: createListEvent({
dTag: editorsDTag,
pubkey: superHex,
createdAt: 11,
tags: [["p", editorNpub]],
}),
});
registerListEvent({
dTag: whitelistDTag,
event: createListEvent({
dTag: whitelistDTag,
pubkey: superHex,
createdAt: 12,
tags: [["p", whitelistNpub]],
}),
});
registerListEvent({
dTag: blacklistDTag,
event: createListEvent({
dTag: blacklistDTag,
pubkey: superHex,
createdAt: 13,
tags: [["p", directBlacklistNpub]],
}),
});
const fallbackState = await loadAdminState();
assert.deepEqual(
fallbackState.blacklist,
[directBlacklistNpub],
"should fall back to base blacklist when community list loading fails",
);
resetListRegistry();
setListFailureMode(null);
const additionBehaviors = [
{ url: relays[0], success: false, error: new Error("relay1 add failure") },
{ url: relays[1], success: true },
+3 -1
View File
@@ -683,7 +683,9 @@ test('admin mutations invoke accessControl stubs and update admin DOM', async ()
assert.equal(whitelistButton.hasAttribute('aria-busy'), false);
assert.ok(successMessages.includes('Added to the whitelist.'));
const removeButton = controller.whitelistList.querySelector('button');
const removeButton = controller.whitelistList.querySelector(
'button[data-role="remove"]',
);
await controller.handleAdminListMutation('whitelist', 'remove', 'npub1allow', removeButton);
const removeEvent = accessEvents.find((entry) => entry[0] === 'removeWhitelist');
assert.deepEqual(removeEvent, [