Promote unstable → beta

Batch: submissions admin section, playlists #37, zaps #47, admin star/ring, appeals author-level unblock, notification dots, playlist clobber fix.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-07 16:08:48 -04:00
commit bc8f73f9cc
66 files changed
+5923 -170

No files matched your search

+130
View File
@@ -458,3 +458,133 @@ test_integrity_note:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11b final: un-quarantine nwc-client (+ user-blocks / nostr-publish-rejection hangs)
```yaml
test_integrity_note:
change_type: ["spec_correction", "refactor_tests", "flake_fix"]
scenarios:
- id: SCN-nwc-unsupported-encryption-scheme
given: "An NWC wallet whose info event (kind 13194) advertises ONLY an encryption scheme bitvid does not implement (nip44_v3)"
when: "ensureEncryptionForContext(context) negotiates a scheme"
then: "It rejects with message /Wallet advertises unsupported encryption schemes: nip44_v3\\./ AND error.code === 'UNSUPPORTED_ENCRYPTION'"
- id: SCN-nwc-mock-toolkit-selection
given: "Each nwc-client section installs a per-section window.NostrTools mock to drive encryption-scheme selection"
when: "The nostr-tools bootstrap has installed its frozen canonical toolkit on the global scope"
then: "Removing scope.__BITVID_CANONICAL_NOSTR_TOOLS__ lets readToolkitFromScope fall through to the section mock, so the wiring under test runs against the mock (not real @noble against fake keys)"
observable_outcomes:
- "ensureEncryptionForContext rejection message + error.code (UNSUPPORTED_ENCRYPTION) — asserted exactly"
- "nip44_v2 section still resolves via the mock's nip44 (assert.match on the mock ciphertext shape)"
determinism_controls:
- "per-section in-memory window.NostrTools mocks; canonical-toolkit removal is deterministic (one-time, nothing re-installs it); no network"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "hard-coded return value"
note: |
nwc-client: TWO issues. (1) MOCK SHADOW (not a spec change): the bootstrap installs a
frozen canonical toolkit that readToolkitFromScope() prefers over window.NostrTools, so
the per-section mocks never took effect and real @noble ran against the fake test keys
("bad point: is not on curve"). Deleting the scope canonical lets the resolver use each
section's mock — the tests exercise the NWC client's encryption-SELECTION wiring, not
@noble crypto (which is real elsewhere; the parseNwcUri hex-vs-bytes production bug is
guarded by tests/nwc-parse-uri.test.mjs). (2) SPEC CORRECTION: one section asserted a
rejection when the toolkit "lacks nip44" by omitting nip44 from the mock. That precondition
is architecturally unreachable — the bootstrap's mergeWithCanonical ALWAYS backfills the
canonical nip44 (even an explicit `nip44: null` is overwritten by `!merged.nip44`), so
nip44_v2 is never an unsupported scheme. Replaced with a wallet advertising a scheme bitvid
genuinely cannot provide (nip44_v3), exercising the SAME UNSUPPORTED_ENCRYPTION rejection
via a reachable scenario, and strengthened the check to also assert error.code — strictly
stronger than the old message-only match.
user-blocks + nostr-publish-rejection: FLAKE_FIX (no assertion changed). Both are
bare-assert files that PASS all assertions then hang on lingering handles after completion
(user-blocks: a never-resolving decrypt's background retry timer that manager.reset() doesn't
cancel; nostr-publish-rejection: js/app.js + subscriptions + userBlocks module-load
timers/connection managers). Applied the repo's established post-completion
`setTimeout(() => process.exit(0), 50)` exit after cleanup. No behavior or expectation was
altered — the process simply exits once the assertions have all run.
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11: uploadModal-reset zombie-callback test made deterministic (correct mock boundary)
```yaml
test_integrity_note:
change_type: ["flake_fix", "refactor_tests"]
scenarios:
- id: SCN-upload-zombie-callback-guard
given: "An upload is in progress (videoUploadState.status === 'uploading') via a controllable pending mediaUploader.uploadVideo"
when: "resetUploads() runs mid-upload (bumping videoUploadId), then the in-flight upload later resolves (a 'zombie' completion)"
then: "The stale completion is ignored — status stays 'idle' and results.videoUrl stays '' — because handleVideoSelection guards on videoUploadId !== currentUploadId"
observable_outcomes:
- "videoUploadState.status: 'uploading' after start, 'idle' after reset, still 'idle' after the zombie resolve"
- "results.videoUrl.value === '' after the zombie resolve"
- "test exits 0 with no async-after-test activity (was leaking webtorrent/Blob errors)"
determinism_controls:
- "mediaUploader.uploadVideo replaced with a manually-resolved promise; captureVideoMetadata stubbed; jsdom DOM; no webtorrent, no network, no real Blob ops"
anti_cheat_rationale:
prevents:
- "over-mocking internal logic"
- "retry/sleep-based flake masking"
note: |
FLAKE_FIX + REFACTOR_TESTS, no assertion weakened. The subtest PASSED its asserts but
leaked async activity after the test ended, which node:test flags as a failure. Root
cause: it mocked modal.generateTorrentMetadata / resolveUploadIdentifier, but
handleVideoSelection drives the upload through this.mediaUploader.uploadVideo — a
DIFFERENT object with its OWN helpers — so the mediaUploader still ran real
createTorrentMetadata()/hashing on a synthetic {name:'video.mp4'} file ("invalid input
type" async), and captureVideoMetadata() ran URL.createObjectURL(file) ("must be an
instance of Blob"). Fixed by mocking at the correct DI boundary
(modal.mediaUploader.uploadVideo returns the controllable pending promise) and stubbing
captureVideoMetadata. The unit under test is the modal's videoUploadId zombie-guard, so
mocking the uploader boundary is correct isolation, not over-mocking (real upload/torrent
paths are covered by the mediaUploader / s3-upload suites).
mutation_verification:
- "Broke the guard (`if (this.videoUploadId !== currentUploadId) return` → `if (false)`) → the test FAILS ('State should remain idle after zombie completion', # fail 1). Reverted."
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
## 2026-07-05 — todo-11b: un-quarantine dm-block-filter (hermetic subscription; no force-exit)
```yaml
test_integrity_note:
change_type: ["flake_fix"]
scenarios:
- id: SCN-dm-block-filter-hermetic
given: "The 'integration with loaded user block list' test loads standard + legacy block lists and filters incoming DMs"
when: "manager.loadBlocks() runs against stubbed fetchListIncrementally, with the live block-list subscription stubbed out"
then: "Both blocked contacts (standard + legacy) are filtered (dmMessages === [ALLOWED]) AND the process opens NO real relay sockets, so node:test exits naturally with the correct exit code"
observable_outcomes:
- "manager.isBlocked(legacy) && manager.isBlocked(standard) === true; dmMessages length 1 (ALLOWED only)"
- "process.getActiveResourcesInfo() shows no TCPSocketWrap after loadBlocks; node exits 0 on pass, 1 on a forced failure (verified — failures NOT masked)"
determinism_controls:
- "stubbed nostrClient.fetchListIncrementally; truthy stub nostrClient.pool; no-op nostrClient.getSubscriptionManager; window.nostr.nip04.decrypt stub. All restored in finally. No network."
anti_cheat_rationale:
prevents:
- "retry/sleep-based flake masking"
- "over-mocking internal logic"
note: |
FLAKE_FIX, no assertion changed. The file PASSED all subtests then hung: loadBlocks()
opens a LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking TCP sockets + reconnect timers that outlive the run so
node:test never exits (surfaced only once the runner was given a per-file timeout).
Diagnosed with process.getActiveResourcesInfo() — 4 TCPSocketWrap + reconnect Timeouts;
NOT the decrypt-retry timer (verified absent). Nulling the pool did not help because
loadBlocks calls ensurePool() (userBlocks.js:967) to re-create+connect a real pool when
pool is falsy. Fix keeps the test hermetic: a TRUTHY stub pool (so ensurePool is skipped)
+ a no-op getSubscriptionManager (so the subscription opens nothing). The block-merge
logic under test still runs fully through the stubbed fetchListIncrementally. A force
`process.exit` in an after() hook was explicitly REJECTED after verifying it masks real
failures (process.exitCode is not set when the hook's timer fires — a deliberately broken
assertion still exited 0 under that approach). Under this fix, the forced-failure control
correctly exits 1.
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
@@ -279,11 +279,12 @@
// Set the recipient's NPUB (your personal NPUB) here.
const recipientNpub =
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -437,23 +438,27 @@ For further questions, contact us through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Encrypt the application content.
log("Encrypting application content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
applicationContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the Nostr event template.
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key; the admin's
// Submissions tab lists these and can approve → add to whitelist.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const eventTemplate = {
kind: 4,
kind: 30083,
created_at: now,
tags: [["p", targetPubHex]],
content: ciphertext,
tags: [
["d", submissionId],
["k", "application"],
["applicant", applicantNpub],
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"],
],
content: applicationContent,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -473,7 +478,7 @@ For further questions, contact us through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -261,11 +261,12 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -417,23 +418,31 @@ For urgent issues, contact us through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Encrypt the bug report content.
log("Encrypting bug report content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
bugReportContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "bug"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
);
const eventTemplate = {
kind: 4,
kind: 30083,
created_at: now,
tags: [["p", targetPubHex]],
content: ciphertext,
tags,
content: bugReportContent,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -453,7 +462,7 @@ For urgent issues, contact us through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -45,6 +45,18 @@
placeholder="Enter the magnet link"
/>
<label for="eventPointer">Blocked video link or event ID:</label>
<input
class="form-control"
type="text"
id="eventPointer"
placeholder="Paste the video's share link, nevent1…, note1…, or 64-char event id"
/>
<p class="form-hint">
Required — open the blocked video's ⋯ menu, choose “Copy link”, and
paste it here so a moderator can locate and unblock the exact video.
</p>
<label for="submissionDate">Date of Content Submission:</label>
<input class="form-control" type="date" id="submissionDate" />
@@ -193,15 +205,46 @@
// Set the recipient's NPUB (your personal NPUB) here.
const recipientNpub =
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
// Validate + normalize the blocked-video reference to a 64-char hex
// event id. Accepts a bitvid share link (which embeds an nevent), a raw
// nevent1…/note1… pointer, or a bare hex id. Returns "" when nothing
// valid is found so the caller can block submission.
const HEX_EVENT_ID = /^[0-9a-f]{64}$/i;
function extractEventHex(input) {
const trimmed = String(input || "").trim();
if (!trimmed) {
return "";
}
if (HEX_EVENT_ID.test(trimmed)) {
return trimmed.toLowerCase();
}
// Pull an nevent/note token out of a URL or free text.
const match = trimmed.match(/(nevent1[0-9a-z]+|note1[0-9a-z]+)/i);
const token = match ? match[1] : trimmed;
try {
const decoded = nip19.decode(token);
if (decoded.type === "nevent" && HEX_EVENT_ID.test(decoded.data?.id || "")) {
return decoded.data.id.toLowerCase();
}
if (decoded.type === "note" && HEX_EVENT_ID.test(decoded.data || "")) {
return decoded.data.toLowerCase();
}
} catch (err) {
// not a decodable pointer
}
return "";
}
document
.getElementById("dm-form")
.addEventListener("submit", async (ev) => {
@@ -220,6 +263,17 @@
const magnetLink = document
.getElementById("magnetLink")
.value.trim();
const eventPointer = document
.getElementById("eventPointer")
.value.trim();
const targetEventHex = extractEventHex(eventPointer);
if (!targetEventHex) {
log(
"Please paste a valid video share link, nevent1…, note1…, or 64-char event id so a moderator can locate the blocked video.",
"error"
);
return;
}
const submissionDate = document
.getElementById("submissionDate")
.value.trim();
@@ -261,6 +315,7 @@
**2. Content Details**
- **Title of the Video:** ${videoTitle}
- **Magnet Link:** ${magnetLink}
- **Blocked Event ID:** ${targetEventHex}
- **Date of Content Submission:** ${submissionDate}
**3. Reason for Appeal**
@@ -315,23 +370,27 @@ For further questions, reach out through bitvid’s Nostr support channels.
const ephemeralPubHex = getPublicKey(ephemeralPriv);
log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "...");
// Encrypt the appeal content.
log("Encrypting appeal content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
appealContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
log("Building appeal submission (kind 30083, public)...");
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const eventTemplate = {
kind: 4,
kind: 30083,
created_at: now,
tags: [["p", targetPubHex]],
content: ciphertext,
tags: [
["d", submissionId],
["k", "appeal"],
["p", targetPubHex],
["e", targetEventHex],
["t", "bitvid-submission"],
["client", "bitvid"],
],
content: appealContent,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -351,7 +410,7 @@ For further questions, reach out through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -197,11 +197,12 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -318,21 +319,31 @@ For additional discussions, reach out via bitvid’s Nostr support channels.
// Encrypt the feedback content.
log("Encrypting feedback content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
feedbackContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template.
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "feedback"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
);
const eventTemplate = {
kind: 4,
kind: 30083,
created_at: now,
tags: [["p", targetPubHex]],
content: ciphertext,
tags,
content: feedbackContent,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -352,7 +363,7 @@ For additional discussions, reach out via bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
@@ -221,11 +221,12 @@
// Set the recipient's NPUB (your personal NPUB)
const recipientNpub =
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe";
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
const RELAYS = [
"wss://relay.snort.social",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
];
const pool = new SimplePool();
@@ -360,21 +361,31 @@ For further discussions, reach out through bitvid’s Nostr support channels.
// Encrypt the feature request content
log("Encrypting feature request content (nip04)...");
const ciphertext = await nip04.encrypt(
ephemeralPriv,
targetPubHex,
featureRequestContent
);
log("[DEBUG] Ciphertext: " + ciphertext);
log("Encryption done.");
// Build the event template
// Build a structured bitvid submission (kind 30083, PUBLIC — no
// encryption). Published from the ephemeral key.
const now = Math.floor(Date.now() / 1000);
const submissionId =
"sub-" +
now.toString(36) +
"-" +
Math.random().toString(36).slice(2, 10);
const tags = [
["d", submissionId],
["k", "feature"],
];
if (userNpub) {
tags.push(["applicant", userNpub]);
}
tags.push(
["p", targetPubHex],
["t", "bitvid-submission"],
["client", "bitvid"]
);
const eventTemplate = {
kind: 4,
kind: 30083,
created_at: now,
tags: [["p", targetPubHex]],
content: ciphertext,
tags,
content: featureRequestContent,
};
log(
"[DEBUG] Event template before finalizing: " +
@@ -394,7 +405,7 @@ For further discussions, reach out through bitvid’s Nostr support channels.
for (const url of RELAYS) {
log("Connecting to " + url + " for subscription...");
const relay = await Relay.connect(url);
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [4] }], {
relay.subscribe([{ authors: [ephemeralPubHex], kinds: [30083] }], {
onEvent(foundEvent) {
if (foundEvent.id === event.id) {
log(
+164
View File
@@ -211,6 +211,28 @@
</svg>
My Videos
</button>
<button
id="profileNavPlaylists"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePanePlaylists"
aria-selected="false"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h11M4 10h11M4 14h7m3 5l6-3.5L14 12v7z"
/>
</svg>
Playlists
</button>
<button
id="profileNavHashtags"
type="button"
@@ -401,6 +423,11 @@
/>
</svg>
Admin (Moderation)
<span
id="profileNavAdminUnreadDot"
class="profile-nav__unread-dot"
aria-hidden="true"
></span>
</button>
<button
id="profileMobileLockNowBtn"
@@ -1135,6 +1162,67 @@
</div>
</section>
<section
id="profilePanePlaylists"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavPlaylists"
>
<p class="text-sm text-muted">
Manage your playlists — rename them, reorder or remove videos, or
delete a playlist. Add videos to a playlist from any video’s ⋯
menu.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Playlists</h3>
<button
id="profilePlaylistsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh playlists"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
<path
d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
></path>
<path d="M16 21h5v-5"></path>
</svg>
</button>
</div>
<p
id="profilePlaylistsLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading your playlists…
</p>
<p id="profilePlaylistsEmpty" class="text-sm text-muted hidden">
You haven’t made any playlists yet. Open any video’s ⋯ menu and
choose “Add to playlist” to start one.
</p>
<ul
id="profilePlaylistsList"
class="grid-stack"
data-orientation="vertical"
></ul>
</section>
<section
id="profilePaneHashtags"
class="profile-pane hidden space-y-6"
@@ -1794,6 +1882,82 @@
>
Moderators
</button>
<button
id="adminSubtabSubmissions"
type="button"
role="tab"
data-admin-subtab="submissions"
aria-controls="adminSubmissionsSection"
aria-selected="false"
class="admin-subtab-has-dot btn-ghost focus-ring px-3 py-1.5 text-sm font-medium hidden"
>
Submissions
<span
id="adminSubtabSubmissionsDot"
class="admin-subtab-dot"
aria-hidden="true"
></span>
</button>
</div>
<div
id="adminSubmissionsSection"
class="space-y-4 hidden"
aria-hidden="true"
>
<div class="flex items-center justify-between gap-3">
<div>
<h4 class="text-base font-semibold text-text">Submissions</h4>
<p class="text-2xs text-muted">
Applications, appeals, and reports from the public forms.
Approve an application to add it to the whitelist.
</p>
</div>
<button
id="adminSubmissionsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh submissions"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
<path
d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
></path>
<path d="M16 21h5v-5"></path>
</svg>
</button>
</div>
<p
id="adminSubmissionsLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading submissions…
</p>
<p id="adminSubmissionsEmpty" class="text-sm text-muted hidden">
No pending submissions.
</p>
<ul
id="adminSubmissionsList"
class="grid-stack"
data-orientation="vertical"
></ul>
</div>
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
+439
View File
@@ -252,6 +252,61 @@
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);
}
/* Non-clipped relative shell that decorateAdminAvatar wraps around a circular
avatar so the corner star isn't clipped. Sizes to the avatar it holds. */
.admin-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
/* Gold ring hugging an admin avatar. A thin page-colored gap makes it read as
a ring, not a border. box-shadow isn't clipped by the avatar's own
overflow, so it works on clipped circular avatars. */
.admin-ring {
box-shadow:
0 0 0 var(--border-width-hairline) var(--color-page),
0 0 0 calc(var(--border-width-hairline) * 3) var(--status-warning);
}
/* Gold ring around the avatar when the logged-in user is an admin — the
unmistakable "you're an admin" signal on your own profile button. A thin
page-colored gap separates the avatar from the ring so it reads as a ring,
not a border. Box-shadow isn't clipped by the avatar's own overflow. */
#profileButton.is-admin > div {
box-shadow:
0 0 0 var(--border-width-hairline) var(--color-page),
0 0 0 calc(var(--border-width-hairline) * 3) var(--status-warning);
}
.header-icon-toggle[data-theme-state="light"]:hover,
.header-icon-toggle[data-theme-state="light"]:focus-visible {
color: var(--color-text);
@@ -2706,8 +2761,11 @@
}
.comment-thread__avatar {
position: relative;
inline-size: var(--comment-avatar-size);
block-size: var(--comment-avatar-size);
/* rounded so the admin ring (box-shadow) reads as a circle */
border-radius: var(--radius-full);
}
.comment-thread__avatar img {
@@ -2923,6 +2981,334 @@
font-variant-numeric: tabular-nums;
}
/* Zap orange as a currentColor source for the popularity chart's zaps line
+ legend swatch (mirrors how the views line uses .text-accent). */
.text-zap {
color: var(--color-zap);
}
.popularity-legend {
display: flex;
gap: var(--space-md);
align-items: center;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
margin-block-start: var(--space-2xs);
}
.popularity-legend__item {
display: inline-flex;
align-items: center;
gap: var(--space-3xs);
}
.popularity-legend__swatch {
inline-size: var(--space-sm);
block-size: var(--size-3px, 0.1875rem);
border-radius: var(--radius-full);
background-color: currentColor;
}
/* #37: channel playlists — each card is a stacked "pile of clips" cover with a
themed gradient scrim and the title set bold over it. */
.playlist-grid {
@apply grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4;
gap: var(--space-lg);
}
.playlist-card {
position: relative;
display: block;
aspect-ratio: 16 / 9;
text-decoration: none;
/* leave room for the offset stack layers to peek out */
margin-block-start: var(--space-2xs);
margin-inline-end: var(--space-xs);
transition: transform 0.14s ease-out;
}
/* Two offset layers behind the cover read as a stack of videos. */
.playlist-card::before,
.playlist-card::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--radius-md);
background-color: var(--color-page-alt);
border: var(--border-width-hairline) solid var(--color-overlay-strong);
box-shadow: var(--shadow-sm);
}
.playlist-card::before {
transform: translate(var(--size-5px), calc(-1 * var(--size-5px)))
rotate(2.5deg);
}
.playlist-card::after {
transform: translate(calc(-1 * var(--size-4px)), calc(-1 * var(--size-3px)))
rotate(-2deg);
}
.playlist-card__thumb {
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
box-shadow: var(--shadow-md);
}
.playlist-card:hover {
transform: translateY(-2px);
}
.playlist-card:hover .playlist-card__thumb {
box-shadow: var(--shadow-lg);
}
.playlist-card__image {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Themed scrim: accent-tinted dark at the base fading to clear, so the bold
title reads over any cover (always dark, so the title is always white). */
.playlist-card__scrim {
position: absolute;
inset: 0;
background-image: linear-gradient(
to top,
color-mix(in srgb, var(--color-accent-strong) 62%, black) 0%,
color-mix(in srgb, var(--color-accent) 26%, transparent) 45%,
transparent 74%
);
}
.playlist-card__count {
position: absolute;
z-index: 2;
inset-block-start: var(--space-2xs);
inset-inline-end: var(--space-2xs);
border-radius: var(--radius-sm);
padding-block: var(--space-3xs);
padding-inline: var(--space-2xs);
font-size: var(--font-size-2xs);
font-weight: 600;
color: var(--color-white);
background-color: var(--color-overlay-strong);
}
.playlist-card__title {
position: absolute;
z-index: 2;
inset-inline: 0;
inset-block-end: 0;
margin: 0;
padding: var(--space-sm);
font-size: var(--font-size-sm);
font-weight: 700;
line-height: 1.2;
color: var(--color-white);
text-shadow: 0 var(--size-2px) var(--size-3px) var(--color-overlay-black);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* #37: profile modal "Playlists" management pane — collapsible rows. */
.playlist-row {
border: var(--border-width-hairline) solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
}
.playlist-row + .playlist-row {
margin-block-start: var(--space-2xs);
}
.playlist-row__head {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
cursor: pointer;
}
.playlist-row__chevron svg {
color: var(--color-text-muted);
transition: transform 0.15s ease-out;
}
.playlist-row__chevron[data-open="true"] svg {
transform: rotate(90deg);
}
.playlist-row__meta {
flex: 1 1 auto;
min-inline-size: 0;
}
.playlist-row__title {
margin: 0;
font-weight: 600;
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-row__count {
margin: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
}
.playlist-row__actions {
display: flex;
gap: var(--space-2xs);
flex-shrink: 0;
}
.playlist-row__videos {
border-block-start: var(--border-width-hairline) solid
var(--color-border-subtle);
padding: var(--space-2xs) var(--space-sm) var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
.playlist-row__empty {
padding-block: var(--space-2xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.playlist-video {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: grab;
}
.playlist-video:active {
cursor: grabbing;
}
.playlist-video--dragging {
opacity: 0.5;
}
.playlist-video--drop-target {
box-shadow: inset 0 var(--size-2px) 0 0 var(--color-accent);
}
.playlist-video__title {
flex: 1 1 auto;
min-inline-size: 0;
font-size: var(--font-size-sm);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-video__controls {
display: flex;
gap: var(--space-3xs);
flex-shrink: 0;
}
/* #23: admin Submissions cards. */
.submission-card {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
padding: var(--space-sm);
border: var(--border-width-hairline) solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: var(--color-page-alt);
}
.submission-card + .submission-card {
margin-block-start: var(--space-2xs);
}
.submission-card__head {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.submission-card__type {
padding-block: var(--space-3xs);
padding-inline: var(--space-2xs);
border-radius: var(--radius-full);
font-size: var(--font-size-2xs);
font-weight: 600;
color: var(--color-white);
background-color: var(--color-accent-strong);
}
.submission-card__applicant {
min-inline-size: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.submission-card__body {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
.submission-card__heading {
margin: 0;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-strong);
}
.submission-card__line {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text);
word-break: break-word;
}
.submission-card__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: flex-start;
}
/* Each action pairs its button with a one-line description so a moderator
knows exactly what the control does before clicking. */
.submission-card__action {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
min-inline-size: 0;
}
.submission-card__action-hint {
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
max-inline-size: 22ch;
}
/* #43: always-visible active-identity pill. Rendered after the header in
flow and pulled up with a negative block-start margin so it sits half-on /
half-off the header divider — every pane shows who you're acting as. */
@@ -3158,6 +3544,13 @@
overflow-x: visible;
}
/* Nav buttons host an absolutely-positioned unread dot, so they must be the
positioning context — otherwise the dot resolves against the whole nav
column (top:50%/left:-6px of the list, not the button). */
.profile-modal__nav > button {
position: relative;
}
.profile-nav__unread-dot {
position: absolute;
top: 50%;
@@ -3177,6 +3570,30 @@
display: block;
}
/* Pending-submissions dot on the Admin pane's "Submissions" subtab. The
subtabs sit in a horizontal row, so this dot rides the top-right corner
(not the left edge like the vertical nav dots). */
.admin-subtab-has-dot {
position: relative;
}
.admin-subtab-dot {
position: absolute;
top: calc(-1 * var(--space-3xs));
right: calc(-1 * var(--space-3xs));
width: var(--space-sm);
height: var(--space-sm);
border-radius: var(--radius-full);
border: var(--border-width-hairline) solid var(--surface-panel);
background-color: var(--status-danger);
display: none;
pointer-events: none;
}
.admin-subtab-dot.is-visible {
display: block;
}
.profile-modal__panes {
display: flex;
flex: 1 1 auto;
@@ -4220,6 +4637,12 @@
margin-top: var(--space-xs);
}
body.iframe-form .form-hint {
margin-top: var(--space-xs);
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
}
body.iframe-form .checkbox-group,
body.iframe-form .radio-group {
display: flex;
@@ -5397,6 +5820,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;
@@ -5525,6 +5956,14 @@
border: var(--border-width-hairline) solid var(--border-subtle);
}
/* Non-clipped relative shell used only when a message avatar needs an admin
star; the plain .dm-message-avatar img has no wrapper otherwise. */
.dm-message-avatar-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.dm-message-bubble {
max-width: 75%;
display: flex;
+2
View File
@@ -301,6 +301,8 @@
/* Measurement primitives */
--size-2px: 2px;
--size-3px: 3px;
--size-4px: 4px;
--size-5px: 5px;
--size-12px: 12px;
--size-24px: 24px;
--size-30px: 30px;
+2
View File
@@ -197,6 +197,8 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', <payment_hash>]`, `['p', <recipient>]`, optional `['e', <event id>]`/`['a', <coordinate>]`, `['amount', <msats>]`, `['bolt11', <invoice>]`, `['preimage', <hex>]`, `['description', <zap request JSON>]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. |
| Playlist (`NOTE_TYPES.PLAYLIST`) | `PLAYLIST_KIND` (`30082`, addressable, `d = playlist id`) | `['t','playlist']`, `['d', <playlist id>]`, optional `['title', ...]`/`['description', ...]`/`['image', ...]`, then **ordered** `['a', '30078:pubkey:d']` refs per video (`['e', <event id>]` also accepted). Item order = tag order. | Creator-curated video playlist (#37). bitvid-native — deliberately NOT NIP-51's `30005` "video curation set", which bitvid already reads as legacy interest-sets (would collide). Built/parsed by `js/playlists/playlistService.js`. |
| Submission (`NOTE_TYPES.SUBMISSION`) | `SUBMISSION_KIND` (`30083`, addressable, `d = submission id`) | `['t','bitvid-submission']`, `['d', <id>]`, `['k', 'application'\|'appeal'\|'bug'\|'feature'\|'feedback']`, `['applicant', <npub>]`, `['p', <admin hex>]`; free-text body in `content`. | Structured, PUBLIC application/appeal/bug/feature/feedback (#23) — replaces the old kind-4 encrypted DM forms so an admin "Submissions" tab can list + approve/deny. Published from an EPHEMERAL key (no login), so `applicant` is the CLAIMED npub. Built/parsed by `js/submissions/submissionService.js`. |
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
+11 -4
View File
@@ -471,12 +471,19 @@ tallies simply stop being counted. No data migration.
- [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify.
- [x] `lint` + `build` + suites green; pushed to `unstable`.
- [ ] **Remaining v1 work:**
- [ ] **Profile-zap counting:** the store queries `#a`/`#e` only — add
`#p` batches so a profile-only tally (no video pointer) is fetched,
and a channel-page total UI surface (D2 chose video+profile).
- [x] **Profile-zap counting DONE:** pointerKey/store support `p:`
pointers (profilePointer(pubkey)); runBatch queries `#p` and both
folds count `#p`-tagged 9735s + tallies. Channel page shows a
"⚡ N sats zapped" total (js/channelZapTotal.js, hidden until
nonzero). Tests in tests/zap-totals.test.mjs. This CLOSES the zap
tally feature (D2 video + profile).
- [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess`
so retried shares publish tallies too. (commit afed041f)
- [ ] **Popularity chart §5.9:** orange zaps line + legend + date axis.
- [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside
the red views line, legend (Views/Zaps), visible date-axis ticks,
and a "· N sats zapped" headline. buildZapSatsTimeSeries (dedup by
payment_hash) + buildPopularityChartSvg + zapEventsFacade. Tests:
tests/zap-time-series-chart.test.mjs.
- [ ] Live end-to-end on unstable (real zap → tally on relay → second
browser counts it); rollout per §9.
+12
View File
@@ -237,6 +237,12 @@ export function createAuthSessionCoordinator(deps) {
if (typeof this.refreshUnreadDmIndicator === "function") {
void this.refreshUnreadDmIndicator({ reason: "auth-login" });
}
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-login" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
const currentView = getHashViewName();
const normalizedView =
@@ -1043,6 +1049,12 @@ export function createAuthSessionCoordinator(deps) {
} else if (this.appChromeController?.setUnreadDmIndicator) {
this.appChromeController.setUnreadDmIndicator(false);
}
if (typeof this.refreshSubmissionsIndicator === "function") {
void this.refreshSubmissionsIndicator({ reason: "auth-logout" });
}
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
const logoutView = getHashViewName();
if (
+6
View File
@@ -128,6 +128,12 @@ export function createUiCoordinator(deps) {
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
// Light the admin star/ring whenever the authenticated UI is applied —
// this covers session restore, not just a fresh login.
if (typeof this.refreshAdminStar === "function") {
this.refreshAdminStar();
}
},
applyLoggedOutUiState() {
+205
View File
@@ -0,0 +1,205 @@
// js/channelPlaylists.js
//
// Channel-page "Playlists" section (#37). Fetches the creator's playlists and
// renders a row of cards linking to the playlist view. Kept out of
// channelProfile.js (that file is at its size cap) and wired in via a one-line
// hook, mirroring channelZapTotal.js. Gated by FEATURE_PLAYLISTS — with the flag
// off it renders nothing and does no fetch.
//
// Also listens for `bitvid:playlists-changed` (dispatched by the "Add to
// playlist" picker) so the section refreshes right after you add/create a
// playlist, instead of needing a manual page reload.
import { fetchCreatorPlaylists } from "./playlists/playlistFacade.js";
import { nostrClient } from "./nostrClientFacade.js";
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
import { FEATURE_PLAYLISTS } from "./constants.js";
import { devLogger } from "./utils/logger.js";
let currentToken = 0;
let currentHex = "";
let currentClient;
let currentDoc = null;
let listenerAttached = false;
function clearChildren(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
function playlistHref(playlist) {
const params = new URLSearchParams({
view: "playlist",
pubkey: playlist.pubkey,
id: playlist.id,
});
return `#${params.toString()}`;
}
// coord -> thumbnail URL, from the app's active videos, so a playlist can show
// its first video's thumbnail as the cover.
function buildThumbnailMap() {
const map = new Map();
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate && typeof video.thumbnail === "string" && video.thumbnail) {
map.set(coordinate, video.thumbnail);
}
}
return map;
}
function coverFor(playlist, thumbnails) {
for (const item of playlist.items) {
if (item.type === "a") {
const thumb = thumbnails.get(item.value);
if (thumb) {
return thumb;
}
}
}
return playlist.image || "";
}
function renderPlaylistCard(doc, playlist, coverUrl) {
const card = doc.createElement("a");
card.href = playlistHref(playlist);
card.className = "playlist-card";
card.setAttribute(
"aria-label",
`Playlist: ${playlist.title} (${playlist.items.length} videos)`,
);
const thumb = doc.createElement("div");
thumb.className = "playlist-card__thumb";
if (coverUrl) {
const img = doc.createElement("img");
img.src = coverUrl;
img.alt = "";
img.loading = "lazy";
img.referrerPolicy = "no-referrer";
img.className = "playlist-card__image";
thumb.appendChild(img);
}
const scrim = doc.createElement("div");
scrim.className = "playlist-card__scrim";
thumb.appendChild(scrim);
const count = doc.createElement("span");
count.className = "playlist-card__count";
const n = playlist.items.length;
count.textContent = `${n} ${n === 1 ? "video" : "videos"}`;
thumb.appendChild(count);
const title = doc.createElement("p");
title.className = "playlist-card__title";
title.textContent = playlist.title;
thumb.appendChild(title);
card.appendChild(thumb);
return card;
}
// Fetch + render the currently-wired channel's playlists. Safe to call any time
// (event refresh, initial wire); a nav token drops a stale fetch.
async function refresh() {
const doc = currentDoc || globalThis.document;
const section =
doc && typeof doc.getElementById === "function"
? doc.getElementById("channelPlaylistsSection")
: null;
const grid = doc?.getElementById?.("channelPlaylistsGrid") || null;
if (!section || !grid) {
return;
}
if (!FEATURE_PLAYLISTS || !currentHex) {
section.classList.add("hidden");
return;
}
const token = currentToken;
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(
currentHex,
currentClient ? { client: currentClient } : {},
);
} catch (error) {
devLogger.warn("[channelPlaylists] Failed to load playlists:", error);
playlists = [];
}
// A newer navigation superseded this fetch.
if (token !== currentToken) {
return;
}
if (!playlists.length) {
section.classList.add("hidden");
return;
}
clearChildren(grid);
const thumbnails = buildThumbnailMap();
const frag = doc.createDocumentFragment();
for (const playlist of playlists) {
frag.appendChild(
renderPlaylistCard(doc, playlist, coverFor(playlist, thumbnails)),
);
}
grid.appendChild(frag);
section.classList.remove("hidden");
}
function onPlaylistsChanged(event) {
const changed = (event?.detail?.pubkey || "").trim().toLowerCase();
if (!currentHex || changed !== currentHex) {
return;
}
// Refresh now, then again shortly after — relays need a beat to serve the
// event we just published back, so the first fetch can miss it.
refresh();
setTimeout(() => {
refresh();
}, 2000);
}
export function teardownChannelPlaylists() {
currentToken += 1;
currentHex = "";
}
/**
* Populate the channel "Playlists" section for a creator. Hidden when the flag
* is off, the container is missing, or the creator has no playlists.
* @param {string} pubkey the channel's hex pubkey
* @param {{ document?: Document, client?: any }} [opts]
*/
export async function wireChannelPlaylists(
pubkey,
{ document: doc = globalThis.document, client } = {},
) {
teardownChannelPlaylists();
currentHex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
currentClient = client;
currentDoc = doc || globalThis.document;
if (!listenerAttached && currentDoc?.addEventListener) {
currentDoc.addEventListener("bitvid:playlists-changed", onPlaylistsChanged);
listenerAttached = true;
}
await refresh();
}
export default wireChannelPlaylists;
+13
View File
@@ -2,12 +2,15 @@
import { nostrClient } from "./nostrClientFacade.js";
import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js";
import { wireChannelZapTotal } from "./channelZapTotal.js";
import { wireChannelPlaylists } from "./channelPlaylists.js";
import { dedupeVideos } from "./utils/videoDeduper.js";
import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js";
import { subscriptions } from "./subscriptions.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
import { applyAdminStar, applyAdminRing } from "./ui/adminBadge.js";
import { getApplication } from "./applicationContext.js";
import { escapeHTML } from "./utils/domUtils.js";
import { formatShortNpub } from "./utils/formatters.js";
@@ -5157,6 +5160,10 @@ function applyChannelProfileMetadata({
}
const blurPubkey = pubkey || currentChannelHex || "";
applyAdminStar(document.getElementById("channelAvatarWrap"), blurPubkey);
// Ring the inner rounded avatar box (the wrap is a non-clipped positioning
// shell; the box-shadow ring belongs on the circular element itself).
applyAdminRing(avatarEl?.parentElement, blurPubkey);
updateChannelModerationVisuals({
bannerEl,
avatarEl,
@@ -5227,6 +5234,12 @@ function applyChannelProfileMetadata({
}
setChannelZapVisibility(Boolean(lightningAddress));
// #47: total sats zapped to this creator (p:<pubkey> pointer in the store).
wireChannelZapTotal(currentChannelHex);
// #37: creator playlists section (no-op unless FEATURE_PLAYLISTS is on).
wireChannelPlaylists(currentChannelHex);
}
async function fetchChannelProfileFromRelays(pubkey) {
+68
View File
@@ -0,0 +1,68 @@
// js/channelZapTotal.js
//
// Channel-page "sats zapped to this creator" total (#47, docs/zap-tally-plan.md
// D2 profile path). Uses a p:<pubkey> pointer in the shared zap-totals store, so
// it counts every zap tagged to the creator — direct profile zaps AND zaps to
// their videos (both carry a `p` tag) — from 9735 receipts + verified bitvid
// tallies. Hidden until there's a nonzero total. Kept out of channelProfile.js
// (file-size budget).
import {
profilePointer,
requestVideoZapTotal,
getVideoZapTotalSnapshot,
onZapTotalsChanged,
} from "./zapTotals.js";
import { formatViewCount } from "./viewCounter.js";
import { FEATURE_ZAP_TALLY } from "./constants.js";
let unsub = null;
function teardown() {
if (typeof unsub === "function") {
try {
unsub();
} catch (error) {
// best effort
}
unsub = null;
}
}
export function teardownChannelZapTotal() {
teardown();
}
export function wireChannelZapTotal(pubkey, { document: doc = globalThis.document } = {}) {
teardown();
const el = doc && typeof doc.getElementById === "function"
? doc.getElementById("channelZapTotal")
: null;
if (!el) {
return;
}
const pointer = FEATURE_ZAP_TALLY ? profilePointer(pubkey) : null;
if (!pointer) {
el.classList.add("hidden");
return;
}
const render = () => {
let sats = 0;
try {
sats = getVideoZapTotalSnapshot(pointer) || 0;
} catch (error) {
sats = 0;
}
if (sats > 0) {
el.textContent = `⚡ ${formatViewCount(sats)} sats zapped`;
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
};
requestVideoZapTotal(pointer); // primes the store (also schedules the fetch)
render();
unsub = onZapTotalsChanged(render);
}
export default wireChannelZapTotal;
+15
View File
@@ -201,6 +201,8 @@ const DEFAULT_FLAGS = Object.freeze({
FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab.
FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab.
FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false.
FEATURE_PLAYLISTS: true, // Creator playlists (#37): channel section + playlist view + add-to-playlist (⋯ menu). Rollback: set false to hide all playlist UI.
FEATURE_SUBMISSIONS: true, // Structured submissions (#23): admin Submissions tab + forms publish kind-30083 instead of DMs. Rollback: set false to hide the admin tab (forms still publish 30083).
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
@@ -226,6 +228,8 @@ const runtimeFlags = (() => {
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED,
FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED,
FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY,
FEATURE_PLAYLISTS: DEFAULT_FLAGS.FEATURE_PLAYLISTS,
FEATURE_SUBMISSIONS: DEFAULT_FLAGS.FEATURE_SUBMISSIONS,
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
@@ -281,6 +285,16 @@ export let FEATURE_ZAP_TALLY = coerceBoolean(
DEFAULT_FLAGS.FEATURE_ZAP_TALLY
);
export let FEATURE_PLAYLISTS = coerceBoolean(
runtimeFlags.FEATURE_PLAYLISTS,
DEFAULT_FLAGS.FEATURE_PLAYLISTS
);
export let FEATURE_SUBMISSIONS = coerceBoolean(
runtimeFlags.FEATURE_SUBMISSIONS,
DEFAULT_FLAGS.FEATURE_SUBMISSIONS
);
export let FEATURE_TRUST_SEEDS = coerceBoolean(
runtimeFlags.FEATURE_TRUST_SEEDS,
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
@@ -598,4 +612,5 @@ export const FEED_TYPES = Object.freeze({
CHANNEL: "channel-profile",
DOCS: "docs",
SEARCH: "search",
PLAYLIST: "playlist",
});
+19
View File
@@ -1283,6 +1283,25 @@ async function bootstrapInterface() {
document.addEventListener(
"bitvid:disclaimer-dismissed",
() => {
// The playlist view (#37) reuses the shared #videoList but is NOT the
// recent feed — force-loading loadVideos() here would race the playlist's
// own fetch and replace it with recent videos. Re-run the current view's
// init for a playlist so its grid re-renders; only the feed grids want the
// recent reload.
const viewName =
((window.location?.hash || "").match(/^#view=([^&]+)/) || [])[1] || "";
if (viewName === "playlist") {
const initPlaylist = viewInitRegistry?.playlist;
if (typeof initPlaylist === "function") {
Promise.resolve(initPlaylist()).catch((error) => {
devLogger.warn(
"[Interface] Failed to re-render playlist after disclaimer dismissed:",
error,
);
});
}
return;
}
if (application && typeof application.loadVideos === "function") {
Promise.resolve(application.loadVideos(true)).catch((error) => {
devLogger.warn(
+212
View File
@@ -48,6 +48,8 @@ export const NOTE_TYPES = Object.freeze({
ZAP_REQUEST: "zapRequest",
ZAP_RECEIPT: "zapReceipt",
ZAP_TALLY: "zapTally",
PLAYLIST: "playlist",
SUBMISSION: "submission",
WATCH_HISTORY: "watchHistory",
SUBSCRIPTION_LIST: "subscriptionList",
SUBSCRIPTION_BACKUP: "subscriptionBackup",
@@ -98,6 +100,21 @@ const DEFAULT_APPEND_TAGS = [];
// (30078 video, 30079 view). See docs/zap-tally-plan.md.
export const ZAP_TALLY_KIND = 30081;
// bitvid-native creator playlist (#37). Addressable (30000–39999) so `d =
// playlist id` yields one canonical, replaceable event per playlist. In bitvid's
// family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's
// 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets
// (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags
// (30078:pubkey:d) reference the videos; order = tag order.
export const PLAYLIST_KIND = 30082;
// bitvid submission (#23): applications / appeals / bug / feature / feedback,
// PUBLIC + structured so an admin "Submissions" tab can list + approve/deny them
// (replacing the old kind-4 encrypted DM flow). Addressable (30000–39999) in
// bitvid's family; published from an EPHEMERAL key (no login), so the `applicant`
// tag carries the CLAIMED npub the admin vouches for. `d` = ephemeral id.
export const SUBMISSION_KIND = 30083;
let cachedUtf8Encoder = null;
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
@@ -339,6 +356,32 @@ const BASE_SCHEMAS = {
],
},
},
[NOTE_TYPES.PLAYLIST]: {
type: NOTE_TYPES.PLAYLIST,
label: "Playlist",
kind: PLAYLIST_KIND,
topicTag: { name: "t", value: "playlist" },
identifierTag: { name: "d" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).",
},
},
[NOTE_TYPES.SUBMISSION]: {
type: NOTE_TYPES.SUBMISSION,
label: "Submission",
kind: SUBMISSION_KIND,
topicTag: { name: "t", value: "bitvid-submission" },
identifierTag: { name: "d" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Free-text submission body (application reason, appeal, bug report, etc.). Structured fields live in tags: k (type), applicant (npub), p (recipient admin).",
},
},
[NOTE_TYPES.VIDEO_MIRROR]: {
type: NOTE_TYPES.VIDEO_MIRROR,
label: "NIP-94 mirror",
@@ -1423,6 +1466,175 @@ export function buildVideoMirrorEvent(params) {
return event;
}
/**
* Builds a bitvid Playlist event (Kind 30082, addressable). All playlist data
* lives in tags: `d` (id), `title`, `description`, `image`, and ordered `a`
* refs (`30078:pubkey:d`) to the videos — order is preserved as tag order.
* Republishing with the same `d` replaces the playlist (NIP-33).
*
* @param {Object} params
* @param {string} params.pubkey
* @param {number} params.created_at
* @param {string} params.dTagValue stable playlist id (required)
* @param {string} [params.title]
* @param {string} [params.description]
* @param {string} [params.image]
* @param {string[]} [params.videoCoordinates] ordered "30078:pubkey:d" strings (`a`)
* @param {string[]} [params.eventRefs] optional ordered raw event ids (`e`)
* @param {string} [params.content]
* @returns {Object} Unsigned event.
*/
export function buildPlaylistEvent(params) {
const {
pubkey,
created_at,
dTagValue,
title = "",
description = "",
image = "",
videoCoordinates = [],
eventRefs = [],
content = "",
} = params || {};
const identifier = typeof dTagValue === "string" ? dTagValue.trim() : "";
if (!identifier) {
throw new Error("A playlist requires a d-tag identifier.");
}
const schema = getNostrEventSchema(NOTE_TYPES.PLAYLIST);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
tags.push([schema?.identifierTag?.name || "d", identifier]);
const trimmedTitle = typeof title === "string" ? title.trim() : "";
if (trimmedTitle) {
tags.push(["title", trimmedTitle]);
}
const trimmedDescription =
typeof description === "string" ? description.trim() : "";
if (trimmedDescription) {
tags.push(["description", trimmedDescription]);
}
const trimmedImage = typeof image === "string" ? image.trim() : "";
if (trimmedImage) {
tags.push(["image", trimmedImage]);
}
// Ordered video refs; dedupe so the same video can't appear twice.
const seen = new Set();
if (Array.isArray(videoCoordinates)) {
videoCoordinates.forEach((coord) => {
const value = typeof coord === "string" ? coord.trim() : "";
if (value && !seen.has(`a:${value}`)) {
seen.add(`a:${value}`);
tags.push(["a", value]);
}
});
}
if (Array.isArray(eventRefs)) {
eventRefs.forEach((ref) => {
const value = typeof ref === "string" ? ref.trim() : "";
if (value && !seen.has(`e:${value}`)) {
seen.add(`e:${value}`);
tags.push(["e", value]);
}
});
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? PLAYLIST_KIND,
pubkey,
created_at,
tags,
content: ensureValidUtf8Content(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.PLAYLIST, event);
}
return event;
}
/**
* Builds a bitvid Submission event (Kind 30083, addressable, PUBLIC). Structured
* so an admin "Submissions" tab can list + approve/deny. Published from an
* ephemeral key, so the `applicant` tag carries the CLAIMED npub.
*
* @param {Object} params
* @param {string} params.pubkey the (ephemeral) author pubkey
* @param {number} params.created_at
* @param {string} params.dTagValue stable submission id (required)
* @param {string} [params.submissionType] application | appeal | bug | feature | feedback
* @param {string} [params.applicantNpub] the npub the submission is about
* @param {string} [params.recipientPubkey] the admin hex pubkey (`p` tag)
* @param {string} [params.content] free-text body
* @returns {Object} Unsigned event.
*/
export function buildSubmissionEvent(params) {
const {
pubkey,
created_at,
dTagValue,
submissionType = "application",
applicantNpub = "",
recipientPubkey = "",
content = "",
} = params || {};
const identifier = typeof dTagValue === "string" ? dTagValue.trim() : "";
if (!identifier) {
throw new Error("A submission requires a d-tag identifier.");
}
const schema = getNostrEventSchema(NOTE_TYPES.SUBMISSION);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
tags.push([schema?.identifierTag?.name || "d", identifier]);
const type =
typeof submissionType === "string" && submissionType.trim()
? submissionType.trim()
: "application";
tags.push(["k", type]);
const applicant =
typeof applicantNpub === "string" ? applicantNpub.trim() : "";
if (applicant) {
tags.push(["applicant", applicant]);
}
const recipient = normalizePointerIdentifier(recipientPubkey);
if (recipient) {
tags.push(["p", recipient]);
}
appendSchemaTags(tags, schema);
const event = {
kind: schema?.kind ?? SUBMISSION_KIND,
pubkey,
created_at,
tags,
content: ensureValidUtf8Content(content),
};
if (isDevMode) {
validateEventAgainstSchema(NOTE_TYPES.SUBMISSION, event);
}
return event;
}
/**
* Builds a Repost (Kind 6) or Generic Repost (Kind 16) event.
*
+11 -15
View File
@@ -21,6 +21,7 @@ import {
resolvePlatformFeePercent,
} from "./platformFee.js";
import { HEX64_REGEX } from "../utils/hex.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
const ZAP_KIND = 9734;
@@ -271,21 +272,16 @@ function decodeAdminPubkey() {
}
function derivePointerTag(videoEvent) {
if (!videoEvent || typeof videoEvent !== "object") {
return null;
}
if (!Array.isArray(videoEvent.tags)) {
return null;
}
const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]);
if (!dTag) {
return null;
}
if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") {
return null;
}
const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`;
return ["a", value];
// Delegate to the app's canonical pointer derivation — the SAME one the read
// side (zapTotals / mostZappedFeed) uses to fetch totals. bitvid's internal
// video objects don't always carry the Nostr `kind` (it can be 0/undefined),
// and the old inline builder trusted `videoEvent.kind` verbatim: a kind-0
// object produced a `0:pubkey:d` coordinate (Number.isFinite(0) === true),
// which the reader — querying `30078:pubkey:d` — could never match, so zaps
// silently counted as zero. buildVideoAddressPointer defaults invalid/zero
// kinds to 30078, keeping writer and reader coordinates in lockstep.
const value = buildVideoAddressPointer(videoEvent);
return value ? ["a", value] : null;
}
function resolveRelayUrls(wallet) {
+179
View File
@@ -0,0 +1,179 @@
// js/playlistView.js
//
// The playlist view (#37): #view=playlist&pubkey=<hex>&id=<playlist id>. Fetches
// the playlist, resolves its ordered `a` refs to the app's active video objects,
// and renders them into the shared #videoList grid via app.renderVideoList
// (preserveOrder so the playlist's order is kept, not re-sorted by date — #21).
// Gated by FEATURE_PLAYLISTS.
import { nostrClient } from "./nostrClientFacade.js";
import { fetchPlaylist } from "./playlists/playlistFacade.js";
import { buildVideoAddressPointer } from "./utils/videoPointer.js";
import { safeEncodeNpub } from "./utils/nostrHelpers.js";
import { FEATURE_PLAYLISTS } from "./constants.js";
import { devLogger } from "./utils/logger.js";
function readParams() {
const params = new URLSearchParams(
typeof window !== "undefined" && window.location
? window.location.hash.slice(1)
: "",
);
return {
pubkey: (params.get("pubkey") || "").trim().toLowerCase(),
id: (params.get("id") || "").trim(),
};
}
// Resolve the playlist's items to video objects, in playlist order, from the
// given pool. Unresolved refs (video missing) are skipped — the count line
// reflects how many resolved.
function resolveOrderedVideos(playlist, videoPool) {
const byCoordinate = new Map();
const byEventId = new Map();
for (const video of videoPool) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate && !byCoordinate.has(coordinate)) {
byCoordinate.set(coordinate, video);
}
if (video && typeof video.id === "string" && video.id) {
byEventId.set(video.id, video);
}
}
const ordered = [];
for (const item of playlist.items) {
const video =
item.type === "a"
? byCoordinate.get(item.value)
: byEventId.get(item.value);
if (video) {
ordered.push(video);
}
}
return ordered;
}
// The videos to resolve against. Resolve from the ALREADY-active cache first —
// when you arrive from the creator's channel (or re-visit), the videos are
// already loaded, so this is instant with no relay round-trip. Only when some
// playlist coordinates are still missing (a cold deep-link) do we fetch the
// referenced creators' videos. Without any fetch, a cold deep-link resolves
// nothing and the shared grid shows the default feed instead.
async function gatherVideoPool(playlist, app) {
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
const covered = new Set();
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate) {
covered.add(coordinate);
}
}
const neededCoords = playlist.items
.filter((item) => item.type === "a")
.map((item) => item.value);
const allCovered = neededCoords.every((coord) => covered.has(coord));
if (allCovered) {
return active;
}
const authors = [
...new Set(neededCoords.map((coord) => coord.split(":")[1]).filter(Boolean)),
];
let fetched = [];
if (
authors.length &&
typeof app?.nostrService?.fetchVideosByAuthors === "function"
) {
try {
fetched = (await app.nostrService.fetchVideosByAuthors(authors)) || [];
} catch (error) {
devLogger.warn("[playlistView] Failed to fetch playlist videos:", error);
}
}
return [...fetched, ...active];
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) {
el.textContent = text;
}
}
export async function initPlaylistView({ getApp } = {}) {
const app = typeof getApp === "function" ? getApp() : null;
const { pubkey, id } = readParams();
if (!FEATURE_PLAYLISTS) {
setText("playlistTitle", "Playlists are not enabled.");
return;
}
if (!pubkey || !id) {
setText("playlistTitle", "Playlist not found.");
return;
}
// Point the back link at the creator's channel.
const back = document.getElementById("playlistBackLink");
const npub = safeEncodeNpub(pubkey);
if (back && npub) {
back.setAttribute("href", `#view=channel-profile&npub=${npub}`);
}
let playlist = null;
try {
playlist = await fetchPlaylist(pubkey, id);
} catch (error) {
devLogger.warn("[playlistView] Failed to fetch playlist:", error);
}
if (!playlist) {
setText("playlistTitle", "Playlist not found.");
setText("playlistMeta", "");
return;
}
setText("playlistTitle", playlist.title);
const pool = await gatherVideoPool(playlist, app);
const videos = resolveOrderedVideos(playlist, pool);
const total = playlist.items.length;
setText(
"playlistMeta",
videos.length === total
? `${total} ${total === 1 ? "video" : "videos"}`
: `${videos.length} of ${total} videos available`,
);
// Always render into the shared grid — even an empty playlist must OVERRIDE
// the default feed that populates #videoList, so a deep-linked playlist never
// shows unrelated recent videos.
if (app && typeof app.mountVideoListView === "function") {
app.mountVideoListView({ includeTags: false });
}
if (app && typeof app.renderVideoList === "function") {
try {
await app.renderVideoList({
videos,
metadata: {
reason: "playlist",
preserveOrder: true,
emptyStateMessage: "None of this playlist's videos could be loaded.",
},
});
} catch (error) {
devLogger.warn("[playlistView] Failed to render playlist videos:", error);
}
}
}
export default initPlaylistView;
+206
View File
@@ -0,0 +1,206 @@
// js/playlists/playlistFacade.js
//
// Network wiring for creator playlists (#37): fetch a creator's playlists / one
// playlist, and publish (build + sign + publish) a playlist. Kept apart from the
// pure playlistService core so that core stays hermetic. Reads route through the
// L1 SubscriptionManager (relay caps + in-flight dedupe), matching the rest of
// bitvid's read paths; writes go through nostrClient.signAndPublishEvent (active
// signer). All entry points take an injectable `client` for testing.
import { nostrClient } from "../nostrClientFacade.js";
import {
PLAYLIST_KIND,
parsePlaylistEvent,
buildPlaylist,
} from "./playlistService.js";
import { devLogger } from "../utils/logger.js";
const LIST_LIMIT = 200;
function getManagerAndRelays(client) {
const manager =
typeof client?.getSubscriptionManager === "function"
? client.getSubscriptionManager()
: null;
const relays = Array.isArray(client?.relays) ? client.relays : [];
return { manager, relays };
}
// Collapse to the newest event per `d` tag (NIP-33 replaceable): a flaky relay
// list can return an old + new copy of the same playlist, so keep only the
// freshest per id.
function newestPerDTag(events) {
const byD = new Map();
for (const event of Array.isArray(events) ? events : []) {
if (!event || !Array.isArray(event.tags)) {
continue;
}
const dTag = event.tags.find(
(t) => Array.isArray(t) && t[0] === "d" && typeof t[1] === "string",
);
const id = dTag ? dTag[1] : "";
if (!id) {
continue;
}
const existing = byD.get(id);
if (
!existing ||
(Number(event.created_at) || 0) > (Number(existing.created_at) || 0)
) {
byD.set(id, event);
}
}
return [...byD.values()];
}
/**
* Fetch a creator's playlists, parsed, newest-per-id, sorted newest-first.
* Empty playlists are dropped from the listing by default (a cleared playlist
* republishes as an empty replaceable event) — pass includeEmpty to keep them.
* @param {string} pubkey
* @param {{ client?: any, includeEmpty?: boolean }} [opts]
* @returns {Promise<Array>} parsed playlists
*/
export async function fetchCreatorPlaylists(
pubkey,
{ client = nostrClient, includeEmpty = false } = {},
) {
const author =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!author) {
return [];
}
const { manager, relays } = getManagerAndRelays(client);
if (!manager || typeof manager.list !== "function" || !relays.length) {
return [];
}
let events = [];
try {
events = await manager.list({
relays,
filters: [{ kinds: [PLAYLIST_KIND], authors: [author], limit: LIST_LIMIT }],
});
} catch (error) {
devLogger.warn("[playlists] Failed to list creator playlists:", error);
return [];
}
return newestPerDTag(events)
.map(parsePlaylistEvent)
.filter(Boolean)
.filter((playlist) => includeEmpty || playlist.items.length > 0)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
/**
* Fetch a single playlist by (pubkey, id). Returns the newest matching event
* parsed, or null.
* @param {string} pubkey
* @param {string} id
* @param {{ client?: any }} [opts]
* @returns {Promise<Object|null>}
*/
export async function fetchPlaylist(pubkey, id, { client = nostrClient } = {}) {
const author =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
const dTag = typeof id === "string" ? id.trim() : "";
if (!author || !dTag) {
return null;
}
const { manager, relays } = getManagerAndRelays(client);
if (!manager || typeof manager.list !== "function" || !relays.length) {
return null;
}
let events = [];
try {
events = await manager.list({
relays,
filters: [
{
kinds: [PLAYLIST_KIND],
authors: [author],
"#d": [dTag],
limit: 20,
},
],
});
} catch (error) {
devLogger.warn("[playlists] Failed to fetch playlist:", error);
return null;
}
const newest = newestPerDTag(events)[0];
return newest ? parsePlaylistEvent(newest) : null;
}
/**
* Build + sign + publish a playlist (replaces the same `d` on relays, NIP-33).
* @param {Object} playlist { pubkey, id, title, description, image, items }
* @param {{ client?: any }} [opts]
* @returns {Promise<Object>} the signed event
*/
export async function publishPlaylist(playlist, { client = nostrClient } = {}) {
const event = buildPlaylist(playlist);
const result = await client.signAndPublishEvent(event, {
context: "playlist",
});
return result?.signedEvent || null;
}
/**
* Delete a playlist: publishes a NIP-09 deletion (kind 5) referencing the
* playlist's addressable coordinate (`a`) and, when known, its event id (`e`),
* so relays that honor deletions drop it. Also republishes the playlist as an
* empty replaceable event so readers that ignore kind 5 still see it as empty
* (and empty playlists are dropped from listings).
* @param {Object} playlist parsed playlist ({ pubkey, id, address, eventId })
* @param {{ client?: any }} [opts]
* @returns {Promise<Object|null>} the signed deletion event
*/
export async function deletePlaylist(playlist, { client = nostrClient } = {}) {
const pubkey =
typeof playlist?.pubkey === "string" ? playlist.pubkey.trim().toLowerCase() : "";
const id = typeof playlist?.id === "string" ? playlist.id.trim() : "";
if (!pubkey || !id) {
return null;
}
const address =
typeof playlist.address === "string" && playlist.address
? playlist.address
: `${PLAYLIST_KIND}:${pubkey}:${id}`;
const tags = [["a", address]];
if (typeof playlist.eventId === "string" && playlist.eventId) {
tags.push(["e", playlist.eventId]);
}
const deletion = {
kind: 5,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "playlist deleted",
};
// Best-effort: replace with an empty playlist so kind-5-ignoring readers drop it.
try {
await publishPlaylist(
{ pubkey, id, title: playlist.title || "", items: [] },
{ client },
);
} catch (error) {
devLogger.warn("[playlists] Failed to empty playlist during delete:", error);
}
const result = await client.signAndPublishEvent(deletion, {
context: "playlist-delete",
});
return result?.signedEvent || null;
}
export default {
fetchCreatorPlaylists,
fetchPlaylist,
publishPlaylist,
};
+245
View File
@@ -0,0 +1,245 @@
// js/playlists/playlistService.js
//
// Creator playlists (#37) — pure core. A playlist is a bitvid-native addressable
// event (kind PLAYLIST_KIND=30082): all data lives in tags (d/title/description/
// image + ordered `a` refs to videos). This module holds the pure, hermetic
// logic — parse an event into a structured playlist, build a signable event from
// one, and immutably add/remove/reorder items. Network I/O (fetch a creator's
// playlists, publish) lives in the facade that wires this to the live client, so
// this core stays fully unit-testable with no relays or signer.
import {
buildPlaylistEvent,
PLAYLIST_KIND,
} from "../nostrEventSchemas.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
export { PLAYLIST_KIND };
function firstTagValue(tags, name) {
const tag = tags.find(
(t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string",
);
return tag ? tag[1] : "";
}
/**
* A structured playlist item references one video, either by addressable
* coordinate (`a` → "30078:pubkey:d") or raw event id (`e`).
* @typedef {{ type: "a" | "e", value: string }} PlaylistItem
*/
export function playlistItemKey(item) {
return item && item.type && typeof item.value === "string" && item.value
? `${item.type}:${item.value}`
: "";
}
/**
* Parse a kind-30082 event into a structured playlist. Returns null for a
* non-playlist event or one missing a `d` identifier. Item order follows tag
* order; duplicate refs are collapsed.
* @param {Object} event
* @returns {null | {
* id: string, pubkey: string, title: string, description: string,
* image: string, items: PlaylistItem[], updatedAt: number,
* address: string, eventId: string,
* }}
*/
export function parsePlaylistEvent(event) {
if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
return null;
}
if (Number.isFinite(event.kind) && event.kind !== PLAYLIST_KIND) {
return null;
}
const tags = event.tags;
const id = firstTagValue(tags, "d").trim();
if (!id) {
return null;
}
const items = [];
const seen = new Set();
for (const tag of tags) {
if (!Array.isArray(tag) || (tag[0] !== "a" && tag[0] !== "e")) {
continue;
}
if (typeof tag[1] !== "string") {
continue;
}
const value = tag[1].trim();
const key = `${tag[0]}:${value}`;
if (value && !seen.has(key)) {
seen.add(key);
items.push({ type: tag[0], value });
}
}
const pubkey =
typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : "";
return {
id,
pubkey,
title: firstTagValue(tags, "title").trim() || "Untitled playlist",
description: firstTagValue(tags, "description").trim(),
image: firstTagValue(tags, "image").trim(),
items,
updatedAt: Number.isFinite(event.created_at) ? event.created_at : 0,
address: `${PLAYLIST_KIND}:${pubkey}:${id}`,
eventId: typeof event.id === "string" ? event.id : "",
};
}
/**
* Immutably add a video (by addressable coordinate) to an item list. Deduped —
* a video already present is a no-op. Appends unless a valid `position` is given.
* @param {PlaylistItem[]} items
* @param {string} coordinate "30078:pubkey:d"
* @param {{ position?: number }} [opts]
* @returns {PlaylistItem[]} a new array
*/
export function addVideoToPlaylist(items, coordinate, { position } = {}) {
const list = Array.isArray(items) ? items.slice() : [];
const value = typeof coordinate === "string" ? coordinate.trim() : "";
if (!value) {
return list;
}
const item = { type: "a", value };
const key = playlistItemKey(item);
if (list.some((existing) => playlistItemKey(existing) === key)) {
return list;
}
if (Number.isInteger(position) && position >= 0 && position < list.length) {
list.splice(position, 0, item);
} else {
list.push(item);
}
return list;
}
/**
* Immutably remove a video from an item list, by coordinate string (treated as
* an `a` ref) or by a {type,value} item.
* @param {PlaylistItem[]} items
* @param {string | PlaylistItem} coordinateOrItem
* @returns {PlaylistItem[]} a new array
*/
export function removeVideoFromPlaylist(items, coordinateOrItem) {
const list = Array.isArray(items) ? items.slice() : [];
const key =
typeof coordinateOrItem === "string"
? `a:${coordinateOrItem.trim()}`
: playlistItemKey(coordinateOrItem);
if (!key) {
return list;
}
return list.filter((item) => playlistItemKey(item) !== key);
}
/**
* Immutably move an item from one index to another (drag-to-reorder). Out-of-
* range indices are ignored / clamped; returns a new array.
* @param {PlaylistItem[]} items
* @param {number} fromIndex
* @param {number} toIndex
* @returns {PlaylistItem[]}
*/
export function reorderPlaylistItems(items, fromIndex, toIndex) {
const list = Array.isArray(items) ? items.slice() : [];
if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) {
return list;
}
if (fromIndex < 0 || fromIndex >= list.length) {
return list;
}
const clampedTo = Math.max(0, Math.min(toIndex, list.length - 1));
if (clampedTo === fromIndex) {
return list;
}
const [moved] = list.splice(fromIndex, 1);
list.splice(clampedTo, 0, moved);
return list;
}
/**
* The addressable coordinate ("30078:pubkey:d") for a video object/event, using
* the app's canonical derivation (defaults invalid/missing kinds to 30078).
* @param {Object} video
* @returns {string} coordinate, or "" if it can't be derived
*/
export function videoCoordinate(video) {
return buildVideoAddressPointer(video);
}
/**
* Build an unsigned, signable playlist event from a structured playlist.
* @param {{
* pubkey: string, id: string, title?: string, description?: string,
* image?: string, items?: PlaylistItem[], content?: string, created_at?: number,
* }} playlist
* @returns {Object} unsigned kind-30082 event
*/
export function buildPlaylist({
pubkey,
id,
title = "",
description = "",
image = "",
items = [],
content = "",
created_at,
} = {}) {
const videoCoordinates = [];
const eventRefs = [];
if (Array.isArray(items)) {
for (const item of items) {
if (!item || typeof item.value !== "string") {
continue;
}
if (item.type === "a") {
videoCoordinates.push(item.value);
} else if (item.type === "e") {
eventRefs.push(item.value);
}
}
}
return buildPlaylistEvent({
pubkey,
created_at: Number.isFinite(created_at)
? created_at
: Math.floor(Date.now() / 1000),
dTagValue: id,
title,
description,
image,
videoCoordinates,
eventRefs,
content,
});
}
/**
* Generate a stable-ish, collision-resistant playlist id for a new playlist's
* `d` tag (time-prefixed + random suffix, lowercase, URL/tag-safe).
* @returns {string}
*/
export function generatePlaylistId() {
const time = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 10);
return `pl-${time}-${rand}`;
}
export default {
PLAYLIST_KIND,
parsePlaylistEvent,
playlistItemKey,
addVideoToPlaylist,
removeVideoFromPlaylist,
reorderPlaylistItems,
videoCoordinate,
buildPlaylist,
generatePlaylistId,
};
+271
View File
@@ -0,0 +1,271 @@
// js/submissions/submissionFacade.js
//
// Network wiring for structured submissions (#23). Submissions are authored by a
// throwaway EPHEMERAL key, so an admin can't NIP-09-delete them (relays only
// honor same-author deletions). "Handled" state therefore lives in an
// admin-authored RESOLVED-SET: an addressable kind-30000 list (one per acting
// moderator, d = bitvid:admin:submissions-resolved) whose `e` tags are the
// resolved submission event ids. Pending = submissions to the admin minus the
// union of every editor's resolved-set — so any moderator's action clears it for
// all. All entry points take an injectable client for testing.
import { nostrClient } from "../nostrClientFacade.js";
import {
SUBMISSION_KIND,
parseSubmissionEvent,
} from "./submissionService.js";
import { publishEventToRelays } from "../nostrPublish.js";
import { devLogger } from "../utils/logger.js";
export const RESOLVED_LIST_KIND = 30000;
export const SUBMISSIONS_RESOLVED_DTAG = "bitvid:admin:submissions-resolved";
const LIST_LIMIT = 500;
function normHex(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function getManagerAndRelays(client) {
const manager =
typeof client?.getSubscriptionManager === "function"
? client.getSubscriptionManager()
: null;
const relays = Array.isArray(client?.relays) ? client.relays : [];
return { manager, relays };
}
// Keep pending submissions alive. They're authored by throwaway ephemeral keys
// and public relays prune events from pubkeys with no social graph, so the queue
// quietly evaporates over time. A signed event can be re-published by anyone, so
// on each fetch we re-broadcast the still-pending events back to our relays to
// refresh them. Best-effort and fire-and-forget — never blocks or fails a fetch.
function mirrorPendingSubmissions(rawEvents, client) {
try {
const pool = client?.pool;
const relays = Array.isArray(client?.relays) ? client.relays : [];
if (!pool || !relays.length || !Array.isArray(rawEvents) || !rawEvents.length) {
return;
}
for (const event of rawEvents) {
Promise.resolve()
.then(() => publishEventToRelays(pool, relays, event))
.catch(() => {});
}
} catch (error) {
// best effort — mirroring must never break the submissions view
}
}
function newestByCreatedAt(events) {
let newest = null;
for (const event of Array.isArray(events) ? events : []) {
if (!newest || (Number(event?.created_at) || 0) > (Number(newest.created_at) || 0)) {
newest = event;
}
}
return newest;
}
/**
* Fetch the PENDING submissions addressed to an admin: parsed, deduped by
* applicant (newest wins), and excluding any already in an editor's resolved-set.
* @param {{ adminHex: string, editorHexes?: string[], client?: any }} opts
* @returns {Promise<Array>} pending submissions, newest-first
*/
export async function fetchPendingSubmissions({
adminHex,
editorHexes = [],
client = nostrClient,
} = {}) {
const admin = normHex(adminHex);
if (!admin) {
return [];
}
const { manager, relays } = getManagerAndRelays(client);
if (!manager || typeof manager.list !== "function" || !relays.length) {
return [];
}
let subEvents = [];
try {
subEvents = await manager.list({
relays,
filters: [{ kinds: [SUBMISSION_KIND], "#p": [admin], limit: LIST_LIMIT }],
});
} catch (error) {
devLogger.warn("[submissions] Failed to list submissions:", error);
return [];
}
const resolvedIds = await fetchResolvedIds({
editorHexes: [admin, ...editorHexes],
manager,
relays,
});
const parsed = (Array.isArray(subEvents) ? subEvents : [])
.map(parseSubmissionEvent)
.filter((s) => s && s.eventId && !resolvedIds.has(s.eventId))
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
// Dedupe by claimed applicant (newest already first); fall back to id.
const byApplicant = new Map();
for (const submission of parsed) {
const key = submission.applicant || submission.id;
if (!byApplicant.has(key)) {
byApplicant.set(key, submission);
}
}
const pending = [...byApplicant.values()];
// Refresh the still-pending events on our relays so they don't get pruned.
const pendingIds = new Set(pending.map((submission) => submission.eventId));
const rawPending = (Array.isArray(subEvents) ? subEvents : []).filter(
(event) => event && pendingIds.has(event.id),
);
mirrorPendingSubmissions(rawPending, client);
return pending;
}
/**
* Look up a single event by id and return its author's hex pubkey (or "").
* The appeal-approval flow uses this to discover WHY a video is hidden — the
* event's own pubkey is the authoritative author, so we don't rely on the
* appellant to supply it.
* @param {{ eventId: string, client?: any }} opts
* @returns {Promise<string>} lowercased author hex, or "" if not found
*/
export async function resolveEventAuthorHex({ eventId, client = nostrClient } = {}) {
const id = normHex(eventId);
if (!id) {
return "";
}
const { manager, relays } = getManagerAndRelays(client);
if (!manager || typeof manager.list !== "function" || !relays.length) {
return "";
}
try {
const events = await manager.list({
relays,
filters: [{ ids: [id], limit: 1 }],
});
const found = (Array.isArray(events) ? events : []).find(
(event) => normHex(event?.id) === id,
);
return found && typeof found.pubkey === "string"
? found.pubkey.trim().toLowerCase()
: "";
} catch (error) {
devLogger.warn("[submissions] Failed to resolve event author:", error);
return "";
}
}
async function fetchResolvedIds({ editorHexes, manager, relays }) {
const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))];
const ids = new Set();
if (!authors.length) {
return ids;
}
let events = [];
try {
events = await manager.list({
relays,
filters: [
{
kinds: [RESOLVED_LIST_KIND],
authors,
"#d": [SUBMISSIONS_RESOLVED_DTAG],
limit: 200,
},
],
});
} catch (error) {
devLogger.warn("[submissions] Failed to list resolved-sets:", error);
return ids;
}
for (const event of Array.isArray(events) ? events : []) {
for (const tag of event?.tags || []) {
if (Array.isArray(tag) && tag[0] === "e" && tag[1]) {
ids.add(tag[1]);
}
}
}
return ids;
}
/**
* Mark a submission handled by adding its event id to the ACTING moderator's
* resolved-set (read-modify-write the addressable list, then republish). Records
* the status (approved/denied) and applicant in the tag for a light audit trail.
* @param {{ submission: Object, status?: "approved"|"denied", actingHex: string, client?: any }} opts
* @returns {Promise<Object|null>} the signed resolved-set event
*/
export async function markSubmissionResolved({
submission,
status = "approved",
actingHex,
client = nostrClient,
} = {}) {
const acting = normHex(actingHex);
const eventId =
typeof submission?.eventId === "string" ? submission.eventId : "";
if (!acting || !eventId) {
return null;
}
const { manager, relays } = getManagerAndRelays(client);
let current = null;
if (manager && typeof manager.list === "function" && relays.length) {
try {
const events = await manager.list({
relays,
filters: [
{
kinds: [RESOLVED_LIST_KIND],
authors: [acting],
"#d": [SUBMISSIONS_RESOLVED_DTAG],
limit: 5,
},
],
});
current = newestByCreatedAt(events);
} catch (error) {
// best effort — start a fresh list
}
}
const tags = [["d", SUBMISSIONS_RESOLVED_DTAG]];
const seen = new Set();
for (const tag of current?.tags || []) {
if (Array.isArray(tag) && tag[0] === "e" && tag[1] && !seen.has(tag[1])) {
seen.add(tag[1]);
tags.push(["e", tag[1], tag[2] || "", tag[3] || ""]);
}
}
if (!seen.has(eventId)) {
tags.push(["e", eventId, status, submission.applicant || ""]);
}
tags.push(["client", "bitvid"]);
const event = {
kind: RESOLVED_LIST_KIND,
pubkey: acting,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
};
const result = await client.signAndPublishEvent(event, {
context: "submission-resolved",
});
return result?.signedEvent || null;
}
export default {
RESOLVED_LIST_KIND,
SUBMISSIONS_RESOLVED_DTAG,
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
};
+139
View File
@@ -0,0 +1,139 @@
// js/submissions/submissionService.js
//
// Structured bitvid submissions (#23) — pure core. A submission is a public,
// addressable kind-30083 event (SUBMISSION_KIND) published from an ephemeral key
// so a public form needs no login; the `applicant` tag carries the CLAIMED npub
// an admin vouches for. This module builds/parses those events with no relay or
// signer, so it's fully unit-testable. Fetch/publish/approve/deny live in the
// facade that wires this to the live client.
import { buildSubmissionEvent, SUBMISSION_KIND } from "../nostrEventSchemas.js";
export { SUBMISSION_KIND };
export const SUBMISSION_TYPES = Object.freeze({
APPLICATION: "application",
APPEAL: "appeal",
BUG: "bug",
FEATURE: "feature",
FEEDBACK: "feedback",
});
const KNOWN_TYPES = new Set(Object.values(SUBMISSION_TYPES));
// Human labels for the admin UI.
export const SUBMISSION_TYPE_LABELS = Object.freeze({
application: "Whitelist application",
appeal: "Content appeal",
bug: "Bug report",
feature: "Feature request",
feedback: "Feedback",
other: "Submission",
});
function firstTagValue(tags, name) {
const tag = tags.find(
(t) => Array.isArray(t) && t[0] === name && typeof t[1] === "string",
);
return tag ? tag[1] : "";
}
const HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i;
// The `e` tag on an appeal points at the blocked video's event id. We only trust
// a well-formed 64-char hex id — the admin approve path feeds it straight into
// the event block list.
function firstEventTarget(tags) {
for (const tag of tags) {
if (Array.isArray(tag) && tag[0] === "e" && typeof tag[1] === "string") {
const id = tag[1].trim().toLowerCase();
if (HEX_EVENT_ID_REGEX.test(id)) {
return id;
}
}
}
return "";
}
/**
* Parse a kind-30083 event into a structured submission. Returns null for a
* non-submission event or one missing a `d` identifier.
* @param {Object} event
* @returns {null | {
* id: string, eventId: string, type: string, applicant: string,
* targetEventId: string, recipient: string, content: string,
* createdAt: number, pubkey: string,
* }}
*/
export function parseSubmissionEvent(event) {
if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
return null;
}
if (Number.isFinite(event.kind) && event.kind !== SUBMISSION_KIND) {
return null;
}
const tags = event.tags;
const id = firstTagValue(tags, "d").trim();
if (!id) {
return null;
}
const rawType = firstTagValue(tags, "k").trim() || "application";
return {
id,
eventId: typeof event.id === "string" ? event.id : "",
type: KNOWN_TYPES.has(rawType) ? rawType : "other",
applicant: firstTagValue(tags, "applicant").trim(),
targetEventId: firstEventTarget(tags),
recipient: firstTagValue(tags, "p").trim(),
content: typeof event.content === "string" ? event.content : "",
createdAt: Number.isFinite(event.created_at) ? event.created_at : 0,
pubkey:
typeof event.pubkey === "string" ? event.pubkey.trim().toLowerCase() : "",
};
}
/**
* Build an unsigned, signable submission event.
* @param {{
* pubkey: string, id: string, type?: string, applicantNpub?: string,
* recipientPubkey?: string, content?: string, created_at?: number,
* }} submission
* @returns {Object} unsigned kind-30083 event
*/
export function buildSubmission({
pubkey,
id,
type = "application",
applicantNpub = "",
recipientPubkey = "",
content = "",
created_at,
} = {}) {
return buildSubmissionEvent({
pubkey,
created_at: Number.isFinite(created_at)
? created_at
: Math.floor(Date.now() / 1000),
dTagValue: id,
submissionType: type,
applicantNpub,
recipientPubkey,
content,
});
}
/** Generate a stable-ish submission id for the `d` tag. */
export function generateSubmissionId() {
const time = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 10);
return `sub-${time}-${rand}`;
}
export default {
SUBMISSION_KIND,
SUBMISSION_TYPES,
SUBMISSION_TYPE_LABELS,
parseSubmissionEvent,
buildSubmission,
generateSubmissionId,
};
+160
View File
@@ -0,0 +1,160 @@
// js/ui/adminBadge.js
//
// Shared "this profile belongs to a bitvid admin" badges. An admin is the super
// admin OR a current list editor (moderator). Two visual treatments, driven off
// one synchronous check (`isAdminActor`):
// • a small gold STAR in the corner opposite the red notification dot
// (`.admin-star`), and
// • a gold RING around the avatar (`.admin-ring`).
// Styling lives in the matching CSS rules.
//
// `isAdminActor` is synchronous, so editors added to the admin lists only show
// 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-decorate 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";
export const ADMIN_RING_CLASS = "admin-ring";
export const ADMIN_WRAP_CLASS = "admin-avatar-wrap";
/**
* 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;
}
// Pure DOM: add/remove the star in `container` given an already-computed
// decision. `container` MUST be positioned + non-clipped.
function setAdminStar(container, show, { doc, label }) {
if (!container || typeof container.querySelector !== "function" || !doc) {
return false;
}
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);
}
} else if (existing) {
existing.remove();
}
return show;
}
/**
* 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. Returns whether a star is present after the call.
*
* @param {Element|null} container
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {boolean}
*/
export function applyAdminStar(
container,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
return setAdminStar(container, isAdminActor(pubkeyOrNpub), { doc, label });
}
/**
* Toggle just the gold ring on a circular element (for surfaces that already
* place the star on a specific element and only need the ring added). The
* element should be circular (border-radius) for the ring to read right.
* @param {Element|null} el
* @param {string} pubkeyOrNpub
* @returns {boolean} whether the ring is applied
*/
export function applyAdminRing(el, pubkeyOrNpub) {
if (!el || !el.classList) {
return false;
}
const isAdmin = isAdminActor(pubkeyOrNpub);
el.classList.toggle(ADMIN_RING_CLASS, isAdmin);
return isAdmin;
}
/**
* Decorate a circular avatar element with BOTH the admin ring and the corner
* star. `avatarEl` is the circular element (an <img> or a clip container). The
* ring is a box-shadow on `avatarEl` itself (not clipped by its own overflow);
* the star needs a non-clipped, positioned wrapper, so this ensures `avatarEl`
* is wrapped in a `.admin-avatar-wrap` span and hangs the star off that.
*
* Idempotent — reuses an existing wrapper. Works whether `avatarEl` is already
* attached to the DOM (wraps it in place) or freshly built and detached (wraps
* it and returns the wrapper for the caller to append).
*
* @param {Element|null} avatarEl
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {Element|null} the wrapper node (append THIS, not the bare avatar)
*/
export function decorateAdminAvatar(
avatarEl,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
if (!avatarEl || !avatarEl.classList || !doc) {
return avatarEl || null;
}
const isAdmin = isAdminActor(pubkeyOrNpub);
avatarEl.classList.toggle(ADMIN_RING_CLASS, isAdmin);
let wrap = avatarEl.parentElement;
const alreadyWrapped =
wrap && wrap.classList && wrap.classList.contains(ADMIN_WRAP_CLASS);
if (!alreadyWrapped) {
wrap = doc.createElement("span");
wrap.className = ADMIN_WRAP_CLASS;
if (avatarEl.parentElement) {
avatarEl.parentElement.insertBefore(wrap, avatarEl);
}
wrap.appendChild(avatarEl);
}
setAdminStar(wrap, isAdmin, { doc, label });
return wrap;
}
export default {
ADMIN_STAR_CLASS,
ADMIN_RING_CLASS,
ADMIN_WRAP_CLASS,
isAdminActor,
applyAdminStar,
applyAdminRing,
decorateAdminAvatar,
};
+126 -13
View File
@@ -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";
@@ -83,6 +84,8 @@ import {
isDevMode,
} from "../config.js";
import { ADMIN_INITIAL_EVENT_BLACKLIST } from "../lists.js";
import { FEATURE_SUBMISSIONS } from "../constants.js";
import { fetchPendingSubmissions } from "../submissions/submissionFacade.js";
import {
prepareStaticModal,
openStaticModal,
@@ -858,21 +861,40 @@ export default class ApplicationBootstrap {
};
app.videosMap = app.nostrService.getVideosMap();
// The profile-button red dot shows when EITHER there are unread DMs OR (for
// admins) there are pending submissions. The Messages nav dot stays DM-only.
app._dmUnread = false;
app._submissionsPending = false;
app.applyProfileNotificationDot = () => {
const profileDot = app._dmUnread || app._submissionsPending;
if (
app.appChromeController &&
typeof app.appChromeController.setUnreadDmIndicator === "function"
) {
app.appChromeController.setUnreadDmIndicator(profileDot);
}
if (
app.profileController &&
typeof app.profileController.setMessagesUnreadIndicator === "function"
) {
app.profileController.setMessagesUnreadIndicator(app._dmUnread);
}
// Pending-submissions dots on the Admin tab + Submissions subtab so an
// admin knows exactly where to look.
if (
app.profileController &&
typeof app.profileController.setSubmissionsUnreadIndicator === "function"
) {
app.profileController.setSubmissionsUnreadIndicator(
app._submissionsPending,
);
}
};
app.refreshUnreadDmIndicator = async ({ reason = "" } = {}) => {
const applyIndicatorState = (visible) => {
if (
app.appChromeController &&
typeof app.appChromeController.setUnreadDmIndicator === "function"
) {
app.appChromeController.setUnreadDmIndicator(visible);
}
if (
app.profileController &&
typeof app.profileController.setMessagesUnreadIndicator === "function"
) {
app.profileController.setMessagesUnreadIndicator(visible);
}
app._dmUnread = Boolean(visible);
app.applyProfileNotificationDot();
};
if (typeof app.isUserLoggedIn === "function" && !app.isUserLoggedIn()) {
@@ -929,6 +951,64 @@ export default class ApplicationBootstrap {
}
};
// #23: admin-only signal — pending submissions light up the profile dot too.
app.refreshSubmissionsIndicator = async ({ reason = "" } = {}) => {
let pending = false;
try {
if (
FEATURE_SUBMISSIONS &&
typeof app.isUserLoggedIn === "function" &&
app.isUserLoggedIn()
) {
const actorNpub =
typeof app.getCurrentUserNpub === "function"
? app.getCurrentUserNpub()
: "";
if (
actorNpub &&
typeof accessControl.canEditAdminLists === "function" &&
accessControl.canEditAdminLists(actorNpub)
) {
const adminHex = app.safeDecodeNpub(ADMIN_SUPER_NPUB);
const editorHexes = Array.from(accessControl.getEditors?.() || [])
.map((npub) => app.safeDecodeNpub(npub))
.filter(Boolean);
const list = await fetchPendingSubmissions({ adminHex, editorHexes });
pending = Array.isArray(list) && list.length > 0;
}
}
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh submissions indicator",
reason,
error,
);
}
app._submissionsPending = pending;
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 returns whether the user is an admin; use it to also
// toggle the gold ring around the profile avatar (the flagship signal
// on your own button — the corner star stays the shared marker).
const isAdmin = applyAdminStar(app.profileButton, npub || "");
if (app.profileButton && app.profileButton.classList) {
app.profileButton.classList.toggle("is-admin", Boolean(isAdmin));
}
} catch (error) {
devLogger.warn("[Application] Failed to refresh admin star", error);
}
};
const nostrUnsubscribes = [];
nostrUnsubscribes.push(
app.nostrService.on("subscription:changed", ({ subscription }) => {
@@ -975,6 +1055,39 @@ export default class ApplicationBootstrap {
}
}),
);
// #23: keep the admin submissions dot fresh — recompute when one is resolved
// (event) and on a light poll (submissions arrive from external users, so
// there's no push signal). refreshSubmissionsIndicator no-ops for non-admins.
if (typeof document !== "undefined") {
const onSubmissionsChanged = () => {
void app.refreshSubmissionsIndicator({ reason: "submissions-changed" });
};
document.addEventListener(
"bitvid:submissions-changed",
onSubmissionsChanged,
);
nostrUnsubscribes.push(() =>
document.removeEventListener(
"bitvid:submissions-changed",
onSubmissionsChanged,
),
);
}
const submissionsPollId = setInterval(() => {
void app.refreshSubmissionsIndicator({ reason: "poll" });
}, 180000);
if (submissionsPollId && typeof submissionsPollId.unref === "function") {
submissionsPollId.unref();
}
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();
+6 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../moderationUiHelpers.js";
import { buildModerationBadgeText } from "../moderationCopy.js";
import { HEX64_REGEX } from "../../utils/hex.js";
import { decorateAdminAvatar } from "../adminBadge.js";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg";
@@ -1004,7 +1005,11 @@ export class VideoCard {
authorMeta.appendChild(authorName);
authorMeta.appendChild(metadata);
wrapper.appendChild(avatarWrapper);
// Ring + star on the creator avatar when the author is a bitvid admin.
const avatarNode = decorateAdminAvatar(avatarWrapper, this.video.pubkey || "", {
doc: this.document,
});
wrapper.appendChild(avatarNode);
wrapper.appendChild(authorMeta);
this.authorPicEl = avatar;
+5
View File
@@ -6,6 +6,7 @@ import {
} from "./videoMenuRenderers.js";
import { attachAmbientBackground } from "../ambientBackground.js";
import { applyDesignSystemAttributes } from "../../designSystem.js";
import { decorateAdminAvatar } from "../adminBadge.js";
import { devLogger } from "../../utils/logger.js";
import { CommentsController } from "./video-modal/commentsController.js";
import { ReactionsController } from "./video-modal/reactionsController.js";
@@ -2464,6 +2465,10 @@ export class VideoModal {
if (this.creatorAvatar) {
this.creatorAvatar.src = avatarUrl || "assets/svg/default-profile.svg";
this.creatorAvatar.alt = name || "Unknown";
// Ring + star on the player's creator avatar when the author is admin.
if (this.creatorAvatar.parentElement) {
decorateAdminAvatar(this.creatorAvatar.parentElement, npub || "");
}
}
if (this.creatorNpub) {
this.creatorNpub.textContent = npub || "";
@@ -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, applyAdminRing } 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 ring + star for comments authored by a bitvid admin.
if (typeof event.pubkey === "string" && event.pubkey.trim()) {
const authorKey = event.pubkey.trim();
applyAdminRing(avatarWrapper, authorKey);
applyAdminStar(avatarWrapper, authorKey, { doc: this.document });
}
const content = this.document.createElement("div");
content.classList.add("comment-thread__content");
+9
View File
@@ -159,6 +159,7 @@ export function createVideoMoreMenuPanel({
playbackUrl = "",
playbackMagnet = "",
canManageBlacklist = false,
canManagePlaylists = false,
context = "card",
designSystem = null,
} = {}) {
@@ -193,6 +194,14 @@ export function createVideoMoreMenuPanel({
dataset: { eventId: metadata.id, context },
});
if (canManagePlaylists) {
appendMenuAction(doc, list, {
text: "Add to playlist",
action: "add-to-playlist",
dataset: { eventId: metadata.id, context },
});
}
const baseBoostDataset = {
eventId: metadata.id,
author: metadata.pubkey,
+9
View File
@@ -1,3 +1,5 @@
import { decorateAdminAvatar, 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,11 @@ export function Avatar({
avatar.appendChild(fallback);
}
// Only admin avatars get decorated (ring + wrapped corner star); non-admin
// avatars are returned exactly as before to keep existing layouts untouched.
if (adminId && isAdminActor(adminId)) {
return decorateAdminAvatar(avatar, adminId, { doc });
}
return avatar;
}
+2
View File
@@ -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);
+13 -2
View File
@@ -1,3 +1,5 @@
import { decorateAdminAvatar, isAdminActor } from "../adminBadge.js";
function createElement(doc, tag, className, text) {
const element = doc.createElement(tag);
if (className) {
@@ -14,6 +16,7 @@ export function MessageBubble({
message = {},
variant = "incoming",
avatarSrc = "",
adminId = "",
} = {}) {
if (!doc) {
throw new Error("MessageBubble requires a document reference.");
@@ -32,6 +35,14 @@ export function MessageBubble({
avatar.src = avatarSrc || "assets/svg/default-profile.svg";
avatar.alt = variant === "outgoing" ? "Me" : "Sender";
// The message avatar is a bare circular <img>; when its author is an admin,
// decorate it (ring + wrapped corner star). Non-admin bubbles keep the plain
// <img> so existing layout is untouched.
let avatarNode = avatar;
if (adminId && isAdminActor(adminId)) {
avatarNode = decorateAdminAvatar(avatar, adminId, { doc });
}
const bubble = createElement(doc, "div", "dm-message-bubble");
bubble.classList.add(`dm-message-bubble--${variant}`);
bubble.dataset.status = status;
@@ -67,9 +78,9 @@ export function MessageBubble({
if (variant === "outgoing") {
row.appendChild(bubble);
row.appendChild(avatar);
row.appendChild(avatarNode);
} else {
row.appendChild(avatar);
row.appendChild(avatarNode);
row.appendChild(bubble);
}
+3
View File
@@ -137,6 +137,9 @@ export function MessageThread({
message,
variant: isOutgoing ? "outgoing" : "incoming",
avatarSrc: isOutgoing ? currentUserAvatarUrl : (contact.avatarSrc || ""),
// Star incoming bubbles from an admin peer; the current user's own admin
// status is already shown on the profile button.
adminId: isOutgoing ? "" : (contact.pubkey || contact.id || ""),
});
timeline.appendChild(bubble);
});
+41
View File
@@ -6,6 +6,9 @@ import {
} from "./components/videoMenuRenderers.js";
import createPopover from "./overlay/popoverEngine.js";
import { handleBlacklistEventAction } from "./moreMenu/blacklistEventAction.js";
import { openPlaylistPicker } from "./playlistPicker.js";
import { buildVideoAddressPointer } from "../utils/videoPointer.js";
import { FEATURE_PLAYLISTS } from "../constants.js";
import {
prepareStaticModal,
openStaticModal,
@@ -731,6 +734,7 @@ export default class MoreMenuController {
playbackUrl: entry.context.playbackUrl,
playbackMagnet: entry.context.playbackMagnet,
canManageBlacklist: entry.context.canManageBlacklist,
canManagePlaylists: entry.context.canManagePlaylists,
context: entry.context.context || "card",
designSystem: entry.context.designSystem || this.designSystem,
});
@@ -906,6 +910,8 @@ export default class MoreMenuController {
playbackMagnet = "",
context = "card",
canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(),
canManagePlaylists = FEATURE_PLAYLISTS &&
!!this.callbacks.getCurrentUserPubkey?.(),
designSystem = null,
onAction = null,
onClose = null,
@@ -924,6 +930,7 @@ export default class MoreMenuController {
playbackUrl,
playbackMagnet,
canManageBlacklist,
canManagePlaylists,
context,
designSystem: designSystem || this.designSystem,
onAction,
@@ -1199,6 +1206,40 @@ export default class MoreMenuController {
}
break;
}
case "add-to-playlist": {
let targetVideo = video || null;
if (!targetVideo && dataset?.eventId) {
targetVideo =
this.callbacks.getVideoByEventId(dataset.eventId) || null;
}
if (!targetVideo && context === "modal") {
targetVideo = currentVideo || null;
}
if (!targetVideo) {
this.callbacks.showError("No video selected.");
break;
}
const coordinate = buildVideoAddressPointer(targetVideo);
if (!coordinate) {
this.callbacks.showError("Couldn’t identify this video.");
break;
}
try {
this.activePopover?.close?.();
} catch (error) {
// ignore
}
openPlaylistPicker({
videoCoordinate: coordinate,
notify: (kind, message) =>
kind === "success"
? this.callbacks.showSuccess(message)
: this.callbacks.showError(message),
}).catch((error) => {
userLogger.warn("[MoreMenu] Add-to-playlist failed:", error);
});
break;
}
case "remove-history": {
await this.callbacks.handleRemoveHistoryAction(dataset);
break;
+254
View File
@@ -0,0 +1,254 @@
// js/ui/playlistPicker.js
//
// "Add to playlist" dialog (#37). Modeled on confirmDialog.js — builds its own
// `bv-modal modal-always-on-top` overlay so it stacks over the video modal. Lists
// the signed-in user's playlists as checkboxes (checked when the video is already
// in them) plus a "Create new playlist" input. On Save it diffs the checkboxes,
// adds/removes the video in each changed playlist, creates a new one if named,
// and publishes each change via the facade (active signer). Self-contained: it
// reads the current pubkey from nostrClient, so callers only pass the video's
// addressable coordinate.
import { nostrClient } from "../nostrClientFacade.js";
import {
fetchCreatorPlaylists,
publishPlaylist,
} from "../playlists/playlistFacade.js";
import {
addVideoToPlaylist,
removeVideoFromPlaylist,
generatePlaylistId,
playlistItemKey,
} from "../playlists/playlistService.js";
import { userLogger } from "../utils/logger.js";
function hasVideo(playlist, coordinate) {
const key = `a:${coordinate}`;
return playlist.items.some((item) => playlistItemKey(item) === key);
}
function makeButton(label, { ghost = false } = {}) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = ghost ? "btn-ghost focus-ring" : "btn focus-ring";
btn.textContent = label;
return btn;
}
/**
* Open the "Add to playlist" dialog for a video.
* @param {Object} params
* @param {string} params.videoCoordinate "30078:pubkey:d" for the video
* @param {(kind: "success"|"error", message: string) => void} [params.notify]
* @returns {Promise<void>}
*/
export async function openPlaylistPicker({ videoCoordinate, notify } = {}) {
const say = typeof notify === "function" ? notify : () => {};
const coordinate =
typeof videoCoordinate === "string" ? videoCoordinate.trim() : "";
if (!coordinate) {
say("error", "Couldn't identify this video.");
return;
}
const pubkey =
typeof nostrClient?.pubkey === "string"
? nostrClient.pubkey.trim().toLowerCase()
: "";
if (!pubkey) {
say("error", "Log in to add videos to a playlist.");
return;
}
if (typeof document === "undefined" || !document.body) {
return;
}
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(pubkey, { includeEmpty: true });
} catch (error) {
userLogger.warn("[playlistPicker] Failed to load playlists:", error);
playlists = [];
}
await new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Add to playlist");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = "Add to playlist";
header.appendChild(heading);
sheet.appendChild(header);
const body = document.createElement("div");
body.className = "p-5 flex flex-col gap-3 max-h-[60vh] overflow-y-auto";
sheet.appendChild(body);
// Existing playlists as checkboxes.
const checkboxes = []; // { playlist, input, wasIn }
for (const playlist of playlists) {
const row = document.createElement("label");
row.className = "flex items-center gap-3 cursor-pointer";
const input = document.createElement("input");
input.type = "checkbox";
input.className = "shrink-0";
const wasIn = hasVideo(playlist, coordinate);
input.checked = wasIn;
const text = document.createElement("span");
text.className = "text-sm text-text";
const n = playlist.items.length;
text.textContent = `${playlist.title} (${n})`;
row.appendChild(input);
row.appendChild(text);
body.appendChild(row);
checkboxes.push({ playlist, input, wasIn });
}
if (!playlists.length) {
const empty = document.createElement("p");
empty.className = "text-sm text-muted";
empty.textContent = "You don't have any playlists yet — create one below.";
body.appendChild(empty);
}
// Create-new field.
const newWrap = document.createElement("div");
newWrap.className = "flex flex-col gap-1 border-t border-border/60 pt-3";
const newLabel = document.createElement("label");
newLabel.className = "text-xs font-medium text-muted";
newLabel.textContent = "Create new playlist";
const newInput = document.createElement("input");
newInput.type = "text";
newInput.placeholder = "Playlist name";
newInput.className = "form-input";
newInput.maxLength = 120;
newWrap.appendChild(newLabel);
newWrap.appendChild(newInput);
body.appendChild(newWrap);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = makeButton("Cancel", { ghost: true });
const saveBtn = makeButton("Save");
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = () => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve();
};
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup();
}
}
function onSave() {
// Compute the diff synchronously, then close the dialog IMMEDIATELY and
// publish in the background — relay publishes take a beat, and blocking the
// dialog on them made it feel stuck / "not close". Success/failure comes
// back as a toast; a `bitvid:playlists-changed` event lets the channel
// section refresh without a manual page reload.
const jobs = [];
for (const { playlist, input, wasIn } of checkboxes) {
if (input.checked === wasIn) {
continue;
}
const items = input.checked
? addVideoToPlaylist(playlist.items, coordinate)
: removeVideoFromPlaylist(playlist.items, coordinate);
jobs.push({ ...playlist, items });
}
const newName = newInput.value.trim();
if (newName) {
jobs.push({
pubkey,
id: generatePlaylistId(),
title: newName,
items: [{ type: "a", value: coordinate }],
});
}
cleanup();
if (!jobs.length) {
return;
}
(async () => {
let ok = 0;
let failed = 0;
for (const job of jobs) {
try {
await publishPlaylist({ pubkey, ...job });
ok += 1;
} catch (error) {
userLogger.warn(
"[playlistPicker] Failed to publish playlist:",
error,
);
failed += 1;
}
}
if (ok && !failed) {
say(
"success",
ok === 1 ? "Playlist updated." : `Updated ${ok} playlists.`,
);
} else if (ok && failed) {
say("error", `Updated ${ok}; ${failed} could not be saved.`);
} else {
say("error", "Couldn't save your playlist changes.");
}
if (ok) {
try {
document.dispatchEvent(
new CustomEvent("bitvid:playlists-changed", {
detail: { pubkey },
}),
);
} catch (error) {
// best effort
}
}
})();
}
cancelBtn.addEventListener("click", cleanup);
backdrop.addEventListener("click", cleanup);
saveBtn.addEventListener("click", onSave);
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
(playlists.length ? saveBtn : newInput).focus();
});
}
export default openPlaylistPicker;
+49 -2
View File
@@ -7,12 +7,23 @@ import {
renderBlockedVideosList,
} from "./blockedVideosSection.js";
import { showConfirm } from "../confirmDialog.js";
import { FEATURE_SUBMISSIONS } from "../../constants.js";
import {
populateSubmissions,
handleSubmissionsClick,
} from "./submissionsSection.js";
const noop = () => {};
// Admin pane sub-tabs, in toolbar order. "moderators" is only shown to the
// super admin (its tab button is hidden otherwise).
const ADMIN_SUBTABS = ["whitelist", "blacklist", "blockedVideos", "moderators"];
// super admin; "submissions" only when FEATURE_SUBMISSIONS is on.
const ADMIN_SUBTABS = [
"whitelist",
"blacklist",
"blockedVideos",
"moderators",
"submissions",
];
const DEFAULT_ADMIN_SUBTAB = "whitelist";
export class ProfileAdminController {
@@ -84,18 +95,27 @@ export class ProfileAdminController {
this.blockedVideoInput = document.getElementById("adminBlockedVideoInput") || null;
this.blockedVideosRefreshBtn = document.getElementById("adminBlockedVideosRefreshBtn") || null;
this.submissionsSection = document.getElementById("adminSubmissionsSection") || null;
this.submissionsList = document.getElementById("adminSubmissionsList") || null;
this.submissionsEmpty = document.getElementById("adminSubmissionsEmpty") || null;
this.submissionsLoading = document.getElementById("adminSubmissionsLoading") || null;
this.submissionsRefreshBtn = document.getElementById("adminSubmissionsRefreshBtn") || null;
this.pendingSubmissions = [];
this.subtabBar = document.getElementById("adminSubtabBar") || null;
this.subtabButtons = {
whitelist: document.getElementById("adminSubtabWhitelist") || null,
blacklist: document.getElementById("adminSubtabBlacklist") || null,
blockedVideos: document.getElementById("adminSubtabBlockedVideos") || null,
moderators: document.getElementById("adminSubtabModerators") || null,
submissions: document.getElementById("adminSubtabSubmissions") || null,
};
this.subtabSections = {
whitelist: this.whitelistSection,
blacklist: this.blacklistSection,
blockedVideos: this.blockedVideosSection,
moderators: this.moderatorSection,
submissions: this.submissionsSection,
};
}
@@ -212,6 +232,17 @@ export class ProfileAdminController {
}
}
if (this.submissionsRefreshBtn instanceof HTMLElement) {
this.submissionsRefreshBtn.addEventListener("click", () => {
void populateSubmissions(this);
});
}
if (this.submissionsList instanceof HTMLElement) {
this.submissionsList.addEventListener("click", (event) =>
handleSubmissionsClick(this, event),
);
}
if (this.addBlockedVideoButton instanceof HTMLElement) {
this.addBlockedVideoButton.addEventListener("click", () => {
void this.handleAddBlockedVideo();
@@ -283,6 +314,10 @@ export class ProfileAdminController {
section.setAttribute("aria-hidden", (!isActive).toString());
}
}
if (target === "submissions") {
void populateSubmissions(this);
}
}
populateAdminLists() {
@@ -566,8 +601,20 @@ export class ProfileAdminController {
}
}
// Submissions tab is only shown when the feature is enabled.
const submissionsTab = this.subtabButtons?.submissions;
if (submissionsTab instanceof HTMLElement) {
submissionsTab.classList.toggle("hidden", !FEATURE_SUBMISSIONS);
if (!FEATURE_SUBMISSIONS) {
submissionsTab.setAttribute("aria-selected", "false");
}
}
this.populateAdminLists();
this.populateBlockedVideos();
if (FEATURE_SUBMISSIONS) {
void populateSubmissions(this);
}
// Re-apply the current sub-tab so exactly one section is visible (and so a
// now-hidden Moderators tab falls back to the default).
this.selectAdminSubtab(this.activeSubtab);
@@ -0,0 +1,527 @@
// js/ui/profileModal/ProfilePlaylistsController.js
//
// The profile modal's "Playlists" pane (#37) — a central place to manage your
// playlists: rename, delete (NIP-09), and expand one to reorder (↑/↓) or remove
// its videos. Mirrors MyVideosController's shape (constructor(mainController) +
// cacheDomReferences/registerEventListeners + a populate/refresh that runs when
// the pane is selected). All writes go through the playlist facade (active
// signer); edits are optimistic and re-sync on failure. Gated by FEATURE_PLAYLISTS.
import { nostrClient } from "../../nostrClientFacade.js";
import {
fetchCreatorPlaylists,
publishPlaylist,
deletePlaylist,
} from "../../playlists/playlistFacade.js";
import {
reorderPlaylistItems,
removeVideoFromPlaylist,
} from "../../playlists/playlistService.js";
import { buildVideoAddressPointer } from "../../utils/videoPointer.js";
import { showConfirm } from "../confirmDialog.js";
import { showTextPrompt } from "../promptDialog.js";
import { FEATURE_PLAYLISTS } from "../../constants.js";
import { devLogger } from "../../utils/logger.js";
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text != null) {
node.textContent = text;
}
return node;
}
function iconButton(label, action, dataset, svgPath) {
const btn = el("button", "btn-ghost btn-icon");
btn.type = "button";
btn.dataset.size = "sm";
btn.setAttribute("aria-label", label);
btn.dataset.plAction = action;
Object.entries(dataset || {}).forEach(([k, v]) => {
btn.dataset[k] = String(v);
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("class", "h-4 w-4");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", svgPath);
svg.appendChild(path);
btn.appendChild(svg);
return btn;
}
const ICONS = {
up: "M12 19V5M5 12l7-7 7 7",
down: "M12 5v14M5 12l7 7 7-7",
remove: "M6 6l12 12M18 6L6 18",
rename: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z",
trash: "M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6",
chevron: "M9 18l6-6-6-6",
};
export class ProfilePlaylistsController {
constructor(mainController) {
this.mainController = mainController;
this.listEl = null;
this.loadingEl = null;
this.emptyEl = null;
this.refreshBtn = null;
this.pubkey = "";
this.playlists = [];
this.expanded = new Set();
this.loading = false;
this._drag = null;
this._dropTarget = null;
this._onChanged = () => {
void this.refresh();
};
}
cacheDomReferences() {
this.listEl = document.getElementById("profilePlaylistsList") || null;
this.loadingEl = document.getElementById("profilePlaylistsLoading") || null;
this.emptyEl = document.getElementById("profilePlaylistsEmpty") || null;
this.refreshBtn =
document.getElementById("profilePlaylistsRefreshBtn") || null;
}
registerEventListeners() {
if (this.refreshBtn instanceof HTMLElement) {
this.refreshBtn.addEventListener("click", () => {
void this.refresh();
});
}
if (this.listEl instanceof HTMLElement) {
this.listEl.addEventListener("click", (event) =>
this.handleListClick(event),
);
// Desktop drag-to-reorder for videos within a playlist. The ↑/↓ buttons
// stay as the accessible / touch-friendly path.
this.listEl.addEventListener("dragstart", (event) =>
this.handleDragStart(event),
);
this.listEl.addEventListener("dragover", (event) =>
this.handleDragOver(event),
);
this.listEl.addEventListener("drop", (event) => this.handleDrop(event));
this.listEl.addEventListener("dragend", () => this.clearDrag());
}
if (typeof document !== "undefined") {
document.addEventListener("bitvid:playlists-changed", this._onChanged);
}
}
setLoading(value) {
this.loading = value;
if (this.loadingEl) {
this.loadingEl.classList.toggle("hidden", !value);
}
}
async refresh() {
if (!FEATURE_PLAYLISTS || !this.listEl) {
return;
}
const active = this.mainController.getActivePubkey?.() || "";
this.pubkey = typeof active === "string" ? active.trim().toLowerCase() : "";
if (!this.pubkey) {
this.playlists = [];
this.render();
return;
}
this.setLoading(true);
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(this.pubkey, {
includeEmpty: true,
});
} catch (error) {
devLogger.warn("[playlists] Failed to load playlists for pane:", error);
}
this.playlists = playlists;
// Fetch the referenced creators' videos so the expanded sublists can show
// real titles/thumbnails (cache may be cold in the modal).
const authors = new Set();
for (const playlist of playlists) {
for (const item of playlist.items) {
if (item.type === "a") {
const author = item.value.split(":")[1];
if (author) {
authors.add(author);
}
}
}
}
const nostrService = this.mainController.services?.nostrService;
if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") {
try {
await nostrService.fetchVideosByAuthors([...authors]);
} catch (error) {
// best effort — fall back to coordinate labels
}
}
this.setLoading(false);
this.render();
}
// coord -> { title, thumbnail } from the app's active videos.
buildVideoMap() {
const map = new Map();
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate && !map.has(coordinate)) {
map.set(coordinate, {
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
});
}
}
return map;
}
render() {
if (!this.listEl) {
return;
}
this.listEl.replaceChildren();
if (!this.playlists.length) {
if (this.emptyEl) {
this.emptyEl.classList.remove("hidden");
}
return;
}
if (this.emptyEl) {
this.emptyEl.classList.add("hidden");
}
const videoMap = this.buildVideoMap();
for (const playlist of this.playlists) {
this.listEl.appendChild(this.renderRow(playlist, videoMap));
}
}
renderRow(playlist, videoMap) {
const li = el("li", "playlist-row");
const head = el("div", "playlist-row__head");
head.dataset.plAction = "toggle";
head.dataset.plId = playlist.id;
const chevron = el("span", "playlist-row__chevron");
if (this.expanded.has(playlist.id)) {
chevron.dataset.open = "true";
}
const chevSvg = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
chevSvg.setAttribute("viewBox", "0 0 24 24");
chevSvg.setAttribute("fill", "none");
chevSvg.setAttribute("stroke", "currentColor");
chevSvg.setAttribute("stroke-width", "2");
chevSvg.setAttribute("class", "h-4 w-4");
chevSvg.setAttribute("aria-hidden", "true");
const chevPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path",
);
chevPath.setAttribute("d", ICONS.chevron);
chevSvg.appendChild(chevPath);
chevron.appendChild(chevSvg);
head.appendChild(chevron);
const meta = el("div", "playlist-row__meta");
meta.appendChild(el("p", "playlist-row__title", playlist.title));
const n = playlist.items.length;
meta.appendChild(
el("p", "playlist-row__count", `${n} ${n === 1 ? "video" : "videos"}`),
);
head.appendChild(meta);
const actions = el("div", "playlist-row__actions");
actions.appendChild(
iconButton("Rename playlist", "rename", { plId: playlist.id }, ICONS.rename),
);
actions.appendChild(
iconButton("Delete playlist", "delete", { plId: playlist.id }, ICONS.trash),
);
head.appendChild(actions);
li.appendChild(head);
if (this.expanded.has(playlist.id)) {
li.appendChild(this.renderVideoList(playlist, videoMap));
}
return li;
}
renderVideoList(playlist, videoMap) {
const wrap = el("ul", "playlist-row__videos");
if (!playlist.items.length) {
wrap.appendChild(el("li", "playlist-row__empty", "This playlist is empty."));
return wrap;
}
playlist.items.forEach((item, index) => {
const row = el("li", "playlist-video");
row.draggable = true;
row.dataset.plId = playlist.id;
row.dataset.plIndex = String(index);
const info = videoMap.get(item.value);
const dtag = item.value.split(":")[2] || item.value;
const label = info?.title || dtag;
const title = el("span", "playlist-video__title", label);
row.appendChild(title);
const controls = el("div", "playlist-video__controls");
const up = iconButton(
"Move up",
"move-up",
{ plId: playlist.id, plIndex: index },
ICONS.up,
);
if (index === 0) {
up.disabled = true;
}
const down = iconButton(
"Move down",
"move-down",
{ plId: playlist.id, plIndex: index },
ICONS.down,
);
if (index === playlist.items.length - 1) {
down.disabled = true;
}
const remove = iconButton(
"Remove from playlist",
"remove",
{ plId: playlist.id, plCoord: item.value },
ICONS.remove,
);
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
row.appendChild(controls);
wrap.appendChild(row);
});
return wrap;
}
handleListClick(event) {
const target =
event.target instanceof Element
? event.target.closest("[data-pl-action]")
: null;
if (!target) {
return;
}
const action = target.dataset.plAction;
const id = target.dataset.plId;
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) {
return;
}
if (action === "toggle") {
this.toggleExpand(id);
} else if (action === "rename") {
void this.rename(playlist);
} else if (action === "delete") {
void this.deletePlaylistRow(playlist);
} else if (action === "move-up") {
void this.moveVideo(playlist, Number(target.dataset.plIndex), -1);
} else if (action === "move-down") {
void this.moveVideo(playlist, Number(target.dataset.plIndex), 1);
} else if (action === "remove") {
void this.removeVideo(playlist, target.dataset.plCoord);
}
}
handleDragStart(event) {
const row = event.target?.closest?.(".playlist-video");
if (!row) {
return;
}
this._drag = {
playlistId: row.dataset.plId,
fromIndex: Number(row.dataset.plIndex),
};
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
try {
event.dataTransfer.setData("text/plain", String(this._drag.fromIndex));
} catch (error) {
// some browsers require setData(); ignore failures
}
}
row.classList.add("playlist-video--dragging");
}
handleDragOver(event) {
if (!this._drag) {
return;
}
const row = event.target?.closest?.(".playlist-video");
if (!row || row.dataset.plId !== this._drag.playlistId) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
if (this._dropTarget && this._dropTarget !== row) {
this._dropTarget.classList.remove("playlist-video--drop-target");
}
if (Number(row.dataset.plIndex) !== this._drag.fromIndex) {
row.classList.add("playlist-video--drop-target");
this._dropTarget = row;
}
}
handleDrop(event) {
const drag = this._drag;
const row = event.target?.closest?.(".playlist-video");
this.clearDrag();
if (!drag || !row || row.dataset.plId !== drag.playlistId) {
return;
}
event.preventDefault();
const toIndex = Number(row.dataset.plIndex);
if (
!Number.isInteger(drag.fromIndex) ||
!Number.isInteger(toIndex) ||
drag.fromIndex === toIndex
) {
return;
}
const playlist = this.playlists.find((p) => p.id === drag.playlistId);
if (!playlist) {
return;
}
const items = reorderPlaylistItems(playlist.items, drag.fromIndex, toIndex);
void this.publishUpdate({ ...playlist, items }, null);
}
clearDrag() {
this._drag = null;
this._dropTarget = null;
if (this.listEl) {
this.listEl
.querySelectorAll(
".playlist-video--dragging, .playlist-video--drop-target",
)
.forEach((node) => {
node.classList.remove(
"playlist-video--dragging",
"playlist-video--drop-target",
);
});
}
}
toggleExpand(id) {
if (this.expanded.has(id)) {
this.expanded.delete(id);
} else {
this.expanded.add(id);
}
this.render();
}
async rename(playlist) {
const name = await showTextPrompt("Give this playlist a new name.", {
title: "Rename playlist",
value: playlist.title,
confirmLabel: "Save",
placeholder: "Playlist name",
});
if (!name || name === playlist.title) {
return;
}
await this.publishUpdate({ ...playlist, title: name }, "Playlist renamed.");
}
async deletePlaylistRow(playlist) {
const ok = await showConfirm(
`Delete “${playlist.title}”? This removes the playlist for everyone. It can’t be undone.`,
{ title: "Delete playlist", confirmLabel: "Delete", danger: true },
);
if (!ok) {
return;
}
this.playlists = this.playlists.filter((p) => p.id !== playlist.id);
this.expanded.delete(playlist.id);
this.render();
try {
await deletePlaylist(playlist);
this.mainController.showSuccess?.("Playlist deleted.");
this.emitChanged();
} catch (error) {
devLogger.warn("[playlists] Delete failed:", error);
this.mainController.showError?.("Couldn’t delete the playlist.");
void this.refresh();
}
}
async moveVideo(playlist, index, direction) {
if (!Number.isInteger(index)) {
return;
}
const items = reorderPlaylistItems(playlist.items, index, index + direction);
await this.publishUpdate({ ...playlist, items }, null);
}
async removeVideo(playlist, coordinate) {
const items = removeVideoFromPlaylist(playlist.items, coordinate);
await this.publishUpdate({ ...playlist, items }, "Removed from playlist.");
}
async publishUpdate(updated, successMessage) {
const index = this.playlists.findIndex((p) => p.id === updated.id);
if (index >= 0) {
this.playlists[index] = { ...this.playlists[index], ...updated };
}
this.render();
try {
await publishPlaylist({ pubkey: this.pubkey, ...updated });
if (successMessage) {
this.mainController.showSuccess?.(successMessage);
}
this.emitChanged();
} catch (error) {
devLogger.warn("[playlists] Update failed:", error);
this.mainController.showError?.("Couldn’t save the change.");
void this.refresh();
}
}
emitChanged() {
try {
document.dispatchEvent(
new CustomEvent("bitvid:playlists-changed", {
detail: { pubkey: this.pubkey },
}),
);
} catch (error) {
// best effort
}
}
}
export default ProfilePlaylistsController;
+429
View File
@@ -0,0 +1,429 @@
// js/ui/profileModal/submissionsSection.js
//
// The admin pane's "Submissions" sub-tab (#23) logic, extracted from
// ProfileAdminController to keep that file under the size cap (mirrors
// blockedVideosSection.js). Each function takes the ProfileAdminController as
// `controller` and uses its cached DOM refs + services. Gated by
// FEATURE_SUBMISSIONS.
import {
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
} from "../../submissions/submissionFacade.js";
import { SUBMISSION_TYPE_LABELS } from "../../submissions/submissionService.js";
import { FEATURE_SUBMISSIONS } from "../../constants.js";
import { safeDecodeNpub, safeEncodeNpub } from "../../utils/nostrHelpers.js";
import { formatShortNpub } from "../../utils/formatters.js";
import { showConfirm } from "../confirmDialog.js";
import { devLogger } from "../../utils/logger.js";
function setLoading(controller, value) {
if (controller.submissionsLoading) {
controller.submissionsLoading.classList.toggle("hidden", !value);
}
}
// Let the app recompute the profile-button "pending submissions" dot after a
// moderator approves/denies one.
function emitSubmissionsChanged() {
try {
document.dispatchEvent(new CustomEvent("bitvid:submissions-changed"));
} catch (error) {
// best effort
}
}
export async function populateSubmissions(controller) {
if (!FEATURE_SUBMISSIONS || !controller.submissionsList) {
return;
}
const services = controller.mainController.services;
const accessControl = services?.accessControl;
const actorNpub = services?.getCurrentUserNpub?.() || "";
if (!actorNpub || !accessControl?.canEditAdminLists?.(actorNpub)) {
controller.pendingSubmissions = [];
renderSubmissions(controller);
return;
}
const adminHex = safeDecodeNpub(controller.mainController.adminSuperNpub);
// getEditors() may return a Set (or array) — coerce before mapping.
const editorHexes = Array.from(accessControl.getEditors?.() || [])
.map((npub) => safeDecodeNpub(npub))
.filter(Boolean);
setLoading(controller, true);
let pending = [];
try {
pending = await fetchPendingSubmissions({ adminHex, editorHexes });
} catch (error) {
devLogger.warn("[profileModal] Failed to load submissions:", error);
}
controller.pendingSubmissions = pending;
setLoading(controller, false);
renderSubmissions(controller);
}
export function renderSubmissions(controller) {
if (!controller.submissionsList) {
return;
}
controller.submissionsList.replaceChildren();
const list = Array.isArray(controller.pendingSubmissions)
? controller.pendingSubmissions
: [];
if (controller.submissionsEmpty) {
controller.submissionsEmpty.classList.toggle("hidden", list.length > 0);
}
for (const submission of list) {
controller.submissionsList.appendChild(renderSubmissionRow(submission));
}
}
// Render `**bold**` segments as <strong>; everything else as plain text — never
// parse HTML from the content (submissions are untrusted public events).
function appendInline(el, text) {
const parts = String(text).split("**");
parts.forEach((part, i) => {
if (!part) {
return;
}
if (i % 2 === 1) {
const strong = document.createElement("strong");
strong.textContent = part;
el.appendChild(strong);
} else {
el.appendChild(document.createTextNode(part));
}
});
}
// Tiny, SAFE markdown-lite renderer for the form-generated body: `#…###`
// headings become bold lines, leading `- `/`* ` bullet markers are stripped,
// `**bold**` is honored inline. No HTML is ever parsed from the content.
function renderSubmissionBody(container, content) {
for (const raw of String(content).split("\n")) {
const line = raw.replace(/\s+$/, "");
if (!line.trim()) {
continue;
}
if (/^#{1,6}\s/.test(line)) {
const heading = document.createElement("p");
heading.className = "submission-card__heading";
appendInline(heading, line.replace(/^#{1,6}\s+/, ""));
container.appendChild(heading);
} else {
const p = document.createElement("p");
p.className = "submission-card__line";
appendInline(p, line.replace(/^[-*]\s+/, ""));
container.appendChild(p);
}
}
}
function renderSubmissionRow(submission) {
const li = document.createElement("li");
li.className = "submission-card";
li.dataset.submissionId = submission.eventId;
const head = document.createElement("div");
head.className = "submission-card__head";
const badge = document.createElement("span");
badge.className = "submission-card__type";
badge.textContent =
SUBMISSION_TYPE_LABELS[submission.type] || SUBMISSION_TYPE_LABELS.other;
head.appendChild(badge);
if (submission.applicant) {
const who = document.createElement("span");
who.className = "submission-card__applicant";
who.textContent = submission.applicant;
head.appendChild(who);
}
li.appendChild(head);
if (submission.content) {
const body = document.createElement("div");
body.className = "submission-card__body";
renderSubmissionBody(body, submission.content);
li.appendChild(body);
}
const actions = document.createElement("div");
actions.className = "submission-card__actions";
for (const action of actionsForSubmission(submission)) {
actions.appendChild(renderAction(action));
}
li.appendChild(actions);
return li;
}
// Build a labeled button + one-line description explaining what it does.
function renderAction({ action, label, hint, primary }) {
const wrap = document.createElement("div");
wrap.className = "submission-card__action";
const button = document.createElement("button");
button.type = "button";
button.className = `${primary ? "btn" : "btn-ghost"} focus-ring`;
button.dataset.size = "sm";
button.dataset.submissionAction = action;
button.textContent = label;
wrap.appendChild(button);
if (hint) {
const caption = document.createElement("span");
caption.className = "submission-card__action-hint";
caption.textContent = hint;
wrap.appendChild(caption);
}
return wrap;
}
// The action set depends on the submission type so each button's effect is
// explicit. Only whitelist applications add to the whitelist; only appeals that
// name a real event can unblock it; everything else is just "mark handled".
function actionsForSubmission(submission) {
if (submission.type === "application" && submission.applicant) {
return [
{
action: "approve",
label: "Approve",
hint: "Adds the applicant to the whitelist.",
primary: true,
},
{
action: "deny",
label: "Deny",
hint: "Rejects the application — nothing is whitelisted.",
},
];
}
if (submission.type === "appeal") {
const actions = [];
if (submission.targetEventId) {
actions.push({
action: "approve-appeal",
label: "Approve & unblock",
hint: "Finds why it's hidden and lifts the block (author-level is confirmed separately).",
primary: true,
});
}
actions.push({
action: "deny",
label: submission.targetEventId ? "Keep blocked" : "Dismiss",
hint: submission.targetEventId
? "Dismisses the appeal — the video stays blocked."
: "Marks the appeal as handled (no event linked to unblock).",
});
return actions;
}
return [
{
action: "deny",
label: "Mark handled",
hint: "Marks this submission as reviewed.",
},
];
}
export function handleSubmissionsClick(controller, event) {
const button =
event.target instanceof Element
? event.target.closest("[data-submission-action]")
: null;
if (!button) {
return;
}
const card = button.closest("[data-submission-id]");
const id = card?.dataset?.submissionId || "";
const submission = (controller.pendingSubmissions || []).find(
(s) => s.eventId === id,
);
if (!submission) {
return;
}
const action = button.dataset.submissionAction;
if (action === "approve") {
void approveSubmission(controller, submission);
} else if (action === "approve-appeal") {
void approveAppeal(controller, submission);
} else {
void denySubmission(controller, submission);
}
}
async function approveSubmission(controller, submission) {
const services = controller.mainController.services;
const accessControl = services?.accessControl;
const actorNpub = services?.getCurrentUserNpub?.() || "";
const applicant = submission.applicant;
if (!actorNpub || !accessControl || !applicant) {
return;
}
const ok = await showConfirm(
`Approve ${applicant}? They'll be added to the whitelist.`,
{ title: "Approve application", confirmLabel: "Approve" },
);
if (!ok) {
return;
}
controller.pendingSubmissions = (controller.pendingSubmissions || []).filter(
(s) => s.eventId !== submission.eventId,
);
renderSubmissions(controller);
try {
await accessControl.addToWhitelist(actorNpub, applicant);
await markSubmissionResolved({
submission,
status: "approved",
actingHex: safeDecodeNpub(actorNpub),
});
controller.mainController.showSuccess?.(
`Approved — ${applicant} added to the whitelist.`,
);
controller.populateAdminLists();
emitSubmissionsChanged();
} catch (error) {
devLogger.warn("[profileModal] Approve submission failed:", error);
controller.mainController.showError?.("Couldn't approve — please try again.");
void populateSubmissions(controller);
}
}
// Diagnose WHY the appealed video is hidden, then apply the right fix. A video
// can be hidden by an event-level block (just that video) or an author-level
// block (the whole npub — hides ALL their content). We resolve the event's
// author from relays and branch: author-level unblock is a bigger hammer, so it
// gets its own explicit, strongly-worded confirmation and is never bundled into
// the default "unblock this video".
async function approveAppeal(controller, submission) {
const services = controller.mainController.services;
const accessControl = services?.accessControl;
const actorNpub = services?.getCurrentUserNpub?.() || "";
const targetEventId = submission.targetEventId;
if (!actorNpub || !accessControl || !targetEventId) {
return;
}
const eventBlocked =
typeof accessControl.isEventBlacklisted === "function"
? Boolean(accessControl.isEventBlacklisted(targetEventId))
: false;
let authorHex = "";
try {
authorHex = await resolveEventAuthorHex({ eventId: targetEventId });
} catch (error) {
authorHex = "";
}
const authorNpub = authorHex ? safeEncodeNpub(authorHex) || "" : "";
const authorBlocked =
authorHex && typeof accessControl.isBlacklisted === "function"
? Boolean(accessControl.isBlacklisted(authorHex))
: false;
// Pick the action + confirmation for the real block state. `apply` runs the
// unblock(s) after the admin confirms; `successMessage` reports what happened.
let apply = null;
let successMessage = "";
if (authorBlocked) {
const who = authorNpub ? formatShortNpub(authorNpub) : "this creator";
const confirmed = await showConfirm(
`This video is hidden because its author (${who}) is blocked platform-wide. Unblocking restores ALL of their content, not just this video. Unblock the author?`,
{ title: "Author is blocked", confirmLabel: "Unblock author" },
);
if (!confirmed) {
return;
}
apply = async () => {
const result = await accessControl.removeFromBlacklist(actorNpub, authorNpub);
if (!result || result.ok === false) {
throw new Error(result?.error || "author-unblock-failed");
}
// Also clear any event-level block so the video is fully visible.
if (eventBlocked && typeof accessControl.removeFromEventBlacklist === "function") {
await accessControl.removeFromEventBlacklist(actorNpub, targetEventId);
}
};
successMessage = "Appeal approved — the author has been unblocked.";
} else if (eventBlocked) {
const confirmed = await showConfirm(
"Unblock this video? It'll be removed from the block list and become visible again.",
{ title: "Approve appeal", confirmLabel: "Unblock" },
);
if (!confirmed) {
return;
}
apply = async () => {
const result = await accessControl.removeFromEventBlacklist(
actorNpub,
targetEventId,
);
if (!result || result.ok === false) {
throw new Error(result?.error || "unblock-failed");
}
};
successMessage = "Appeal approved — the video has been unblocked.";
} else {
// Not on either block list. Distinguish "confirmed not blocked" from
// "couldn't verify" (event not found on connected relays).
const message = authorHex
? "This video isn't on the event or author block list — there's nothing to unblock (it may already be unblocked, or hidden by trusted moderation reports). Mark the appeal as resolved?"
: "Couldn't find this video on connected relays to check its block status. If you've already lifted the block, mark the appeal as resolved — otherwise cancel and check the admin block lists.";
const confirmed = await showConfirm(message, {
title: authorHex ? "Nothing to unblock" : "Couldn't verify",
confirmLabel: "Mark resolved",
});
if (!confirmed) {
return;
}
apply = async () => {};
successMessage = "Appeal marked as resolved.";
}
controller.pendingSubmissions = (controller.pendingSubmissions || []).filter(
(s) => s.eventId !== submission.eventId,
);
renderSubmissions(controller);
try {
await apply();
await markSubmissionResolved({
submission,
status: "approved",
actingHex: safeDecodeNpub(actorNpub),
});
controller.mainController.showSuccess?.(successMessage);
controller.populateAdminLists?.();
emitSubmissionsChanged();
} catch (error) {
devLogger.warn("[profileModal] Approve appeal failed:", error);
controller.mainController.showError?.(
"Couldn't complete the appeal — please try again.",
);
void populateSubmissions(controller);
}
}
async function denySubmission(controller, submission) {
const actorNpub =
controller.mainController.services?.getCurrentUserNpub?.() || "";
if (!actorNpub) {
return;
}
controller.pendingSubmissions = (controller.pendingSubmissions || []).filter(
(s) => s.eventId !== submission.eventId,
);
renderSubmissions(controller);
try {
await markSubmissionResolved({
submission,
status: "denied",
actingHex: safeDecodeNpub(actorNpub),
});
controller.mainController.showSuccess?.("Submission dismissed.");
emitSubmissionsChanged();
} catch (error) {
devLogger.warn("[profileModal] Deny submission failed:", error);
controller.mainController.showError?.("Couldn't dismiss — please try again.");
void populateSubmissions(controller);
}
}
+52 -1
View File
@@ -45,6 +45,8 @@ import { DMSettingsModalController } from "./dm/DMSettingsModalController.js";
import { ProfileWalletController } from "./profileModal/ProfileWalletController.js";
import { ProfileStorageController } from "./profileModal/ProfileStorageController.js";
import { MyVideosController } from "./profileModal/MyVideosController.js";
import { ProfilePlaylistsController } from "./profileModal/ProfilePlaylistsController.js";
import { FEATURE_PLAYLISTS } from "../constants.js";
import { ProfileDirectMessageController } from "./profileModal/ProfileDirectMessageController.js";
import { ProfileRelayController } from "./profileModal/ProfileRelayController.js";
import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js";
@@ -53,6 +55,7 @@ import { ProfileModerationController } from "./profileModal/ProfileModerationCon
import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js";
import { ProfileEditController } from "./profileModal/ProfileEditController.js";
import { isInStackedModal } from "./focusTrapStacking.js";
import { decorateAdminAvatar } from "./adminBadge.js";
const noop = () => {};
@@ -530,6 +533,7 @@ export class ProfileModalController {
this.walletController = new ProfileWalletController(this);
this.storageController = new ProfileStorageController(this);
this.myVideosController = new MyVideosController(this);
this.playlistsController = new ProfilePlaylistsController(this);
this.mobileViewState = "menu";
this.lastMobileViewState = "menu";
this.setActivePane(this.getActivePane());
@@ -696,6 +700,12 @@ export class ProfileModalController {
this.navButtons.storage = document.getElementById("profileNavStorage") || null;
this.navButtons.myvideos =
document.getElementById("profileNavMyVideos") || null;
this.navButtons.playlists =
document.getElementById("profileNavPlaylists") || null;
// Hide the Playlists tab entirely while the feature is off.
if (!FEATURE_PLAYLISTS && this.navButtons.playlists) {
this.navButtons.playlists.classList.add("hidden");
}
this.navButtons.hashtags =
document.getElementById("profileNavHashtags") || null;
this.navButtons.subscriptions =
@@ -711,6 +721,12 @@ export class ProfileModalController {
this.navButtons.admin = document.getElementById("profileNavAdmin") || null;
this.navButtons.safety = document.getElementById("profileNavSafety") || null;
// Pending-submissions dots: on the Admin nav tab and the Submissions subtab.
this.adminNavUnreadDot =
document.getElementById("profileNavAdminUnreadDot") || null;
this.adminSubmissionsDot =
document.getElementById("adminSubtabSubmissionsDot") || null;
this.profileEditBtn = document.getElementById("profileEditBtn") || null;
this.profileEditBackBtn = document.getElementById("profileEditBackBtn") || null;
@@ -721,6 +737,8 @@ export class ProfileModalController {
this.panes.storage = document.getElementById("profilePaneStorage") || null;
this.panes.myvideos =
document.getElementById("profilePaneMyVideos") || null;
this.panes.playlists =
document.getElementById("profilePanePlaylists") || null;
this.panes.hashtags = document.getElementById("profilePaneHashtags") || null;
this.panes.subscriptions =
document.getElementById("profilePaneSubscriptions") || null;
@@ -816,6 +834,7 @@ export class ProfileModalController {
this.walletController.cacheDomReferences();
this.storageController.cacheDomReferences();
this.myVideosController.cacheDomReferences();
this.playlistsController.cacheDomReferences();
if (this.dmController.pendingMessagesRender) {
const { messages, actorPubkey } = this.dmController.pendingMessagesRender;
@@ -1340,6 +1359,26 @@ export class ProfileModalController {
);
}
// Pending-submissions dots for admins: on the Admin nav tab (gated on that tab
// being visible) and on the Submissions subtab button inside the admin pane.
setSubmissionsUnreadIndicator(visible) {
const show = Boolean(visible);
const adminButton = this.navButtons.admin;
const adminNavVisible =
adminButton instanceof HTMLElement &&
!adminButton.classList.contains("hidden") &&
!adminButton.hasAttribute("hidden");
if (this.adminNavUnreadDot instanceof HTMLElement) {
this.adminNavUnreadDot.classList.toggle(
"is-visible",
show && adminNavVisible,
);
}
if (this.adminSubmissionsDot instanceof HTMLElement) {
this.adminSubmissionsDot.classList.toggle("is-visible", show);
}
}
@@ -1976,6 +2015,7 @@ export class ProfileModalController {
this.walletController.registerEventListeners();
this.storageController.registerEventListeners();
this.myVideosController.registerEventListeners();
this.playlistsController.registerEventListeners();
this.adminController.registerEventListeners();
this.moderationController.registerEventListeners();
@@ -2593,6 +2633,13 @@ export class ProfileModalController {
if (this.identityCardAvatar.src !== activeAvatarSrc) {
this.identityCardAvatar.src = activeAvatarSrc;
}
// Ring + star on the "Acting as" avatar when the active profile is admin.
if (this.identityCardAvatar.parentElement) {
decorateAdminAvatar(
this.identityCardAvatar.parentElement,
hasActiveProfile ? activeMeta?.npub || "" : "",
);
}
}
const listEl = this.switcherList;
@@ -2731,7 +2778,9 @@ export class ProfileModalController {
}
metaSpan.append(topLine, nameSpan, npubSpan);
button.append(avatarSpan, metaSpan);
// Ring + star on a saved profile's avatar when that account is admin.
const avatarNode = decorateAdminAvatar(avatarSpan, entry.pubkey);
button.append(avatarNode, metaSpan);
const ariaLabel = isSelected
? `${cardDisplayName} selected`
@@ -3413,6 +3462,8 @@ export class ProfileModalController {
this.storageController.populateStoragePane();
} else if (target === "myvideos") {
void this.myVideosController.populate({ forceFetch: true });
} else if (target === "playlists") {
void this.playlistsController.refresh();
} else if (target === "hashtags") {
this.hashtagController.populateHashtagPreferences();
if (activeHex && this.services.hashtagPreferences) {
+107
View File
@@ -151,4 +151,111 @@ export function showPasswordPrompt(message, options = {}) {
});
}
// A plain single-line text prompt (sibling of showPasswordPrompt) — used for
// things like renaming a playlist. Resolves the trimmed string, or null on
// cancel / empty. Prefill with `value`.
export function showTextPrompt(message, options = {}) {
const {
title = "",
confirmLabel = "Save",
cancelLabel = "Cancel",
placeholder = "",
value = "",
maxLength = 200,
} = options || {};
if (typeof document === "undefined" || !document.body) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5 flex flex-col gap-3";
if (message) {
const text = document.createElement("p");
text.className = "text-sm text-muted-strong";
text.textContent = String(message);
body.appendChild(text);
}
const input = document.createElement("input");
input.type = "text";
input.className = "form-input";
input.placeholder = placeholder;
input.value = value;
input.maxLength = maxLength;
body.appendChild(input);
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-ghost focus-ring";
cancelBtn.textContent = cancelLabel;
const okBtn = document.createElement("button");
okBtn.type = "button";
okBtn.className = "btn focus-ring";
okBtn.textContent = confirmLabel;
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
const submit = () => cleanup(input.value.trim() || null);
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(null);
} else if (event.key === "Enter" && document.activeElement === input) {
event.preventDefault();
submit();
}
}
cancelBtn.addEventListener("click", () => cleanup(null));
backdrop.addEventListener("click", () => cleanup(null));
okBtn.addEventListener("click", submit);
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
input.focus();
input.select();
});
}
export default showPasswordPrompt;
+287 -4
View File
@@ -15,7 +15,20 @@ import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "./viewCounter.js";
import { listVideoZapEventsWithDefaultClient } from "./zapEventsFacade.js";
import {
getVideoZapTotalSnapshot,
requestVideoZapTotal,
onZapTotalsChanged,
ZAP_RECEIPT_KIND,
} from "./zapTotals.js";
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
import {
verifyBitvidZapTally,
extractBolt11Fields,
} from "./payments/zapReceiptValidator.js";
import { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.js";
import { FEATURE_ZAP_TALLY } from "./constants.js";
import { devLogger } from "./utils/logger.js";
export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window
@@ -58,6 +71,73 @@ export function buildViewCountTimeSeries(
return { series, total: cumulative };
}
// Read the authoritative sats for one zap event (9735 receipt or bitvid tally),
// plus a dedup key (payment_hash preferred, else event id). Returns null when
// the event isn't a countable zap (invalid tally, no amount, …).
function readZapEvent(event, tools) {
const getSats = tools?.nip57?.getSatoshisAmountFromBolt11;
if (event?.kind === ZAP_TALLY_KIND) {
const verdict = verifyBitvidZapTally(event, { getSats });
if (!verdict?.ok || !(verdict.sats > 0)) {
return null;
}
return { sats: verdict.sats, dedup: verdict.paymentHash || event.id };
}
if (event?.kind === ZAP_RECEIPT_KIND && event?.id) {
const bolt11 =
(Array.isArray(event.tags)
? event.tags.find((t) => Array.isArray(t) && t[0] === "bolt11")?.[1]
: "") || "";
let sats = 0;
try {
const parsed = typeof getSats === "function" ? Number(getSats(bolt11)) : NaN;
sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
} catch (error) {
sats = 0;
}
if (sats <= 0) {
return null;
}
const dedup = extractBolt11Fields(bolt11).paymentHash || event.id;
return { sats, dedup };
}
return null;
}
// Bucket zap events into a cumulative SATS-over-time series, deduped by
// payment_hash across 9735s + tallies (same rule as the zap-totals store), so
// the chart and the badge agree. Returns ordered buckets + the total.
export function buildZapSatsTimeSeries(
events,
{ windowSeconds = VIEW_CHART_WINDOW_SECONDS, tools = null } = {},
) {
const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS);
const seenPayments = new Set();
const perBucket = new Map();
for (const event of Array.isArray(events) ? events : []) {
const createdAt = Number(event?.created_at);
if (!Number.isFinite(createdAt) || createdAt <= 0) {
continue;
}
const read = readZapEvent(event, tools);
if (!read || seenPayments.has(read.dedup)) {
continue;
}
seenPayments.add(read.dedup);
const bucket = Math.floor(createdAt / w);
perBucket.set(bucket, (perBucket.get(bucket) || 0) + read.sats);
}
const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b);
const series = [];
let cumulative = 0;
for (const bucket of buckets) {
const sats = perBucket.get(bucket) || 0;
cumulative += sats;
series.push({ bucketStart: bucket * w, sats, cumulative });
}
return { series, total: cumulative };
}
function formatDay(seconds) {
try {
return new Date(seconds * 1000).toLocaleDateString(undefined, {
@@ -138,6 +218,128 @@ export function buildViewCountChartSvg(doc, series, { width = 320, height = 120
return svg;
}
// Draw one cumulative series (area + line) into `svg` using a color class on a
// wrapping <g> (currentColor). x()/y() map data → pixels. Independent per-series
// Y scaling (D5): the caller passes this series' own maxY.
function drawSeries(doc, svg, points, { x, y, baseY, colorClass }) {
if (!points.length) return;
const coords =
points.length === 1
? [
[x(points[0].bucketStart), y(points[0].cumulative)],
[x(points[points.length - 1].bucketStart) + 1, y(points[0].cumulative)],
]
: points.map((p) => [x(p.bucketStart), y(p.cumulative)]);
const linePoints = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(" ");
const areaPoints =
`${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` +
linePoints +
` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`;
const group = doc.createElementNS(SVG_NS, "g");
if (colorClass) group.classList.add(colorClass);
const area = doc.createElementNS(SVG_NS, "polygon");
area.setAttribute("points", areaPoints);
area.setAttribute("fill", "currentColor");
area.setAttribute("fill-opacity", "0.15");
area.setAttribute("stroke", "none");
group.appendChild(area);
const line = doc.createElementNS(SVG_NS, "polyline");
line.setAttribute("points", linePoints);
line.setAttribute("fill", "none");
line.setAttribute("stroke", "currentColor");
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-linejoin", "round");
line.setAttribute("stroke-linecap", "round");
group.appendChild(line);
svg.appendChild(group);
}
// Popularity chart with TWO cumulative series on a shared TIME x-axis, each with
// its OWN y scale (views in counts, zaps in sats — independent per-series
// scaling shows each trend honestly; the legend names the units). Adds VISIBLE
// x-axis date ticks. Views = accent (red), zaps = --color-zap (orange).
export function buildPopularityChartSvg(
doc,
{ views = [], zaps = [] } = {},
{ width = 320, height = 132 } = {},
) {
const pad = { top: 8, right: 8, bottom: 26, left: 8 };
const svg = doc.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", "100%");
svg.setAttribute("role", "img");
svg.setAttribute("preserveAspectRatio", "none");
const viewsPts = Array.isArray(views) ? views : [];
const zapsPts = Array.isArray(zaps) ? zaps : [];
if (!viewsPts.length && !zapsPts.length) {
svg.setAttribute("aria-label", "No views or zaps yet");
return svg;
}
const innerW = Math.max(1, width - pad.left - pad.right);
const innerH = Math.max(1, height - pad.top - pad.bottom);
const baseY = pad.top + innerH;
// Shared time domain across both series.
const allX = [...viewsPts, ...zapsPts].map((p) => p.bucketStart);
const minX = Math.min(...allX);
const maxX = Math.max(...allX);
const spanX = maxX - minX || 1;
const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW;
// Per-series y scale (each fills the height against its own max).
const makeY = (pts) => {
const maxY = (pts[pts.length - 1]?.cumulative) || 1;
return (cumulative) => pad.top + (1 - cumulative / maxY) * innerH;
};
drawSeries(doc, svg, viewsPts, {
x,
y: makeY(viewsPts),
baseY,
colorClass: "text-accent",
});
drawSeries(doc, svg, zapsPts, {
x,
y: makeY(zapsPts),
baseY,
colorClass: "text-zap",
});
// Visible x-axis date ticks (min → max, a few evenly spaced) so the reader
// knows the time range — the old chart only put dates in the aria-label.
const tickCount = spanX > 1 ? 3 : 1;
for (let i = 0; i < tickCount; i += 1) {
const t = tickCount === 1 ? minX : minX + (spanX * i) / (tickCount - 1);
const label = doc.createElementNS(SVG_NS, "text");
const px = x(t);
label.setAttribute("x", px.toFixed(1));
label.setAttribute("y", (height - 8).toFixed(1));
label.setAttribute("font-size", "9");
label.setAttribute("fill", "currentColor");
label.setAttribute("fill-opacity", "0.6");
label.setAttribute(
"text-anchor",
i === 0 ? "start" : i === tickCount - 1 ? "end" : "middle",
);
label.textContent = formatDay(t);
svg.appendChild(label);
}
const viewsTotal = viewsPts[viewsPts.length - 1]?.cumulative || 0;
const zapsTotal = zapsPts[zapsPts.length - 1]?.cumulative || 0;
svg.setAttribute(
"aria-label",
`${viewsTotal} views and ${zapsTotal} sats zapped from ${formatDay(minX)} to ${formatDay(maxX)}`,
);
return svg;
}
// ---- Modal -------------------------------------------------------------------
function pointerForVideo(video) {
@@ -173,6 +375,11 @@ export function openPopularityModal({
video,
listViewEvents = listVideoViewEventsWithDefaultClient,
subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient,
listZapEvents = listVideoZapEventsWithDefaultClient,
getTools = () =>
(typeof globalThis !== "undefined" &&
(globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ || globalThis.NostrTools)) ||
null,
mount = null,
} = {}) {
if (!doc) return null;
@@ -216,11 +423,28 @@ export function openPopularityModal({
const chartHost = el(doc, "div", "popularity-modal__chart");
dialog.appendChild(chartHost);
// Legend: red = Views, orange = Zaps (sats). Token colors via .text-accent /
// .text-zap on the swatch (currentColor). Only shown when the feature is on.
const legend = el(doc, "div", "popularity-legend");
const legendItem = (colorClass, label) => {
const item = el(doc, "span", "popularity-legend__item");
item.appendChild(el(doc, "span", `popularity-legend__swatch ${colorClass}`));
item.appendChild(el(doc, "span", "", label));
return item;
};
legend.appendChild(legendItem("text-accent", "Views"));
if (FEATURE_ZAP_TALLY) {
legend.appendChild(legendItem("text-zap", "Zaps (sats)"));
}
dialog.appendChild(legend);
const note = el(
doc,
"p",
"text-2xs text-muted mt-2",
"Public view data · updates as more views load.",
FEATURE_ZAP_TALLY
? "Public view + zap data · updates as more load."
: "Public view data · updates as more views load.",
);
dialog.appendChild(note);
@@ -229,8 +453,10 @@ export function openPopularityModal({
// ---- data + render ----
const eventsById = new Map();
const zapEventsById = new Map();
let closed = false;
let unsubscribe = null;
let unsubscribeZapTotals = null;
let countToken = null;
let counterTotal = null;
let rerenderTimer = null;
@@ -242,6 +468,13 @@ export function openPopularityModal({
return true;
};
const addZapEvent = (event) => {
const id = typeof event?.id === "string" ? event.id : "";
if (!id || zapEventsById.has(id)) return false;
zapEventsById.set(id, event);
return true;
};
// The headline number must match what the card / counter shows everywhere
// (the authoritative, deduped, cached viewCounter total) — NOT a separate count
// re-derived from a one-off relay list (which varies by relay response and
@@ -254,17 +487,33 @@ export function openPopularityModal({
if (!Number.isFinite(total)) {
return;
}
totalLine.textContent =
const viewsText =
total > 0
? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}`
: "No views recorded yet.";
// Zap total from the shared store (matches the badge), appended when on.
if (FEATURE_ZAP_TALLY) {
let zapSats = 0;
try {
zapSats = getVideoZapTotalSnapshot(pointer) || 0;
} catch (error) {
zapSats = 0;
}
totalLine.textContent =
zapSats > 0 ? `${viewsText} · ${formatViewCount(zapSats)} sats zapped` : viewsText;
} else {
totalLine.textContent = viewsText;
}
};
const renderChart = () => {
if (closed) return;
const { series } = buildViewCountTimeSeries([...eventsById.values()]);
const { series: views } = buildViewCountTimeSeries([...eventsById.values()]);
const { series: zaps } = FEATURE_ZAP_TALLY
? buildZapSatsTimeSeries([...zapEventsById.values()], { tools: getTools() })
: { series: [] };
chartHost.textContent = "";
chartHost.appendChild(buildViewCountChartSvg(doc, series));
chartHost.appendChild(buildPopularityChartSvg(doc, { views, zaps }));
renderHeadline();
};
@@ -297,6 +546,13 @@ export function openPopularityModal({
// best effort
}
}
if (typeof unsubscribeZapTotals === "function") {
try {
unsubscribeZapTotals();
} catch (error) {
// best effort
}
}
doc.removeEventListener("keydown", onKeydown, true);
backdrop.remove();
if (activePopularityModal === handle) {
@@ -362,6 +618,33 @@ export function openPopularityModal({
devLogger.warn("[popularity] Failed to subscribe to view events:", error);
}
// Zaps-over-time (orange line): fetch the pointer's zap events (9735 + tally)
// for the chart, and (headline) request the shared total + re-render when the
// zap-totals store changes. Gated by the feature flag.
if (FEATURE_ZAP_TALLY) {
try {
requestVideoZapTotal(pointer); // primes the store for the headline
unsubscribeZapTotals = onZapTotalsChanged(() => {
if (!closed) renderHeadline();
});
} catch (error) {
devLogger.warn("[popularity] Failed to subscribe to zap totals:", error);
}
Promise.resolve()
.then(() => listZapEvents(pointer, { since }))
.then((events) => {
if (closed) return;
let added = false;
for (const event of Array.isArray(events) ? events : []) {
if (addZapEvent(event)) added = true;
}
if (added) renderChart();
})
.catch((error) => {
devLogger.warn("[popularity] Failed to load zap events:", error);
});
}
const handle = { close };
activePopularityModal = handle;
return handle;
+5
View File
@@ -5,6 +5,7 @@ import { initKidsView } from "./kidsView.js";
import { initExploreView } from "./exploreView.js";
import { initTrendingView } from "./trendingView.js";
import { initMostZappedView } from "./mostZappedView.js";
import { initPlaylistView } from "./playlistView.js";
import { subscriptions } from "./subscriptions.js";
import { getApplication } from "./applicationContext.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
@@ -232,6 +233,10 @@ export const viewInitRegistry = {
// Call the initialization function from channelProfile.js
initChannelProfileView();
},
[FEED_TYPES.PLAYLIST]: () => {
// #37: render a creator playlist's ordered videos.
initPlaylistView({ getApp: getApplication });
},
[FEED_TYPES.SEARCH]: async () => {
try {
const module = await import("./searchView.js");
+71
View File
@@ -0,0 +1,71 @@
// js/zapEventsFacade.js
//
// Lists the zap events for a video/profile pointer so the Popularity chart can
// draw a zaps-over-time series (docs/zap-tally-plan.md §5.9). Fetches BOTH real
// NIP-57 receipts (kind 9735) and bitvid tallies (ZAP_TALLY_KIND); the chart's
// buildZapSatsTimeSeries verifies tallies + dedups by payment_hash, so this just
// needs to return the raw events. Routed through the L1 SubscriptionManager
// (relay caps + in-flight dedupe), matching the zap-totals store.
import { nostrClient } from "./nostrClientFacade.js";
import { ZAP_RECEIPT_KIND } from "./zapTotals.js";
import { ZAP_TALLY_KIND } from "./nostrEventSchemas.js";
import { devLogger } from "./utils/logger.js";
const LIMIT = 500;
// pointer: the tag-style ["a"|"e", value, relay?] array resolveVideoPointer
// produces (or an { type, value } object).
function pointerFilterKey(pointer) {
const type = Array.isArray(pointer)
? pointer[0]
: pointer && typeof pointer === "object"
? pointer.type
: "";
const value = Array.isArray(pointer)
? pointer[1]
: pointer && typeof pointer === "object"
? pointer.value
: "";
if ((type === "a" || type === "e") && typeof value === "string" && value.trim()) {
return { tag: `#${type}`, value: value.trim() };
}
return null;
}
export async function listVideoZapEventsWithDefaultClient(
pointer,
{ since, client = nostrClient } = {},
) {
const pf = pointerFilterKey(pointer);
if (!pf) {
return [];
}
const manager =
typeof client?.getSubscriptionManager === "function"
? client.getSubscriptionManager()
: null;
const relays = Array.isArray(client?.relays) ? client.relays : [];
if (!manager || typeof manager.list !== "function" || !relays.length) {
return [];
}
const filter = {
kinds: [ZAP_RECEIPT_KIND, ZAP_TALLY_KIND],
[pf.tag]: [pf.value],
limit: LIMIT,
};
if (Number.isFinite(since) && since > 0) {
filter.since = Math.floor(since);
}
try {
const events = await manager.list({ relays, filters: [filter] });
return Array.isArray(events) ? events : [];
} catch (error) {
devLogger.warn("[zapEvents] Failed to list zap events:", error);
return [];
}
}
export default listVideoZapEventsWithDefaultClient;
+25 -3
View File
@@ -39,14 +39,16 @@ function tagValues(event, name) {
// Accepts BOTH pointer shapes used in the app: the canonical tag-style array
// ["a"|"e", value, relay?] (what deriveVideoPointerInfo/resolveVideoPointer
// produce) and the object form { type, value }.
const POINTER_TYPES = new Set(["a", "e", "p"]);
export function pointerKey(pointer) {
let type = "";
let value = "";
if (Array.isArray(pointer)) {
type = pointer[0] === "a" || pointer[0] === "e" ? pointer[0] : "";
type = POINTER_TYPES.has(pointer[0]) ? pointer[0] : "";
value = typeof pointer[1] === "string" ? pointer[1].trim() : "";
} else if (pointer && typeof pointer === "object") {
type = pointer.type === "a" || pointer.type === "e" ? pointer.type : "";
type = POINTER_TYPES.has(pointer.type) ? pointer.type : "";
value = typeof pointer.value === "string" ? pointer.value.trim() : "";
}
if (!type || !value) {
@@ -55,6 +57,14 @@ export function pointerKey(pointer) {
return `${type}:${value}`;
}
// A `p:` (profile/channel) pointer for a creator pubkey — the total sats zapped
// to that pubkey via bitvid (direct profile zaps AND zaps to their videos, both
// tagged `p`). Used by the channel page; distinct key space from video a:/e:.
export function profilePointer(pubkey) {
const hex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
return hex ? ["p", hex] : null;
}
// Sats carried by one receipt: bolt11 first (the actual paid invoice), then
// the zap request's amount tag (msats) embedded in the description.
export function extractReceiptAmountSats(event, tools) {
@@ -207,9 +217,11 @@ export function createZapTotalsStore({
const aValues = [];
const eValues = [];
const pValues = [];
for (const pointer of batch.values()) {
if (pointer.type === "a") aValues.push(pointer.value);
else if (pointer.type === "e") eValues.push(pointer.value);
else if (pointer.type === "p") pValues.push(pointer.value);
}
// One query for both sources: real 9735 receipts + bitvid tallies.
@@ -236,6 +248,15 @@ export function createZapTotalsStore({
limit: RECEIPTS_PER_FILTER_LIMIT,
});
}
// Profile/channel totals: every zap to this pubkey (direct or via a video)
// is tagged `#p`. Only queried when a p: pointer is requested (channel page).
for (let i = 0; i < pValues.length; i += MAX_POINTERS_PER_FILTER) {
filters.push({
kinds,
"#p": pValues.slice(i, i + MAX_POINTERS_PER_FILTER),
limit: RECEIPTS_PER_FILTER_LIMIT,
});
}
let events = [];
try {
@@ -267,6 +288,7 @@ export function createZapTotalsStore({
const keys = [
...tagValues(event, "a").map((value) => `a:${value}`),
...tagValues(event, "e").map((value) => `e:${value}`),
...tagValues(event, "p").map((value) => `p:${value}`),
].filter((key) => batch.has(key) || totals.has(key));
if (!keys.length) {
continue;
@@ -310,7 +332,7 @@ export function createZapTotalsStore({
continue;
}
const keys = verdict.pointerTags
.filter((t) => t[0] === "a" || t[0] === "e")
.filter((t) => t[0] === "a" || t[0] === "e" || t[0] === "p")
.map((t) => `${t[0]}:${t[1]}`)
.filter((key) => batch.has(key) || totals.has(key));
for (const key of keys) {
+2 -2
View File
@@ -32,7 +32,7 @@ const GRANDFATHERED = {
"js/channelProfile.js": 5529,
"js/nostr/client.js": 4356,
"js/ui/components/VideoCard.js": 3129,
"js/nostrEventSchemas.js": 3250,
"js/nostrEventSchemas.js": 3541,
"js/ui/loginModalController.js": 2552,
"js/historyView.js": 2613,
"js/services/moderationService.js": 2512,
@@ -44,7 +44,7 @@ const GRANDFATHERED = {
"js/state/cache.js": 1827,
"js/app/feedCoordinator.js": 1857,
"js/viewCounter.js": 1023,
"js/app/authSessionCoordinator.js": 1803,
"js/app/authSessionCoordinator.js": 2008,
"js/ui/components/RevertModal.js": 1663,
"js/payments/nwcClient.js": 1642,
"js/ui/moreMenuController.js": 1601,
+1 -4
View File
@@ -13,10 +13,7 @@ process.env.NODE_ENV = "test";
// imported `node:test`, silently excluding ~20 bare-assert files; several were
// failing on main, unnoticed). Each entry MUST cite why and link the triage item.
// Remove an entry as soon as its file is fixed. See todo/TODO_2026-06-20_pre-launch.md todo-11b.
const QUARANTINE = new Map([
["tests/user-blocks.test.mjs", "HANG — async leak; needs a deterministic rewrite. triage todo-11b"],
["tests/nwc-client.test.mjs", "FAIL — mocks nostr-tools but is shadowed by the frozen canonical toolkit the bootstrap installs (real @noble rejects the fake keys). The underlying production bug (parseNwcUri passed a hex secret to getPublicKey, which needs bytes — NWC connection broken) is FIXED and guarded by tests/nwc-parse-uri.test.mjs; this file needs a mock-injection rework. triage todo-11b (NWC / item #3)"],
]);
const QUARANTINE = new Map([]);
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const testsDir = path.join(rootDir, "tests");
+95
View File
@@ -0,0 +1,95 @@
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,
decorateAdminAvatar,
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);
});
test("decorateAdminAvatar wraps, rings, and stars an admin avatar (attached)", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
const wrap = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
// wrapped in place, ring on the avatar, star on the wrap
assert.ok(wrap.classList.contains("admin-avatar-wrap"));
assert.equal(avatar.parentElement, wrap);
assert.equal(wrap.parentElement, parent, "wrapper takes the avatar's place");
assert.ok(avatar.classList.contains("admin-ring"));
assert.equal(wrap.querySelectorAll(".admin-star").length, 1);
});
test("decorateAdminAvatar is idempotent and reuses the wrapper", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
const first = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
const second = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
assert.equal(first, second, "same wrapper reused");
assert.equal(parent.querySelectorAll(".admin-avatar-wrap").length, 1);
assert.equal(first.querySelectorAll(".admin-star").length, 1);
});
test("decorateAdminAvatar adds no ring/star for a non-admin", () => {
const parent = doc.createElement("div");
const avatar = doc.createElement("span");
parent.appendChild(avatar);
decorateAdminAvatar(avatar, STRANGER_NPUB, { doc });
assert.equal(avatar.classList.contains("admin-ring"), false);
// wrapper may exist but must carry no star
assert.equal(parent.querySelectorAll(".admin-star").length, 0);
});
test("decorateAdminAvatar handles a detached avatar (returns the wrapper to append)", () => {
const avatar = doc.createElement("span");
const wrap = decorateAdminAvatar(avatar, ADMIN_SUPER_NPUB, { doc });
assert.ok(wrap.classList.contains("admin-avatar-wrap"));
assert.equal(avatar.parentElement, wrap);
assert.ok(avatar.classList.contains("admin-ring"));
assert.equal(wrap.querySelectorAll(".admin-star").length, 1);
});
+20
View File
@@ -194,6 +194,8 @@ describe("DM block/mute filtering", () => {
: nostrClient.relays;
const originalFetchListIncrementally = nostrClient.fetchListIncrementally;
const originalRelayEntries = relayManager.getEntries();
const originalPool = nostrClient.pool;
const originalGetSubscriptionManager = nostrClient.getSubscriptionManager;
const relayUrls = ["wss://dm-block-regression.example"];
relayManager.setEntries(
@@ -202,6 +204,22 @@ describe("DM block/mute filtering", () => {
);
nostrClient.relays = relayUrls;
// Keep this test hermetic. Otherwise loadBlocks() opens a LIVE block-list
// subscription through nostrClient's real SimplePool aimed at the fake relay
// above, leaking TCP sockets + reconnect timers that outlive the test and
// hang the node:test runner AFTER every assertion has already passed.
// (1) A truthy stub pool stops loadBlocks from calling ensurePool(), which
// otherwise re-creates and connects a real pool when pool is falsy
// (userBlocks.js) — so nulling the pool alone is not enough.
// (2) A no-op subscription manager makes startBlockListSubscription() a
// no-op instead of a real relay REQ.
// The block MERGE under test still runs entirely through the stubbed
// fetchListIncrementally below. Both are restored in finally.
nostrClient.pool = { __mockForDmBlockFilterTest: true };
nostrClient.getSubscriptionManager = () => ({
subscribe: () => ({ close() {} }),
});
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
@@ -317,6 +335,8 @@ describe("DM block/mute filtering", () => {
});
nostrClient.relays = originalRelays;
nostrClient.fetchListIncrementally = originalFetchListIncrementally;
nostrClient.pool = originalPool;
nostrClient.getSubscriptionManager = originalGetSubscriptionManager;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
+9
View File
@@ -473,3 +473,12 @@ try {
window.NostrTools = originalWindowNostrTools;
}
}
// All assertions above run to completion before the cleanup finally block; a
// failure would have rejected the top-level await and exited non-zero. This
// file imports the full app stack (js/app.js, subscriptions, userBlocks) whose
// module-load side effects (timers/connection managers) keep the event loop
// alive after the tests finish, so the process would otherwise hang. Force a
// clean exit — the established pattern for this repo's bare-assert tests with
// lingering handles. The small delay lets the "tests passed" log flush first.
setTimeout(() => process.exit(0), 50);
+33 -2
View File
@@ -8,6 +8,22 @@ const nwcModule = await import("../js/payments/nwcClient.js");
const { __TESTING__, resetWalletClient } = nwcModule;
const { buildPayInvoiceParams, ensureActiveState, parseNwcUri, getActiveState } = __TESTING__;
// The nostr-tools bootstrap installs a frozen canonical toolkit on the global
// scope, and nwcClient's readToolkitFromScope() prefers it over
// window.NostrTools. That shadows the per-section mocks below, so the real
// @noble crypto runs and rejects the fake test keys ("bad point: is not on
// curve"). These sections exercise the NWC client's encryption-SELECTION
// wiring — which scheme it negotiates and how it threads
// getConversationKey/encrypt — NOT @noble's crypto correctness (that's real
// elsewhere, and the hex-vs-bytes production bug parseNwcUri once had is
// guarded by tests/nwc-parse-uri.test.mjs). Remove the canonical so the
// resolver falls through to each section's mock. Nothing re-installs it during
// the run, so one removal holds for every section.
delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__;
if (globalThis.window && typeof globalThis.window === "object") {
delete globalThis.window.__BITVID_CANONICAL_NOSTR_TOOLS__;
}
await (async () => {
const params = buildPayInvoiceParams({
invoice: "bolt11-invoice",
@@ -240,9 +256,19 @@ await (async () => {
`&secret=${secretKey}`;
const context = ensureActiveState({ nwcUri: uri });
// Spec correction: the original scenario advertised "nip44_v2" and relied on
// the mock toolkit LACKING nip44 to force a rejection. That precondition is
// architecturally unreachable — the nostr-tools bootstrap's mergeWithCanonical
// always backfills the canonical nip44 (even an explicit `nip44: null` is
// overwritten), so nip44_v2 is ALWAYS a supported scheme and the wallet's
// advertisement would be honored, not rejected. The intended behavior under
// test is: when a wallet advertises ONLY encryption schemes bitvid cannot
// provide, ensureEncryption rejects with UNSUPPORTED_ENCRYPTION. bitvid only
// implements nip04 + nip44_v2, so advertising an unknown scheme is the
// reachable, faithful way to exercise that exact branch.
context.infoEvent = {
kind: 13194,
tags: [["encryption", "nip44_v2"]],
tags: [["encryption", "nip44_v3"]],
};
await assert.rejects(
@@ -250,7 +276,12 @@ await (async () => {
(error) => {
assert.match(
error?.message || "",
/Wallet advertises unsupported encryption schemes: nip44_v2\./
/Wallet advertises unsupported encryption schemes: nip44_v3\./
);
assert.equal(
error?.code,
"UNSUPPORTED_ENCRYPTION",
"unsupported-scheme rejection must carry the UNSUPPORTED_ENCRYPTION code"
);
return true;
}
+138
View File
@@ -0,0 +1,138 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
fetchCreatorPlaylists,
fetchPlaylist,
publishPlaylist,
} from "../js/playlists/playlistFacade.js";
import {
buildPlaylistEvent,
PLAYLIST_KIND,
} from "../js/nostrEventSchemas.js";
const AUTHOR = "a".repeat(64);
const OTHER = "c".repeat(64);
const coord = (d) => `30078:${"b".repeat(64)}:${d}`;
function playlistEvent({ pubkey = AUTHOR, id, title, coords = [], created_at }) {
const event = buildPlaylistEvent({
pubkey,
created_at,
dTagValue: id,
title,
videoCoordinates: coords,
});
event.id = `id-${id}-${created_at}`;
return event;
}
// Mock client: a subscription manager whose list() filters an in-memory event
// set by kinds/authors/#d, plus a signAndPublishEvent that captures the event.
function mockClient({ events = [], onPublish } = {}) {
return {
relays: ["wss://mock.relay"],
getSubscriptionManager: () => ({
list: async ({ filters }) => {
const f = filters[0] || {};
return events.filter((ev) => {
if (f.kinds && !f.kinds.includes(ev.kind)) return false;
if (f.authors && !f.authors.includes(ev.pubkey)) return false;
if (f["#d"]) {
const d = ev.tags.find((t) => t[0] === "d")?.[1];
if (!f["#d"].includes(d)) return false;
}
return true;
});
},
}),
signAndPublishEvent: async (event, options) => {
if (onPublish) onPublish(event, options);
return { signedEvent: { ...event, id: "signed-id", sig: "sig" } };
},
};
}
test("fetchCreatorPlaylists returns newest-per-id, drops empties, sorts newest-first", async () => {
const events = [
// Same id "a" published twice — the newer (created_at 200) must win.
playlistEvent({ id: "a", title: "A old", coords: [coord("v1")], created_at: 100 }),
playlistEvent({ id: "a", title: "A new", coords: [coord("v1"), coord("v2")], created_at: 200 }),
playlistEvent({ id: "b", title: "B", coords: [coord("v9")], created_at: 300 }),
// Empty playlist — dropped from the listing by default.
playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 400 }),
// Another author's playlist — must not appear.
playlistEvent({ pubkey: OTHER, id: "x", title: "Other", coords: [coord("v0")], created_at: 500 }),
];
const playlists = await fetchCreatorPlaylists(AUTHOR, { client: mockClient({ events }) });
assert.deepEqual(playlists.map((p) => p.id), ["b", "a"], "sorted newest-first, empties + other author dropped");
const a = playlists.find((p) => p.id === "a");
assert.equal(a.title, "A new", "newest copy wins");
assert.equal(a.items.length, 2);
});
test("fetchCreatorPlaylists can include empty playlists when asked", async () => {
const events = [playlistEvent({ id: "empty", title: "Empty", coords: [], created_at: 1 })];
const playlists = await fetchCreatorPlaylists(AUTHOR, {
client: mockClient({ events }),
includeEmpty: true,
});
assert.deepEqual(playlists.map((p) => p.id), ["empty"]);
});
test("fetchCreatorPlaylists returns [] for blank pubkey or no relays", async () => {
assert.deepEqual(await fetchCreatorPlaylists("", { client: mockClient({}) }), []);
const noRelays = { ...mockClient({}), relays: [] };
assert.deepEqual(await fetchCreatorPlaylists(AUTHOR, { client: noRelays }), []);
});
test("fetchPlaylist returns the newest matching playlist parsed", async () => {
const events = [
playlistEvent({ id: "list", title: "old", coords: [coord("v1")], created_at: 10 }),
playlistEvent({ id: "list", title: "new", coords: [coord("v1"), coord("v2")], created_at: 20 }),
playlistEvent({ id: "other", title: "nope", coords: [coord("v3")], created_at: 99 }),
];
const parsed = await fetchPlaylist(AUTHOR, "list", { client: mockClient({ events }) });
assert.equal(parsed.title, "new");
assert.equal(parsed.id, "list");
assert.equal(parsed.items.length, 2);
assert.equal(await fetchPlaylist(AUTHOR, "missing", { client: mockClient({ events }) }), null);
});
test("publishPlaylist builds a kind-30082 event and publishes with the playlist context", async () => {
let published = null;
let ctx = null;
const client = mockClient({
onPublish: (event, options) => {
published = event;
ctx = options?.context;
},
});
const signed = await publishPlaylist(
{
pubkey: AUTHOR,
id: "new-list",
title: "Fresh",
items: [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
],
created_at: 777,
},
{ client },
);
assert.equal(published.kind, PLAYLIST_KIND);
assert.equal(ctx, "playlist");
assert.equal(published.tags.find((t) => t[0] === "d")?.[1], "new-list");
assert.equal(published.tags.find((t) => t[0] === "title")?.[1], "Fresh");
assert.deepEqual(
published.tags.filter((t) => t[0] === "a").map((t) => t[1]),
[coord("v1"), coord("v2")],
);
assert.equal(signed.id, "signed-id", "returns the signed event");
});
+223
View File
@@ -0,0 +1,223 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
PLAYLIST_KIND,
parsePlaylistEvent,
addVideoToPlaylist,
removeVideoFromPlaylist,
reorderPlaylistItems,
videoCoordinate,
buildPlaylist,
generatePlaylistId,
playlistItemKey,
} from "../js/playlists/playlistService.js";
import { buildPlaylistEvent } from "../js/nostrEventSchemas.js";
const PUBKEY = "a".repeat(64);
const AUTHOR = "b".repeat(64);
const coord = (dtag, author = AUTHOR) => `30078:${author}:${dtag}`;
test("buildPlaylistEvent emits kind, identifier, metadata, and ordered a-refs", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 1000,
dTagValue: "my-list",
title: "My List",
description: "cool clips",
image: "https://img.example/x.jpg",
videoCoordinates: [coord("v1"), coord("v2"), coord("v3")],
});
assert.equal(event.kind, PLAYLIST_KIND);
assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "my-list");
assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "playlist");
assert.equal(event.tags.find((t) => t[0] === "title")?.[1], "My List");
assert.equal(event.tags.find((t) => t[0] === "description")?.[1], "cool clips");
assert.equal(
event.tags.find((t) => t[0] === "image")?.[1],
"https://img.example/x.jpg",
);
// Ordered a-refs, in the given order.
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
});
test("buildPlaylistEvent dedupes repeated video coordinates, preserving first order", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 1000,
dTagValue: "d1",
videoCoordinates: [coord("v1"), coord("v2"), coord("v1"), coord("v3")],
});
const aRefs = event.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(aRefs, [coord("v1"), coord("v2"), coord("v3")]);
});
test("buildPlaylistEvent requires a d-tag identifier", () => {
assert.throws(
() => buildPlaylistEvent({ pubkey: PUBKEY, created_at: 1, dTagValue: " " }),
/requires a d-tag/i,
);
});
test("parsePlaylistEvent structures a playlist and preserves item order", () => {
const event = buildPlaylistEvent({
pubkey: PUBKEY,
created_at: 4242,
dTagValue: "list-7",
title: "Seven",
videoCoordinates: [coord("a"), coord("b")],
eventRefs: ["e".repeat(64)],
});
event.id = "event-id-123";
const parsed = parsePlaylistEvent(event);
assert.equal(parsed.id, "list-7");
assert.equal(parsed.pubkey, PUBKEY);
assert.equal(parsed.title, "Seven");
assert.equal(parsed.updatedAt, 4242);
assert.equal(parsed.address, `${PLAYLIST_KIND}:${PUBKEY}:list-7`);
assert.equal(parsed.eventId, "event-id-123");
assert.deepEqual(parsed.items, [
{ type: "a", value: coord("a") },
{ type: "a", value: coord("b") },
{ type: "e", value: "e".repeat(64) },
]);
});
test("parsePlaylistEvent rejects the wrong kind and a missing d-tag", () => {
assert.equal(
parsePlaylistEvent({ kind: 30078, tags: [["d", "x"]] }),
null,
"wrong kind → null",
);
assert.equal(
parsePlaylistEvent({ kind: PLAYLIST_KIND, tags: [["title", "no id"]] }),
null,
"missing d → null",
);
assert.equal(parsePlaylistEvent(null), null);
});
test("parsePlaylistEvent defaults an empty title", () => {
const parsed = parsePlaylistEvent({
kind: PLAYLIST_KIND,
pubkey: PUBKEY,
created_at: 1,
tags: [["d", "no-title"]],
});
assert.equal(parsed.title, "Untitled playlist");
});
test("build → parse round-trips a full playlist (order intact)", () => {
const items = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "a", value: coord("v3") },
];
const event = buildPlaylist({
pubkey: PUBKEY,
id: "rt",
title: "Round Trip",
description: "desc",
items,
created_at: 55,
});
const parsed = parsePlaylistEvent({ ...event, id: "signed-id" });
assert.equal(parsed.title, "Round Trip");
assert.equal(parsed.description, "desc");
assert.deepEqual(parsed.items, items);
});
test("addVideoToPlaylist appends, dedupes, inserts by position, and is immutable", () => {
const base = [{ type: "a", value: coord("v1") }];
const appended = addVideoToPlaylist(base, coord("v2"));
assert.deepEqual(appended.map((i) => i.value), [coord("v1"), coord("v2")]);
assert.deepEqual(base.map((i) => i.value), [coord("v1")], "input not mutated");
const deduped = addVideoToPlaylist(appended, coord("v1"));
assert.equal(deduped.length, 2, "existing video is a no-op");
const inserted = addVideoToPlaylist(appended, coord("v3"), { position: 1 });
assert.deepEqual(inserted.map((i) => i.value), [
coord("v1"),
coord("v3"),
coord("v2"),
]);
assert.deepEqual(addVideoToPlaylist(base, " "), base.slice(), "blank ignored");
});
test("removeVideoFromPlaylist removes by coordinate and by item, immutably", () => {
const base = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "e", value: "e".repeat(64) },
];
const byCoord = removeVideoFromPlaylist(base, coord("v1"));
assert.deepEqual(byCoord.map(playlistItemKey), [
`a:${coord("v2")}`,
`e:${"e".repeat(64)}`,
]);
assert.equal(base.length, 3, "input not mutated");
const byItem = removeVideoFromPlaylist(base, { type: "e", value: "e".repeat(64) });
assert.ok(!byItem.some((i) => i.type === "e"));
});
test("reorderPlaylistItems moves items, clamps, no-ops safely, immutably", () => {
const base = [
{ type: "a", value: coord("v1") },
{ type: "a", value: coord("v2") },
{ type: "a", value: coord("v3") },
];
// Move first to last.
const moved = reorderPlaylistItems(base, 0, 2);
assert.deepEqual(moved.map((i) => i.value), [
coord("v2"),
coord("v3"),
coord("v1"),
]);
assert.deepEqual(base.map((i) => i.value), [
coord("v1"),
coord("v2"),
coord("v3"),
], "input not mutated");
// toIndex beyond the end clamps to last.
const clamped = reorderPlaylistItems(base, 0, 99);
assert.equal(clamped[clamped.length - 1].value, coord("v1"));
// Out-of-range fromIndex → unchanged copy.
assert.deepEqual(reorderPlaylistItems(base, 5, 0), base);
// Same index → unchanged.
assert.deepEqual(reorderPlaylistItems(base, 1, 1), base);
});
test("videoCoordinate derives the 30078 coordinate from a video object", () => {
const video = {
kind: 30078,
pubkey: AUTHOR,
tags: [["d", "1700000000000-abc"]],
};
assert.equal(videoCoordinate(video), `30078:${AUTHOR}:1700000000000-abc`);
// A video object carrying kind 0 (bitvid's internal objects often do) still
// yields the canonical 30078 coordinate — matches the zap-pointer contract.
assert.equal(
videoCoordinate({ kind: 0, pubkey: AUTHOR, tags: [["d", "x"]] }),
`30078:${AUTHOR}:x`,
);
});
test("generatePlaylistId produces distinct, tag-safe ids", () => {
const a = generatePlaylistId();
const b = generatePlaylistId();
assert.notEqual(a, b);
assert.match(a, /^pl-[0-9a-z]+-[0-9a-z]+$/);
});
+172
View File
@@ -0,0 +1,172 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
fetchPendingSubmissions,
markSubmissionResolved,
resolveEventAuthorHex,
RESOLVED_LIST_KIND,
SUBMISSIONS_RESOLVED_DTAG,
} from "../js/submissions/submissionFacade.js";
import { buildSubmissionEvent, SUBMISSION_KIND } from "../js/nostrEventSchemas.js";
const ADMIN = "a".repeat(64);
const MOD = "b".repeat(64);
const ephem = (n) => String(n).repeat(64).slice(0, 64);
function submissionEvent({ id, applicant, created_at, author, content = "hi" }) {
const event = buildSubmissionEvent({
pubkey: author || ephem("e"),
created_at,
dTagValue: id,
submissionType: "application",
applicantNpub: applicant,
recipientPubkey: ADMIN,
content,
});
event.id = `evt-${id}`;
return event;
}
function resolvedSet({ author, eventIds, created_at = 1000 }) {
return {
id: `resolved-${author.slice(0, 4)}`,
kind: RESOLVED_LIST_KIND,
pubkey: author,
created_at,
tags: [
["d", SUBMISSIONS_RESOLVED_DTAG],
...eventIds.map((eid) => ["e", eid, "approved", ""]),
],
};
}
function mockClient({ events = [], onPublish } = {}) {
return {
relays: ["wss://mock.relay"],
getSubscriptionManager: () => ({
list: async ({ filters }) => {
const f = filters[0] || {};
return events.filter((ev) => {
if (f.ids && !f.ids.includes(ev.id)) return false;
if (f.kinds && !f.kinds.includes(ev.kind)) return false;
if (f.authors && !f.authors.includes(ev.pubkey)) return false;
if (f["#p"]) {
const p = ev.tags.find((t) => t[0] === "p")?.[1];
if (!f["#p"].includes(p)) return false;
}
if (f["#d"]) {
const d = ev.tags.find((t) => t[0] === "d")?.[1];
if (!f["#d"].includes(d)) return false;
}
return true;
});
},
}),
signAndPublishEvent: async (event, options) => {
if (onPublish) onPublish(event, options);
return { signedEvent: { ...event, id: "signed-id", sig: "sig" } };
},
};
}
test("fetchPendingSubmissions returns pending, excludes resolved, dedupes by applicant", async () => {
const events = [
submissionEvent({ id: "s1", applicant: "npub-alice", created_at: 100 }),
// alice re-applied later — newest should win
submissionEvent({ id: "s2", applicant: "npub-alice", created_at: 200 }),
submissionEvent({ id: "s3", applicant: "npub-bob", created_at: 150 }),
// carol's was resolved by a moderator
submissionEvent({ id: "s4", applicant: "npub-carol", created_at: 300 }),
// resolved-set from a moderator marks carol's evt-s4 handled
resolvedSet({ author: MOD, eventIds: ["evt-s4"] }),
];
const pending = await fetchPendingSubmissions({
adminHex: ADMIN,
editorHexes: [MOD],
client: mockClient({ events }),
});
const applicants = pending.map((s) => s.applicant).sort();
assert.deepEqual(applicants, ["npub-alice", "npub-bob"], "carol resolved, alice deduped");
const alice = pending.find((s) => s.applicant === "npub-alice");
assert.equal(alice.id, "s2", "newest application for alice wins");
});
test("fetchPendingSubmissions still returns pending when a pool is present (mirror is best-effort)", async () => {
const events = [
submissionEvent({ id: "m1", applicant: "npub-x", created_at: 100 }),
];
const client = mockClient({ events });
// Presence of a pool triggers the re-broadcast mirror path; any publish
// failure is swallowed and must not affect the returned queue.
client.pool = {};
const pending = await fetchPendingSubmissions({ adminHex: ADMIN, client });
assert.equal(pending.length, 1);
assert.equal(pending[0].applicant, "npub-x");
});
test("fetchPendingSubmissions returns [] for a blank admin or no relays", async () => {
assert.deepEqual(await fetchPendingSubmissions({ adminHex: "", client: mockClient({}) }), []);
const noRelays = { ...mockClient({}), relays: [] };
assert.deepEqual(await fetchPendingSubmissions({ adminHex: ADMIN, client: noRelays }), []);
});
test("markSubmissionResolved appends to the acting moderator's resolved-set", async () => {
// MOD already resolved evt-old; now resolves evt-s3.
const events = [resolvedSet({ author: MOD, eventIds: ["evt-old"] })];
let published = null;
const client = mockClient({ events, onPublish: (e) => (published = e) });
await markSubmissionResolved({
submission: { eventId: "evt-s3", applicant: "npub-bob" },
status: "denied",
actingHex: MOD,
client,
});
assert.equal(published.kind, RESOLVED_LIST_KIND);
assert.equal(published.pubkey, MOD);
assert.equal(published.tags.find((t) => t[0] === "d")?.[1], SUBMISSIONS_RESOLVED_DTAG);
const eTags = published.tags.filter((t) => t[0] === "e").map((t) => t[1]);
assert.deepEqual(eTags.sort(), ["evt-old", "evt-s3"], "keeps prior + adds new");
const newTag = published.tags.find((t) => t[0] === "e" && t[1] === "evt-s3");
assert.equal(newTag[2], "denied", "records status");
assert.equal(newTag[3], "npub-bob", "records applicant");
});
test("resolveEventAuthorHex returns the blocked event's author (lowercased)", async () => {
const blockedEventId = "f".repeat(64);
const authorHex = "C".repeat(64); // stored upper-case to prove normalization
const events = [
{ id: blockedEventId, kind: 30078, pubkey: authorHex, tags: [], content: "" },
];
const author = await resolveEventAuthorHex({
eventId: blockedEventId,
client: mockClient({ events }),
});
assert.equal(author, "c".repeat(64));
});
test("resolveEventAuthorHex returns '' for a blank id or an event not found", async () => {
assert.equal(await resolveEventAuthorHex({ eventId: "", client: mockClient({}) }), "");
assert.equal(
await resolveEventAuthorHex({
eventId: "a".repeat(64),
client: mockClient({ events: [] }),
}),
"",
);
});
test("markSubmissionResolved is a no-op without an acting pubkey or event id", async () => {
assert.equal(
await markSubmissionResolved({ submission: { eventId: "x" }, actingHex: "", client: mockClient({}) }),
null,
);
assert.equal(
await markSubmissionResolved({ submission: {}, actingHex: MOD, client: mockClient({}) }),
null,
);
});
+160
View File
@@ -0,0 +1,160 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
SUBMISSION_KIND,
SUBMISSION_TYPES,
parseSubmissionEvent,
buildSubmission,
generateSubmissionId,
} from "../js/submissions/submissionService.js";
import { buildSubmissionEvent } from "../js/nostrEventSchemas.js";
const EPHEMERAL = "e".repeat(64);
const ADMIN = "a".repeat(64);
const APPLICANT = "npub1exampleapplicant";
test("buildSubmissionEvent emits kind, d, type, applicant, recipient, topic", () => {
const event = buildSubmissionEvent({
pubkey: EPHEMERAL,
created_at: 1000,
dTagValue: "sub-1",
submissionType: "application",
applicantNpub: APPLICANT,
recipientPubkey: ADMIN,
content: "Please whitelist me, I make Blender tutorials.",
});
assert.equal(event.kind, SUBMISSION_KIND);
assert.equal(event.tags.find((t) => t[0] === "d")?.[1], "sub-1");
assert.equal(event.tags.find((t) => t[0] === "t")?.[1], "bitvid-submission");
assert.equal(event.tags.find((t) => t[0] === "k")?.[1], "application");
assert.equal(event.tags.find((t) => t[0] === "applicant")?.[1], APPLICANT);
assert.equal(event.tags.find((t) => t[0] === "p")?.[1], ADMIN);
assert.equal(event.content, "Please whitelist me, I make Blender tutorials.");
});
test("buildSubmissionEvent requires a d-tag", () => {
assert.throws(
() => buildSubmissionEvent({ pubkey: EPHEMERAL, created_at: 1, dTagValue: " " }),
/requires a d-tag/i,
);
});
test("buildSubmissionEvent defaults type to application", () => {
const event = buildSubmissionEvent({
pubkey: EPHEMERAL,
created_at: 1,
dTagValue: "d",
});
assert.equal(event.tags.find((t) => t[0] === "k")?.[1], "application");
});
test("parseSubmissionEvent structures a submission", () => {
const event = buildSubmissionEvent({
pubkey: EPHEMERAL,
created_at: 4242,
dTagValue: "sub-7",
submissionType: "bug",
applicantNpub: APPLICANT,
recipientPubkey: ADMIN,
content: "the zap button is off-center",
});
event.id = "event-id-9";
const parsed = parseSubmissionEvent(event);
assert.equal(parsed.id, "sub-7");
assert.equal(parsed.eventId, "event-id-9");
assert.equal(parsed.type, "bug");
assert.equal(parsed.applicant, APPLICANT);
assert.equal(parsed.recipient, ADMIN);
assert.equal(parsed.content, "the zap button is off-center");
assert.equal(parsed.createdAt, 4242);
assert.equal(parsed.pubkey, EPHEMERAL);
});
test("parseSubmissionEvent surfaces an appeal's blocked-event target from the e tag", () => {
const target = "b".repeat(64);
const parsed = parseSubmissionEvent({
kind: SUBMISSION_KIND,
pubkey: EPHEMERAL,
created_at: 10,
tags: [
["d", "appeal-1"],
["k", "appeal"],
["p", ADMIN],
["e", target.toUpperCase()],
],
});
assert.equal(parsed.type, "appeal");
// lowercased, ready to feed straight into the event block list
assert.equal(parsed.targetEventId, target);
});
test("parseSubmissionEvent ignores a malformed e tag and defaults target to ''", () => {
const parsed = parseSubmissionEvent({
kind: SUBMISSION_KIND,
pubkey: EPHEMERAL,
created_at: 10,
tags: [
["d", "appeal-2"],
["k", "appeal"],
["e", "not-a-real-event-id"],
],
});
assert.equal(parsed.targetEventId, "");
});
test("parseSubmissionEvent leaves targetEventId empty when no e tag is present", () => {
const parsed = parseSubmissionEvent({
kind: SUBMISSION_KIND,
pubkey: EPHEMERAL,
created_at: 10,
tags: [["d", "x"], ["k", "application"]],
});
assert.equal(parsed.targetEventId, "");
});
test("parseSubmissionEvent normalizes an unknown type to 'other'", () => {
const parsed = parseSubmissionEvent({
kind: SUBMISSION_KIND,
pubkey: EPHEMERAL,
created_at: 1,
tags: [["d", "x"], ["k", "totally-made-up"]],
});
assert.equal(parsed.type, "other");
});
test("parseSubmissionEvent rejects the wrong kind and a missing d", () => {
assert.equal(parseSubmissionEvent({ kind: 1, tags: [["d", "x"]] }), null);
assert.equal(
parseSubmissionEvent({ kind: SUBMISSION_KIND, tags: [["k", "application"]] }),
null,
);
assert.equal(parseSubmissionEvent(null), null);
});
test("build → parse round-trips every known type", () => {
for (const type of Object.values(SUBMISSION_TYPES)) {
const event = buildSubmission({
pubkey: EPHEMERAL,
id: `rt-${type}`,
type,
applicantNpub: APPLICANT,
recipientPubkey: ADMIN,
content: `body for ${type}`,
created_at: 5,
});
const parsed = parseSubmissionEvent({ ...event, id: `signed-${type}` });
assert.equal(parsed.type, type);
assert.equal(parsed.content, `body for ${type}`);
assert.equal(parsed.applicant, APPLICANT);
}
});
test("generateSubmissionId produces distinct, tag-safe ids", () => {
const a = generateSubmissionId();
const b = generateSubmissionId();
assert.notEqual(a, b);
assert.match(a, /^sub-[0-9a-z]+-[0-9a-z]+$/);
});
+14 -12
View File
@@ -151,17 +151,9 @@ describe("UploadModal Reset Logic", () => {
let resolveUpload;
const delayedUpload = new Promise(r => { resolveUpload = r; });
const slowR2Service = {
prepareUpload: async () => ({
settings: {},
bucketEntry: { bucket: 'test', publicBaseUrl: 'https://test.com' }
}),
uploadFile: () => delayedUpload // Return promise that waits
};
const modal = new UploadModal({
storageService: mockStorageService,
r2Service: slowR2Service,
r2Service: mockR2Service,
getCurrentPubkey: () => "pk1",
removeTrackingScripts: () => {},
setGlobalModalState: () => {},
@@ -175,9 +167,19 @@ describe("UploadModal Reset Logic", () => {
modal.activeProvider = "cloudflare_r2";
modal.activeCredentials = { accountId: 'acc', accessKeyId: 'key', secretAccessKey: 'sec' };
// Mock generateTorrentMetadata BEFORE calling handleVideoSelection
modal.generateTorrentMetadata = async () => ({ hasValidInfoHash: false });
modal.resolveUploadIdentifier = async () => "mock-identifier";
// handleVideoSelection() drives the upload through this.mediaUploader.uploadVideo
// — NOT the modal's own generate/resolve helpers — so mock at THAT boundary.
// Returning the controllable pending promise lets us reset mid-upload and then
// release a "zombie" completion. This isolates the unit under test (the modal's
// videoUploadId versioning guard) from real webtorrent/S3, which is what the
// test intends. Previously the test mocked modal.generateTorrentMetadata /
// resolveUploadIdentifier (the wrong object), so the mediaUploader still ran
// real createTorrentMetadata()/hashing on a synthetic file — throwing "invalid
// input type" as async activity after the test ended.
modal.mediaUploader.uploadVideo = () => delayedUpload;
// captureVideoMetadata() does URL.createObjectURL(file), which needs a real
// Blob; stub it so the synthetic file below doesn't throw an async Blob error.
modal.captureVideoMetadata = async () => {};
// Start upload
const file = { name: "video.mp4" };
+7 -2
View File
@@ -36,8 +36,13 @@ test('createHashChangeHandler', async (t) => {
loadedView = null;
mockLocation.hash = '';
await handler();
// Default is most-recent-videos if not logged in
assert.equal(loadedView, 'views/most-recent-videos.html');
// spec_correction (todo-11): commit 04621efc (2026-06-24, "Trending defaults")
// made TRENDING the deliberate logged-out landing (FEATURE_TRENDING_FEED
// defaults on; hashChangeHandler.js:95-99). This test still expected the OLD
// default (most-recent-videos) — the same stale spec the sibling
// tests/app/hash-change-handler.test.mjs was already corrected for. Equally
// strict: the correct observable outcome is views/trending.html.
assert.equal(loadedView, 'views/trending.html');
});
await t.test('redirects legacy view', async () => {
+10 -2
View File
@@ -8,11 +8,19 @@ import {
import { buildVideoSearchFilterMatcher } from "../../js/search/searchFilterMatchers.js";
test("parseFilterQuery collects filters and text tokens", () => {
// spec_correction (todo-11): the author filter now runs values through
// normalizeAuthorValues (searchFilters.js) — it accepts a 64-char hex pubkey
// or a decodable npub1 and DROPS anything else (with an error), because videos
// carry HEX pubkeys and npub authors otherwise matched nothing. The old fixture
// "abc123" is neither hex64 nor an npub, so it is (correctly) dropped, leaving
// authorPubkeys empty and an error — contradicting this happy-path test. Use a
// valid hex pubkey so the test exercises the intended normalization.
const authorHex = "a".repeat(64);
const parsed = parseFilterQuery(
"author:abc123 tag:#news duration:<=5m has:magnet nsfw:false hello world",
`author:${authorHex} tag:#news duration:<=5m has:magnet nsfw:false hello world`,
);
assert.deepEqual(parsed.filters.authorPubkeys, ["abc123"]);
assert.deepEqual(parsed.filters.authorPubkeys, [authorHex]);
assert.deepEqual(parsed.filters.tags, ["news"]);
assert.equal(parsed.filters.duration.maxSeconds, 300);
assert.equal(parsed.filters.hasMagnet, true);
+9
View File
@@ -1786,3 +1786,12 @@ await (async () => {
manager.reset();
}
})();
// All assertions above run at top level; a failure would have rejected and
// exited non-zero before reaching here. The decrypt-timeout section
// deliberately installs a never-resolving nip44Decrypt, which leaves a
// background decrypt-retry timer alive (manager.reset() doesn't cancel it),
// so the process would otherwise hang. Force a clean exit — the established
// pattern for this repo's bare-assert tests with lingering handles.
console.log("user-blocks tests passed");
setTimeout(() => process.exit(0), 50);
+48
View File
@@ -334,6 +334,53 @@ async function testSplitMath() {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
// Regression: bitvid's internal video objects don't always carry the Nostr
// `kind` — it can be 0 or undefined. The zap request's `a` (coordinate) tag
// MUST still address the video as kind 30078, because the read side
// (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d`.
// A kind-0 coordinate (`0:pubkey:d`) publishes fine but can never be matched,
// so zaps silently count as zero. This asserts the coordinate defaults to
// 30078 regardless of the source object's kind.
async function testZapPointerDefaultsToVideoKindWhenMissing() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const { deps } = createDeps();
const callbackUrl = "https://lnurl.example/api/callback";
const bech32Address = encodeLnurlBech32(callbackUrl);
const pubkey = "d".repeat(64);
for (const brokenKind of [0, undefined, "not-a-number"]) {
const videoEvent = {
id: "event-id",
pubkey,
lightningAddress: bech32Address,
tags: [["d", "1782939097186-elrw1x3fdvg"]],
kind: brokenKind,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 500, comment: "PointerKind" },
deps
);
const parsedZap = JSON.parse(result.receipts[0].zapRequest);
const aTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "a");
assert(aTag, `zap request should carry an 'a' coordinate (kind=${brokenKind})`);
assert.equal(
aTag[1],
`30078:${pubkey}:1782939097186-elrw1x3fdvg`,
`coordinate must address the video as kind 30078, not '${brokenKind}'`
);
}
if (previousOverride === undefined) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
} else {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
}
}
async function testBech32LightningAddressZapTag() {
const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
@@ -1046,6 +1093,7 @@ async function testSplitRoundingPreservesEverySat() {
await testCreatorEqualsPlatformCollapsesToOnePayment();
await testSplitRoundingPreservesEverySat();
await testSplitMath();
await testZapPointerDefaultsToVideoKindWhenMissing();
await testBech32LightningAddressZapTag();
await testUrlLightningAddressZapTag();
await testRequestInvoiceIncludesLnurlParam();
+110
View File
@@ -0,0 +1,110 @@
// #47 §5.9: the popularity chart gains a zaps-over-time (orange) series with a
// visible date axis. buildZapSatsTimeSeries buckets cumulative SATS from 9735
// receipts + verified tallies, deduped by payment_hash (same rule as the badge
// store). buildPopularityChartSvg draws two token-colored lines + date ticks.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-zap-sats-series
// given: "zap events (9735) across days, incl. a duplicate payment_hash"
// when: "buildZapSatsTimeSeries buckets them"
// then: "cumulative sats per day; a repeated payment counts once"
// - id: SCN-popularity-svg
// given: "views + zaps cumulative series"
// when: "buildPopularityChartSvg renders"
// then: "two <polyline> series (accent + zap colors) and visible date <text> ticks"
// observable_outcomes:
// - "series buckets/cumulative/total"
// - "SVG polyline count, series color classes, date-tick text present"
// determinism_controls:
// - "in-test bech32 bolt11 vector; injected getSats; JSDOM"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { bech32 } from "../vendor/crypto-helpers.bundle.min.js";
import {
buildZapSatsTimeSeries,
buildPopularityChartSvg,
} from "../js/viewCountChart.js";
const lenWords = (n) => [n >> 5, n & 31];
function bolt11WithPaymentHash(fillByte) {
const pWords = bech32.toWords(new Uint8Array(32).fill(fillByte));
const words = [0, 0, 0, 0, 0, 0, 0, 1, ...lenWords(pWords.length), ...pWords, ...new Array(104).fill(0)];
return bech32.encode("lnbc", words, 2000);
}
const DAY = 86400;
const receipt = (id, createdAt, fillByte) => ({
id,
kind: 9735,
created_at: createdAt,
tags: [["bolt11", bolt11WithPaymentHash(fillByte)]],
});
const tools = { nip57: { getSatoshisAmountFromBolt11: () => 500 } };
test("buildZapSatsTimeSeries: cumulative sats per day", () => {
const events = [
receipt("a", 10 * DAY + 100, 0x11),
receipt("b", 11 * DAY + 100, 0x22),
receipt("c", 11 * DAY + 200, 0x33),
];
const { series, total } = buildZapSatsTimeSeries(events, { tools });
assert.equal(total, 1500, "3 × 500 sats");
assert.equal(series.length, 2, "two day buckets");
assert.equal(series[0].sats, 500);
assert.equal(series[1].sats, 1000);
assert.equal(series[1].cumulative, 1500, "cumulative across days");
});
test("buildZapSatsTimeSeries: a repeated payment_hash is counted once", () => {
const events = [
receipt("a", 10 * DAY, 0xab),
receipt("b", 10 * DAY, 0xab), // same payment_hash (same bolt11 fill) → dup
];
const { total } = buildZapSatsTimeSeries(events, { tools });
assert.equal(total, 500, "deduped by payment_hash, not 1000");
});
test("buildZapSatsTimeSeries: empty/garbage → empty series", () => {
assert.deepEqual(buildZapSatsTimeSeries([], { tools }), { series: [], total: 0 });
assert.deepEqual(buildZapSatsTimeSeries(null, { tools }), { series: [], total: 0 });
});
test("buildPopularityChartSvg: two colored lines + visible date ticks", () => {
const doc = new JSDOM("<!DOCTYPE html><body></body>").window.document;
const views = [
{ bucketStart: 10 * DAY, cumulative: 3 },
{ bucketStart: 12 * DAY, cumulative: 8 },
];
const zaps = [
{ bucketStart: 11 * DAY, cumulative: 500 },
{ bucketStart: 12 * DAY, cumulative: 2100 },
];
const svg = buildPopularityChartSvg(doc, { views, zaps });
const polylines = svg.querySelectorAll("polyline");
assert.equal(polylines.length, 2, "one line per series");
assert.ok(svg.querySelector("g.text-accent polyline"), "views line uses the accent color");
assert.ok(svg.querySelector("g.text-zap polyline"), "zaps line uses the zap color");
const ticks = svg.querySelectorAll("text");
assert.ok(ticks.length >= 2, "visible x-axis date ticks rendered");
assert.ok(
Array.from(ticks).every((t) => t.textContent && t.textContent.length > 0),
"each tick has a formatted date label",
);
assert.match(svg.getAttribute("aria-label"), /views and .* sats zapped/);
});
test("buildPopularityChartSvg: no data → labelled empty, no lines", () => {
const doc = new JSDOM("<!DOCTYPE html><body></body>").window.document;
const svg = buildPopularityChartSvg(doc, { views: [], zaps: [] });
assert.equal(svg.querySelectorAll("polyline").length, 0);
assert.match(svg.getAttribute("aria-label"), /No views or zaps/i);
});
+38
View File
@@ -32,10 +32,13 @@ import {
createZapTotalsStore,
extractReceiptAmountSats,
pointerKey,
profilePointer,
ZAP_RECEIPT_KIND,
} from "../js/zapTotals.js";
import { createMostZappedSorter } from "../js/feedEngine/sorters.js";
const CREATOR_HEX = "c".repeat(64);
const A1 = "30078:aaaa:video-1";
const A2 = "30078:bbbb:video-2";
@@ -304,6 +307,41 @@ test("cross-source dedup: a 9735 and a tally with the same payment_hash count on
);
});
test("profilePointer + pointerKey accept p: (channel total)", () => {
assert.deepEqual(profilePointer(CREATOR_HEX), ["p", CREATOR_HEX]);
assert.equal(profilePointer(" " + CREATOR_HEX.toUpperCase() + " ")[1], CREATOR_HEX);
assert.equal(profilePointer(""), null);
assert.equal(pointerKey(["p", CREATOR_HEX]), `p:${CREATOR_HEX}`);
assert.equal(pointerKey({ type: "p", value: CREATOR_HEX }), `p:${CREATOR_HEX}`);
});
test("profile counting: a p: pointer counts #p-tagged zaps (direct + via video)", async () => {
// A video zap's 9735 carries both #a (video) and #p (creator). Querying the
// creator's p: pointer should count it toward the channel total.
const store = createZapTotalsStore({
persistKey: null,
isTallyEnabled: () => false, // 9735-only for this test
getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }),
getClient: () => ({
relays: ["wss://relay.example"],
getSubscriptionManager: () => ({
list: async () => [
receipt("r1", [["p", CREATOR_HEX], ["a", A1], amountTag(1500000)]), // 1500 sats
receipt("r2", [["p", CREATOR_HEX], amountTag(600000)]), // 600 sats, direct
],
}),
}),
schedule: (fn) => { fn(); return 1; },
});
store.request({ type: "p", value: CREATOR_HEX });
await store.flush();
assert.equal(
store.getSnapshot({ type: "p", value: CREATOR_HEX }),
2100,
"channel total sums every #p-tagged zap",
);
});
test("tally counting is gated: disabled → tallies ignored", async () => {
const store = makeTallyStore({
enabled: false,
+57 -15
View File
@@ -1241,10 +1241,20 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro
local patch is guesswork — the update is the honest fix.
### 11. Harden flaky tests (CI gate reliability)
- [ ] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") intermittently
hangs/cancels (jsdom/webtorrent async-hang flake; documented in KNOWN_ISSUES,
reproduced on pre-refactor `1b11cb1b`). Make it deterministic so it can be a
trusted release gate. Audit e2e parallel-load flakiness too.
- [x] `tests/ui/uploadModal-reset.test.mjs` ("UploadModal Reset Logic") — FIXED 2026-07-05.
The "guard against zombie callbacks" subtest mocked `modal.generateTorrentMetadata` /
`resolveUploadIdentifier`, but handleVideoSelection drives the upload through
`this.mediaUploader.uploadVideo` (a DIFFERENT object with its own helpers) — so the
mediaUploader still ran real `createTorrentMetadata()`/hashing on a synthetic
`{name:"video.mp4"}` file, throwing "invalid input type" as async activity AFTER the
test ended (node:test flags that as an uncaughtException) plus
`captureVideoMetadata()` calling `URL.createObjectURL(file)` → "must be an instance of
Blob". Not intermittent anymore — it was a consistent, wrong-boundary mock. Fix: mock
at the real DI boundary — `modal.mediaUploader.uploadVideo = () => delayedUpload` (the
controllable pending promise) + stub `modal.captureVideoMetadata`. Now hermetic, no
webtorrent/Blob, deterministic (3/3 exit 0, # pass 3). Mutation-verified: breaking the
videoUploadId zombie guard makes the test fail (# fail 1). See TEST_INTEGRITY.md.
- [ ] Audit e2e parallel-load flakiness too.
### 11b. SILENTLY-EXCLUDED unit tests — 20 files never run in CI (found 2026-06-23)
> **2026-07-03 addendum — stale-default failure was hiding in local runs:**
@@ -1276,8 +1286,10 @@ test below was failing on `main` unnoticed).
`watch-history-feed`, `subscriptions-feed`, `discussion-count-service`,
`feed-engine`, `zap-split`, `nostr-view-events`,
`watchHistory/watch-history-telemetry`, `unit/ui/thumbnailBinder`.
- [ ] **Triage + un-quarantine the 10 broken files** (first-error diagnosis 2026-06-23;
each needs the stale-vs-real-bug investigation the delete-flow file got):
- [x] **ALL broken files triaged + un-quarantined (COMPLETE 2026-07-05).** The QUARANTINE
map in `run-unit-tests.mjs` is now empty; every unit test file runs in CI. Per-file
diagnosis + spec-correction history below (each got the stale-vs-real-bug
investigation the delete-flow file got):
- `watch-history` (#2) — DONE + UN-QUARANTINED 2026-06-23. Three stale tests,
all spec-corrected to shipped behavior (see TEST_INTEGRITY.md):
(1) `testWatchHistoryPartialRelayRetry` asserted snapshot THROWS on partial
@@ -1322,15 +1334,45 @@ test below was failing on `main` unnoticed).
parseCommunityBlacklistReferences decoded them twice; and createListEvent omitted
the `d` tag that selectNewestEventsForReferences matches on. Fixed all three to be
faithful to production. See TEST_INTEGRITY.md.
- `nostr-publish-rejection` — STILL QUARANTINED. Partially diagnosed 2026-06-25:
multi-precondition setup. (1) `testPublishVideoNoteDefaultsToLiveModeInDev` needs
dev mode — set `globalThis.__BITVID_DEV_MODE_OVERRIDE__=true` BEFORE the first
(dynamic) config-importing import (~line 50). (2) The `publishNip71Video` sub-test
then fails — `publishVideo`'s NIP-71 invocation path needs FEATURE_PUBLISH_NIP71 /
condition triage; possibly more after. Quarantine note carries the steps.
- `nostr-publish-rejection`, `nwc-client`, `user-blocks` remain quarantined
(see QUARANTINE map in run-unit-tests.mjs for the precise reason on each).
- `user-blocks` — STILL QUARANTINED: HANGS (async leak); needs a deterministic rewrite.
- `nostr-publish-rejection` — ✅ DONE + UN-QUARANTINED 2026-07-05. The two spec
precondition fixes had already landed (dev-mode override at top; NIP-71
auto-publish spec-corrected to expect ZERO calls with FEATURE_PUBLISH_NIP71 off).
Remaining issue was a post-completion HANG: the file imports the full app stack
(js/app.js + subscriptions + userBlocks) whose module-load timers/connection
managers keep the event loop alive after all assertions pass. Applied the repo's
established bare-assert `setTimeout(() => process.exit(0), 50)` exit after cleanup.
NOTE: it was never actually in the QUARANTINE map (only 2 entries: user-blocks +
nwc-client), so it had been silently hanging any local `npm run test:unit` (no
per-file timeout unless UNIT_TEST_TIMEOUT_MS is set).
- `nwc-client` — ✅ DONE + UN-QUARANTINED 2026-07-05. (1) Mock-shadow fixed: the
bootstrap's frozen canonical toolkit is preferred by readToolkitFromScope, so the
per-section `window.NostrTools` mocks never took effect (real @noble rejected the
fake keys). Removing the scope canonical lets the resolver fall through to each
mock. (2) Spec correction: one section expected a rejection when the toolkit
"lacks nip44," but mergeWithCanonical ALWAYS backfills the canonical nip44 (even
explicit `nip44:null`), so nip44_v2 is never absent — the wallet now advertises a
genuinely-unsupported scheme (`nip44_v3`) to exercise the same UNSUPPORTED_ENCRYPTION
path, and the assertion also checks `error.code`.
- `user-blocks` — ✅ DONE + UN-QUARANTINED 2026-07-05. Post-completion HANG: the
decrypt-timeout section installs a never-resolving nip44Decrypt that leaves a
background decrypt-retry timer alive (manager.reset() doesn't cancel it). Applied
the same `setTimeout(() => process.exit(0), 50)` exit after the final assertion.
- `dm-block-filter` — ✅ DONE + UN-QUARANTINED 2026-07-05. A FOURTH, pre-existing hang
surfaced by running the full suite with a per-file timeout (it was never in the map,
so it had been silently hanging local `npm run test:unit`). All subtests PASS, then
node:test never exits. Root cause (diagnosed via process.getActiveResourcesInfo):
the "integration with loaded user block list" test's `manager.loadBlocks()` opens a
LIVE block-list subscription through nostrClient's real SimplePool aimed at the
test's fake relay URLs, leaking 4 TCP sockets + reconnect timers. NOT the
decrypt-retry timer (verified absent). Nulling the pool did NOT help — loadBlocks
calls `ensurePool()` (userBlocks.js:967) to re-create+connect a real pool when pool
is falsy. Fix (node:test-SAFE, no force-exit): make the test hermetic — (1) a TRUTHY
stub pool so ensurePool() is skipped, (2) a no-op getSubscriptionManager so the
subscription opens nothing; the block MERGE still runs via the stubbed
fetchListIncrementally. Verified: clean run exits 0 (# pass 12), AND a deliberately
broken assertion still exits 1 (# fail 1) — failures are NOT masked. See
TEST_INTEGRITY.md.
- **QUARANTINE map is now EMPTY** — every unit test file runs in CI.
### 12. Promotion: `unstable → beta`
- [ ] After this batch soaks and the high-priority items land, promote `unstable → beta`
+28 -10
View File
@@ -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>
@@ -140,6 +142,13 @@
>Website</a
>
<p id="channelLightning" class="text-sm text-muted-strong"></p>
<!-- #47: total sats zapped to this creator (bitvid tallies + 9735s).
Hidden until there's a nonzero total. -->
<p
id="channelZapTotal"
class="text-sm text-zap font-medium hidden"
title="Total sats zapped to this creator via bitvid"
></p>
</div>
<!-- Area for Subscribe/Unsubscribe button -->
@@ -149,6 +158,15 @@
<hr class="my-6" />
<!-- #37: creator playlists. Hidden unless FEATURE_PLAYLISTS is on and the
creator has at least one playlist (populated by js/channelPlaylists.js). -->
<section id="channelPlaylistsSection" class="hidden mb-8">
<h3 class="text-lg font-bold text-text-strong mb-4">Playlists</h3>
<div id="channelPlaylistsGrid" class="playlist-grid">
<!-- Playlist cards go here -->
</div>
</section>
<div class="flex items-center gap-2 mb-4">
<h3 class="text-lg font-bold text-text-strong">Videos by This User</h3>
<button
+41
View File
@@ -0,0 +1,41 @@
<!-- views/playlist.html — creator playlist (#37). Renders the playlist's ordered
videos into the shared #videoList grid via app.renderVideoList. -->
<section data-ds="new">
<div class="mb-4">
<a
href="#view=most-recent-videos"
id="playlistBackLink"
class="btn-ghost"
data-size="sm"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-4 h-4"
aria-hidden="true"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Back to channel
</a>
</div>
<div class="flex items-center gap-2 mb-2">
<h2 id="playlistTitle" class="text-xl font-semibold text-text">Playlist</h2>
</div>
<p id="playlistMeta" class="text-sm text-muted mb-4"></p>
<div id="videoList" class="bv-grid-video" data-testid="video-list">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text">Loading playlist…</span>
</div>
</div>
</div>
</section>