From 4fee05a44721d2db01e9aef88bcf425ff5269105 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:13:48 -0400 Subject: [PATCH 1/5] Add community blacklist federation support --- docs/moderation/README.md | 10 ++ js/adminListStore.js | 227 ++++++++++++++++++++++++++++--- js/config.js | 2 + js/nostrEventSchemas.js | 6 + tests/admin-list-store.test.mjs | 234 +++++++++++++++++++++++++++++++- 5 files changed, 458 insertions(+), 21 deletions(-) diff --git a/docs/moderation/README.md b/docs/moderation/README.md index 840515a9..f4f924c6 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -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::${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:`. +2. Every curator publishes their own `kind 30000` list using the referenced `d` tag (`${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:`) 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. diff --git a/js/adminListStore.js b/js/adminListStore.js index d8a346a2..aecdeb16 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -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) { diff --git a/js/config.js b/js/config.js index d0a7e501..ffe22022 100644 --- a/js/config.js +++ b/js/config.js @@ -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; diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 766f483d..f535671d 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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; diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index dc42c515..fdbdb7a9 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -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 }, From a3e5df33c2026c666db9b06b7461a72e7f50aa28 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:16:58 -0400 Subject: [PATCH 2/5] Update roadmap for shipped source visibility guard --- content/roadmap.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/content/roadmap.md b/content/roadmap.md index 01aa1d73..e8252371 100644 --- a/content/roadmap.md +++ b/content/roadmap.md @@ -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. From 51630fb2f67843a1c3dfc3bf957e3717d04f9d65 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:17:05 -0400 Subject: [PATCH 3/5] Document moderation menu actions --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b13a4e3f..c85aa63e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,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. --- From 64882383e16680409e80b7bbc682e86742c01184 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:17:08 -0400 Subject: [PATCH 4/5] Update docs to reflect edit-modal visibility controls --- README.md | 4 ++-- content/getting-started.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b13a4e3f..30fd24f1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -38,7 +38,7 @@ Open the **Upload** modal from the header toolbar and choose the flow that match - **Custom (hosted URL or magnet)**: Provide a title plus an HTTPS video URL and/or a WebTorrent magnet. The form requires at least one transport, validates `ws=`/`xs=` hints, and keeps magnets raw by decoding them with `safeDecodeMagnet()` before publish. - **Cloudflare (R2 direct upload)**: Connect your bucket once via the Cloudflare pane, then drop media files for bitvid to upload through the R2 API. The modal tracks progress, lets you tweak metadata, and publishes the resulting R2 URL back into the note automatically. -Hosted URLs remain the preferred playback path, and you can still add a magnet or supplemental web seeds when using either mode. Use the **Private** toggle to keep the resulting card visible only to you. +Hosted URLs remain the preferred playback path, and you can still add a magnet or supplemental web seeds when using either mode. After your note publishes, open the card’s **More** menu, choose **Edit**, and use the [visibility switch in the Edit Video modal](components/edit-video-modal.html) to move between public and private. ### How playback works diff --git a/content/getting-started.md b/content/getting-started.md index b9e171df..1088ce4b 100644 --- a/content/getting-started.md +++ b/content/getting-started.md @@ -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? From e8d20cd8dd6d56d3103ae3f1553f39a4b8dc9805 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:17:15 -0400 Subject: [PATCH 5/5] Add channel and copy actions for profile modal lists --- css/tailwind.source.css | 8 + js/app.js | 4 + js/ui/profileModalController.js | 254 +++++++++++++++++++++--- tests/profile-modal-controller.test.mjs | 4 +- 4 files changed, 244 insertions(+), 26 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 5b97baf0..934d7a6a 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -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; diff --git a/js/app.js b/js/app.js index 23afb2b2..29aa56bf 100644 --- a/js/app.js +++ b/js/app.js @@ -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 = { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 6ad6a6a0..cac23d0a 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -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); diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 4af78fed..67971dad 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -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, [