mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
commit
bc8f73f9cc
66 files changed
+5923
-170
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,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");
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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]+$/);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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]+$/);
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user