mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
merge: promote beta → main — Blossom companion-event P2P, blob management, storage fixes (#30)
This commit is contained in:
commit
0dd7072b12
127 files changed
+13312
-299
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"
|
||||
@@ -833,16 +860,51 @@
|
||||
<p class="text-sm text-muted">
|
||||
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from bitvid.
|
||||
</p>
|
||||
<div id="profileWalletBitcoinConnectRow" class="hidden space-y-2">
|
||||
<button
|
||||
id="profileWalletBitcoinConnect"
|
||||
type="button"
|
||||
class="btn focus-ring inline-flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z"></path>
|
||||
</svg>
|
||||
Connect wallet
|
||||
</button>
|
||||
<p class="text-xs text-muted">
|
||||
One-tap connect via Bitcoin Connect (NWC) — or paste a connection manually below.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm text-muted-strong" for="profileWalletUri">
|
||||
<span class="mb-2 block font-medium">Wallet Connect URI</span>
|
||||
<input
|
||||
id="profileWalletUri"
|
||||
type="url"
|
||||
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
|
||||
class="input"
|
||||
/>
|
||||
</label>
|
||||
<details id="profileWalletAdvanced" class="wallet-advanced">
|
||||
<summary
|
||||
id="profileWalletAdvancedSummary"
|
||||
class="wallet-advanced__summary cursor-pointer text-xs font-medium text-muted-strong hover:text-text"
|
||||
>
|
||||
Advanced — paste a connection manually
|
||||
</summary>
|
||||
<label
|
||||
class="mt-3 block text-sm text-muted-strong"
|
||||
for="profileWalletUri"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Wallet Connect URI</span>
|
||||
<input
|
||||
id="profileWalletUri"
|
||||
type="url"
|
||||
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
|
||||
class="input"
|
||||
/>
|
||||
</label>
|
||||
</details>
|
||||
<label class="block text-sm text-muted-strong" for="profileWalletDefaultZap">
|
||||
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
|
||||
<input
|
||||
@@ -950,9 +1012,35 @@
|
||||
<option value="cloudflare_r2" selected>Cloudflare R2</option>
|
||||
<option value="backblaze_b2">Backblaze B2</option>
|
||||
<option value="generic_s3">Custom S3</option>
|
||||
<option id="storageProviderBlossomOption" value="blossom" class="hidden">Blossom (Nostr-native)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Blossom (Nostr-native) — keyless, multi-server. Shown when the
|
||||
Blossom provider is selected; auth is your Nostr signer. -->
|
||||
<div id="storageBlossomSection" class="hidden space-y-3">
|
||||
<div class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
|
||||
Blossom stores files on nostr-native blob servers. No access keys — uploads are authorized by your Nostr signer. Add one or more servers (uploads mirror to all for resilience). The first server is the primary.
|
||||
</div>
|
||||
<label class="block text-sm text-muted-strong" for="storageBlossomServers">
|
||||
<span class="mb-2 block font-medium">Blossom servers (one URL per line)</span>
|
||||
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
|
||||
https://another.blossom.example"></textarea>
|
||||
</label>
|
||||
<!-- BUD-03 kind-10063 discovery: import the list you've published, or publish the list above. -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button id="storageBlossomImportBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Import from my server list
|
||||
</button>
|
||||
<button id="storageBlossomPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Publish my server list
|
||||
</button>
|
||||
</div>
|
||||
<p id="storageBlossomServerListStatus" class="text-xs text-muted" role="status"></p>
|
||||
</div>
|
||||
|
||||
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
|
||||
<div id="storageS3Fields" class="space-y-4">
|
||||
<!-- R2 Helper -->
|
||||
<div id="storageR2Helper" class="card border border-info/30 bg-info/10 p-3 text-xs text-info">
|
||||
Enter your Cloudflare R2 credentials. Your Account ID (from dashboard) is used to connect. For Public Access URL, use your custom domain or R2.dev subdomain. Ensure the token has "Object Read & Write" permissions.
|
||||
@@ -1006,6 +1094,8 @@
|
||||
The public URL where your files will be accessible (CDN).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- /storageS3Fields -->
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-muted-strong">
|
||||
<input id="storageDefault" type="checkbox" class="checkbox" />
|
||||
@@ -1135,6 +1225,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"
|
||||
@@ -1524,6 +1675,56 @@
|
||||
data-orientation="vertical"
|
||||
></ul>
|
||||
</div>
|
||||
<div class="space-y-3 border-t border-border/60 pt-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-text">
|
||||
Trusted creators
|
||||
</h4>
|
||||
<p class="text-xs text-muted">
|
||||
Turn off web-of-trust warnings for a creator across all
|
||||
their videos. They stay listed here until you reset them.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<label class="min-w-0 flex-1 space-y-1">
|
||||
<span class="text-xs font-medium text-muted"
|
||||
>Creator npub</span
|
||||
>
|
||||
<input
|
||||
id="profileAuthorOverrideInput"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="npub1…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="profileAuthorOverrideAddBtn"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
>
|
||||
Always show
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileAuthorOverrideStatus"
|
||||
class="text-xs text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<div
|
||||
id="profileAuthorOverridesEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
|
||||
>
|
||||
No trusted creators yet.
|
||||
</div>
|
||||
<ul
|
||||
id="profileAuthorOverridesList"
|
||||
class="grid-stack hidden"
|
||||
data-orientation="vertical"
|
||||
></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
|
||||
@@ -1794,6 +1995,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,63 @@ export const ALLOW_NSFW_CONTENT = false;
|
||||
*/
|
||||
export const FEATURE_NIP71_INGEST = true;
|
||||
|
||||
/**
|
||||
* Whether this deployment enables the Audio / Music / Podcast experience
|
||||
* (dedicated Audio player, sidebar tab, and the channel / "My Content" Audio
|
||||
* tabs — see docs/audio-integration-plan.md and TODO #60).
|
||||
*
|
||||
* Defaults to `false` — "off = no trace": with it off, nothing audio renders and
|
||||
* audio-only notes stay filtered out of the video feeds (current behavior). Flip
|
||||
* to `true` to light up the Audio surfaces. Watch/ingest-first; a separate
|
||||
* publish flag will gate uploading audio later.
|
||||
*/
|
||||
export const FEATURE_AUDIO_INGEST = false;
|
||||
|
||||
/**
|
||||
* Whether this deployment shows the "Connect wallet" button that uses Alby's
|
||||
* Bitcoin Connect for a smooth NWC connect flow (docs/bitcoin-connect-plan.md,
|
||||
* TODO #61). When on, the Wallet Connect pane offers Bitcoin Connect alongside
|
||||
* the manual URI field (which stays as the backup). It only lazy-loads the
|
||||
* vendored Bitcoin Connect bundle when the user actually clicks Connect.
|
||||
*
|
||||
* SHIPPING DEFAULT should be `false` ("off = no trace"). Currently `true` for
|
||||
* local testing only — reset to `false` before pushing/shipping until verified.
|
||||
*/
|
||||
export const FEATURE_BITCOIN_CONNECT = true;
|
||||
|
||||
/**
|
||||
* Whether this deployment offers Blossom (nostr-native blob storage) as a
|
||||
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
|
||||
* (docs/blossom-plan.md, TODO #30). Blossom uploads authorize with the user's
|
||||
* existing nostr signer (kind-24242 auth events) instead of S3 access keys, and
|
||||
* mirror to multiple servers for resilience.
|
||||
*
|
||||
* Defaults to `false` ("off = no trace"): with it off the Storage pane shows only
|
||||
* R2 / S3 / B2 as today and the vendored Blossom SDK is never imported. Flip to
|
||||
* `true` to expose the Blossom provider.
|
||||
*
|
||||
* Currently `true` for LOCAL testing of Phase 1b — reset to `false` (shipping
|
||||
* default) before pushing/promoting until the upload flow is verified.
|
||||
*/
|
||||
export const FEATURE_BLOSSOM_STORAGE = true;
|
||||
|
||||
/**
|
||||
* FEATURE_BLOSSOM_TORRENT_METADATA — restore WebTorrent/P2P for Blossom-hosted
|
||||
* videos without hosting a `.torrent` file. Blossom servers reject a `.torrent`
|
||||
* (415), so a Blossom video's magnet ships webseed-only (no `xs=`) and can't
|
||||
* bootstrap WebTorrent. With this on, bitvid publishes the torrent piece-map as a
|
||||
* separate, infohash-keyed Nostr event (kind 30078, `d=bitvid:torrent:<infohash>`)
|
||||
* and fetches it lazily only when the torrent path actually runs.
|
||||
* See docs/blossom-torrent-metadata-plan.md.
|
||||
*
|
||||
* Defaults to `false` ("off = no trace"): off ⇒ no companion event is published
|
||||
* and the playback lookup is skipped (Blossom stays URL-only, exactly as today).
|
||||
*
|
||||
* Currently `true` for LOCAL testing of the companion-event flow — reset to
|
||||
* `false` (shipping default) before pushing/promoting.
|
||||
*/
|
||||
export const FEATURE_BLOSSOM_TORRENT_METADATA = true;
|
||||
|
||||
/**
|
||||
* Card liveness visibility policy — what a non-owner video card does while its
|
||||
* CDN/WebTorrent liveness probes are still running:
|
||||
|
||||
+544
-4
@@ -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);
|
||||
@@ -518,6 +573,32 @@
|
||||
-webkit-backdrop-filter: blur(var(--overlay-backdrop-blur));
|
||||
}
|
||||
|
||||
/* `display: flex` above overrides the UA `[hidden] { display: none }`, so an
|
||||
overlay with the `hidden` attribute (e.g. the beacon processing spinner)
|
||||
would render on load and cover the whole page. Restore hide-on-hidden. */
|
||||
.ds-overlay-backdrop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* "Advanced — paste a connection manually" disclosure in the Wallet pane
|
||||
(Bitcoin Connect). A subtle rotating caret replaces the default marker. */
|
||||
.wallet-advanced__summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wallet-advanced__summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wallet-advanced__summary::before {
|
||||
content: "▸ ";
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.wallet-advanced[open] > .wallet-advanced__summary::before {
|
||||
content: "▾ ";
|
||||
}
|
||||
|
||||
.popularity-modal__backdrop {
|
||||
z-index: var(--z-overlay-popover);
|
||||
padding: var(--space-md);
|
||||
@@ -2706,8 +2787,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 +3007,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 +3570,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 +3596,79 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Compact creator/account cards (Subscriptions, Friends, Blocked, Admin
|
||||
lists, Moderation). Desktop lays them out in a row: summary left, actions
|
||||
right. `.profile-account-card` is the card itself (padded); the
|
||||
`--row` variant is for a nested flex row inside an already-padded card. */
|
||||
.profile-account-card,
|
||||
.profile-account-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.profile-account-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.profile-account-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
/* Phones: stack the card so the name/npub get the full width (no more
|
||||
4-line-wrapped npub in a crammed column), with the actions as a full-width
|
||||
row of equal-width buttons below. */
|
||||
@media (max-width: calc(theme("screens.sm") - 0.02px)) {
|
||||
.profile-account-card,
|
||||
.profile-account-card__row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.profile-account-card {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.profile-account-card__actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* tighter buttons so all three fit one clean row */
|
||||
.profile-account-card__actions .btn-ghost {
|
||||
padding-inline: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-modal__panes {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -3245,18 +3737,30 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-xs);
|
||||
/* Every tile the SAME height, so a 2-line label can't stretch its row
|
||||
taller than the others (the "ragged grid of ellipses" fix). */
|
||||
grid-auto-rows: var(--profile-modal-nav-tile-height);
|
||||
}
|
||||
|
||||
.profile-modal__nav > button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2xs);
|
||||
width: 100%;
|
||||
padding-block: var(--space-sm);
|
||||
padding-inline: var(--space-2xs);
|
||||
block-size: 100%;
|
||||
/* Consistent rounded-square tiles, not rounded-full ellipses (btn's
|
||||
default) that turn into circles once a tile gets taller. */
|
||||
border-radius: var(--radius-xl);
|
||||
/* Subtle fill so they read as solid cards, not floating outlines. The
|
||||
active tile's bg-primary (higher specificity) still wins. */
|
||||
background-color: var(--color-panel);
|
||||
padding-block: var(--space-xs);
|
||||
padding-inline: var(--space-3xs);
|
||||
line-height: 1.15;
|
||||
overflow: hidden;
|
||||
/* wrap labels at spaces, not mid-word */
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
@@ -3267,6 +3771,16 @@
|
||||
.profile-modal__nav > button svg {
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* In the tile grid the unread dot rides the tile's top-right corner instead
|
||||
of the vertical-nav left edge (where it landed in the grid gutter). */
|
||||
.profile-modal__nav > button .profile-nav__unread-dot {
|
||||
top: var(--space-2xs);
|
||||
left: auto;
|
||||
right: var(--space-2xs);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4220,6 +4734,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;
|
||||
@@ -5345,7 +5865,11 @@
|
||||
.dm-contact-row--active:hover,
|
||||
.dm-contact-row--active:focus-visible {
|
||||
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 60%, var(--border-strong));
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 60%,
|
||||
var(--border-strong)
|
||||
);
|
||||
box-shadow: inset 3px 0 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -5397,6 +5921,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 +6057,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;
|
||||
@@ -357,6 +359,9 @@
|
||||
--notification-banner-max-inline: var(--size-28rem);
|
||||
--profile-modal-nav-min-width: max-content;
|
||||
--profile-modal-nav-max-width: max-content;
|
||||
/* Uniform height for the mobile tab-grid tiles (roomy enough for a 2-line
|
||||
label, so a wrapping tile never stretches its row taller than the rest). */
|
||||
--profile-modal-nav-tile-height: 5rem;
|
||||
--profile-modal-scrollbar-offset: var(--space-2xs);
|
||||
--modal-embed-height: 80vh;
|
||||
--profile-history-max-height: 26.25rem;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Audio / Music / Podcasts — Dev Plan
|
||||
|
||||
Status: **DECISIONS LOCKED (D1–D10, per recommendations, 2026-07-09)** — ready to
|
||||
build; config flag `FEATURE_AUDIO_INGEST` wired (default off). Sibling of the shorts
|
||||
(`docs/shorts-plan.md`) and live (`docs/live-ingest-plan.md` /
|
||||
`docs/live-publish-plan.md`) plans; shares the media-category model with both.
|
||||
Tracks TODO #60. The near-term "keep audio out of the video feed" guard is
|
||||
already shipped (see #60 / `convertEventToVideo` `isAudioOnlyVideoObject`).
|
||||
|
||||
## Executive summary
|
||||
|
||||
bitvid is video-only: it supports NIP-71 video (kinds 21/22/34235/34236) and
|
||||
native kind 30078, and has **no `<audio>` player**. Podcasts/music published as
|
||||
native 30078 with `imeta m audio/*` currently render as a broken `<video>`, so
|
||||
they are now filtered out of the video feeds. This plan turns "filter them out"
|
||||
into "give them a proper home": an **Audio** category with its own player,
|
||||
sidebar tab, channel-profile tab, and "My Content" tab — built on a **shared
|
||||
media-category resolver** (`video | audio | live | short`) used everywhere so a
|
||||
note lands in exactly one bucket. Watch/ingest-first (mirrors live-ingest),
|
||||
flag-gated from day one (off = no trace), whitelist-scoped like shorts/live.
|
||||
|
||||
## Landscape — audio kinds & NIPs (surveyed 2026-07-09, local NIP mirror)
|
||||
|
||||
| Surface | Kind / tag | Notes | Support |
|
||||
| --- | --- | --- | --- |
|
||||
| **Native re-upload** (today) | 30078 + `imeta m audio/*` | What Nostr Compass / Nodesignal use; bespoke, not a standard audio kind | pipeline exists |
|
||||
| **NIP-A0 Voice Messages** | `kind 1222` (root), `1244` (reply) | Native short audio notes; URL to audio file, rec. `audio/mp4`/.m4a, ≤60s | ❌ |
|
||||
| **NIP-73 External Content IDs** | `i`/`k` = `podcast:guid` / `podcast:item:guid` / `podcast:publisher:guid` | The Podcasting 2.0 / PodcastIndex / Fountain bridge — reference real feeds/episodes | ❌ |
|
||||
| **NIP-53 live audio** | `kind 30311` (stream), `30312` (audio rooms/spaces) | Live audio; **owned by #16**, not this plan | ❌ (#16) |
|
||||
| **NIP-71 audio variants** | `imeta m audio/*` + `waveform` | Already in the spec bitvid parses (audio track on a video) | partial |
|
||||
| **Music apps** (Wavlake, Stemstr) | app-specific custom kinds | Not ratified NIPs → opt-in integration only, not core | ❌ |
|
||||
|
||||
## Decisions needed
|
||||
|
||||
> **DECISION 1 — v1 source scope. ✅ LOCKED (= recommendation below).**
|
||||
> Which sources count as "audio" for the first release?
|
||||
> - **Option A — Native only:** kind 30078 with `imeta m audio/*` (+ a native
|
||||
> `t:audio` marker for new bitvid publishing). Lowest effort — reuses the whole
|
||||
> existing 30078 pipeline; matches the data already in the wild.
|
||||
> - **Option B — A + NIP-A0 voice notes** (`kind 1222`/`1244`). Adds a small,
|
||||
> standardized native audio kind. Moderate.
|
||||
> - **Option C — A + B + NIP-73 podcast-feed ingest** (pull real podcast RSS via
|
||||
> PodcastIndex GUIDs). Full podcast client. Biggest — its own phase.
|
||||
> _Recommendation: **A for v1**, B fast-follow, C as a later phase (D7)._
|
||||
|
||||
> **DECISION 2 — Discovery / whitelist scope. ✅ LOCKED (= recommendation below).**
|
||||
> Mirror shorts D1 / live D1: list audio only from **whitelisted/trusted authors**?
|
||||
> _Recommendation: **Yes** — same scope as video/shorts/live, one consistent trust
|
||||
> model. (No separate audio allowlist.)_
|
||||
|
||||
> **DECISION 3 — Does audio appear in the main feed, or Audio-tab only? ✅ LOCKED (= recommendation below).**
|
||||
> - **Option A — Audio-tab only** (excluded from main/discovery video feeds).
|
||||
> Consistent with the shipped guard that already excludes audio from video feeds,
|
||||
> and with shorts D3 (Shorts-tab only).
|
||||
> - **Option B — also mixed into the main feed** with an audio card treatment.
|
||||
> _Recommendation: **Option A** — keeps the video feed clean; audio is a
|
||||
> deliberate destination, not an interruption._
|
||||
|
||||
> **DECISION 4 — Player model. ✅ LOCKED: A for v1, architected toward B.**
|
||||
> - **Option A — Reuse the player modal in "audio mode":** detect audio → swap the
|
||||
> `<video>` for an `<audio>` element + big poster art, reuse the existing
|
||||
> zaps/comments/moderation/share chrome. Fastest; one modal.
|
||||
> - **Option B — Persistent mini audio player** (bottom bar that keeps playing while
|
||||
> you browse — the podcast/music norm). Best listening UX; larger build (global
|
||||
> playback state, survives navigation).
|
||||
> _Recommendation: **A for v1**, **B as the real target** — audio's whole value is
|
||||
> "keep listening while you browse," so plan the architecture so A can grow into B._
|
||||
|
||||
> **DECISION 5 — Category signal / resolver. ✅ LOCKED (= recommendation below).**
|
||||
> How is a note bucketed as audio (must be deterministic + shared)?
|
||||
> Signals, in priority: (1) `imeta m audio/*` with no video variant, (2) kind
|
||||
> `1222`/`1244` (if D1≥B), (3) `podcast:*` `i` tag (if D1=C), (4) native `t:audio`
|
||||
> marker, (5) unambiguous audio URL extension (the shipped fallback). `.ogg` stays
|
||||
> video unless imeta says audio.
|
||||
> _Also decide: **adopt a `t:audio` marker** (parallel to `t:video`) for bitvid's
|
||||
> own audio publishing so the Audio feed has a clean relay-side `#t` filter?
|
||||
> Recommendation: **Yes.**_
|
||||
|
||||
> **DECISION 6 — Publish vs watch-only for v1. ✅ LOCKED (= recommendation below).**
|
||||
> - **Option A — Watch/ingest-only** (mirrors live-ingest): bitvid surfaces others'
|
||||
> audio, no upload UI yet.
|
||||
> - **Option B — Publish too:** upload an audio file → native 30078 with
|
||||
> `imeta m audio/*` + `t:audio` (and/or NIP-A0 `1222` for short voice notes).
|
||||
> _Recommendation: **A for v1**; split publish into its own plan/phase like
|
||||
> live-ingest vs live-publish._
|
||||
|
||||
> **DECISION 7 — Podcast-feed interop (NIP-73). ✅ LOCKED: Yes, Phase 3.**
|
||||
> Should bitvid reference real Podcasting 2.0 feeds/episodes (link an audio note to
|
||||
> its `podcast:guid` / `podcast:item:guid`, dedupe re-uploads, interop with Fountain
|
||||
> / PodcastIndex)? _Recommendation: **Yes, but Phase 3** — high interop value, but
|
||||
> pulls in RSS/GUID resolution; not v1._
|
||||
|
||||
> **DECISION 8 — One "Audio" category, or split Music vs Podcasts? ✅ LOCKED (= recommendation below).**
|
||||
> _Recommendation: **one Audio category** for v1 (podcasts + music together); add a
|
||||
> Music sub-filter later if Wavlake-style content appears. Splitting now needs a
|
||||
> reliable music-vs-podcast signal we don't have._
|
||||
|
||||
> **DECISION 9 — Value-for-Value / zaps. ✅ LOCKED (= recommendation below).**
|
||||
> Podcasting 2.0 has streaming V4V (per-minute sats). bitvid has one-shot zaps.
|
||||
> _Recommendation: **reuse the existing zap system** for v1; per-minute streaming
|
||||
> V4V is a future enhancement, not v1._
|
||||
|
||||
> **DECISION 10 — Config flag. ✅ LOCKED (= recommendation below).**
|
||||
> Name + granularity. _Recommendation: `FEATURE_AUDIO_INGEST` (off = no trace),
|
||||
> later `FEATURE_AUDIO_PUBLISH`; matches `FEATURE_LIVE_INGEST` / shorts pattern._
|
||||
|
||||
## Config flags (off = no trace)
|
||||
|
||||
- `FEATURE_AUDIO_INGEST` (default **false**) — gates the Audio sidebar tab, the
|
||||
channel/`My Content` Audio tabs, the audio player, and audio-category resolution.
|
||||
With it off, nothing audio renders and the shipped guard keeps audio out of the
|
||||
video feeds (current behavior).
|
||||
- `FEATURE_AUDIO_PUBLISH` (default **false**, later) — gates the upload-side audio
|
||||
content-type + native marker.
|
||||
|
||||
## Field mapping — detecting an "audio" note (the shared resolver)
|
||||
|
||||
Add ONE `resolveMediaCategory(video) → "video" | "audio" | "live" | "short"` used
|
||||
by the sidebar feeds, the public channel tabs, AND the profile-modal "My Content"
|
||||
tabs (TODO #60). Audio branch reuses the already-shipped
|
||||
`isAudioOnlyVideoObject(video)` logic:
|
||||
|
||||
- **audio** ⇐ no magnet AND no `imeta m video/*` AND ( `imeta m audio/*` OR audio
|
||||
kind `1222`/`1244` OR a `podcast:*` `i` tag OR `t:audio` OR an unambiguous audio
|
||||
URL `.mp3/.m4a/.m4b/.f4a/.aac/.wav/.flac/.opus/.oga/.weba/.mka` ). `.ogg` alone
|
||||
stays video.
|
||||
- **short**/**live** per shorts-plan / live-ingest-plan.
|
||||
- **video** otherwise (default).
|
||||
|
||||
Metadata an audio card wants (already present on the wild podcast notes):
|
||||
`title`, `image`/`thumbnail` (cover art), `duration`, `summary`/`description`,
|
||||
`channel`, `show`, podcast `t` tags. NIP-71 audio variants also carry `waveform`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Category resolver** (`js/nostr/…` or `js/services/…`) — single source of truth
|
||||
(D5). Feed engine + channelProfile + MyVideosController all call it. This is the
|
||||
natural home for the #17 unified media grid.
|
||||
- **Per-category lazy loaders** — each tab (sidebar + channel + My Content) fetches
|
||||
only its category on select; no cross-contamination (audio guard already keeps
|
||||
audio out of video and vice-versa).
|
||||
- **Audio player** (D4) — v1: player modal detects `category==="audio"` → renders
|
||||
`<audio>` + poster + existing zap/comment/moderation chrome. Architect the
|
||||
playback/session layer so it can later hoist into a **persistent mini-player**
|
||||
(Option B) without rewriting the surfaces.
|
||||
- **Channel profile + "My Content"** — TODO #60 tab strips (Videos / Audio / Live /
|
||||
Shorts), empty categories hidden; "My Videos" pane renamed **My Content**.
|
||||
|
||||
## Audio / podcast UX best practices
|
||||
|
||||
- **Keep playing while browsing** — the defining audio behavior; the persistent
|
||||
mini-player (D4-B) is what makes bitvid usable as a podcast/music app.
|
||||
- **Cover art forward** — audio cards lead with `image`; no 16:9 letterboxing.
|
||||
- **Duration + resume** — show length; remember playback position per episode
|
||||
(ties into watch-history).
|
||||
- **Speed + skip** — 1x–2x rate, ±15/30s skip; background/lock-screen media session
|
||||
(`navigator.mediaSession`) so phones show cover art + transport controls.
|
||||
- **Waveform** (nice-to-have) when NIP-71 `waveform` is present.
|
||||
|
||||
## Phases (each flag-gated from day one)
|
||||
|
||||
- **Phase 0 — Resolver + guard alignment.** Ship `resolveMediaCategory`; refactor
|
||||
the shipped `isAudioOnlyVideoObject` to feed it. No UX. (Unblocks shorts/live too.)
|
||||
- **Phase 1 — Audio tab + player (watch-only, D1=A).** Sidebar Audio tab (whitelist-
|
||||
scoped), audio-mode player modal, native 30078 `m audio/*` + `t:audio`. The
|
||||
headline.
|
||||
- **Phase 1.5 — Channel + My Content tabs** (TODO #60). Category tab strips on the
|
||||
public channel and the renamed "My Content" pane.
|
||||
- **Phase 2 — NIP-A0 voice notes** (D1=B). Ingest `1222`/`1244`; short-note card.
|
||||
- **Phase 2.5 — Publish** (`FEATURE_AUDIO_PUBLISH`, D6-B). Upload audio → native
|
||||
30078 `m audio/*` + `t:audio`; optional NIP-A0 for short voice.
|
||||
- **Phase 3 — Persistent mini-player** (D4-B) + **NIP-73 podcast-feed interop** (D7):
|
||||
reference real feeds/episodes, dedupe re-uploads, PodcastIndex/Fountain interop.
|
||||
|
||||
## Moderation, whitelist & NSFW
|
||||
|
||||
Inherit the existing model unchanged (same as shorts/live): whitelist scope (D2),
|
||||
web-of-trust mute/report blur, per-event blacklist, NSFW gate — all key off the
|
||||
author + event, not the media type. The account-level WoT override (#24) applies
|
||||
to audio creators too.
|
||||
|
||||
## Risks / watch-items
|
||||
|
||||
- **Broken/foreign audio formats** — some `audio/*` won't play cross-browser; the
|
||||
`<audio>` element degrades better than `<video>` but still probe/fallback.
|
||||
- **Re-upload duplication** — the same episode as a native 30078 AND (later) a
|
||||
NIP-73-referenced feed item → dedupe by `podcast:item:guid` once D7 lands.
|
||||
- **Persistent player scope creep** — global playback state touches routing/history;
|
||||
keep v1 to the modal, but don't design it in a way that blocks D4-B.
|
||||
- **Empty tabs** — whitelist-scoped audio may be sparse; hide empty categories.
|
||||
- **Music licensing** — surfacing Wavlake-style music is an integration, not core;
|
||||
keep it opt-in (D8).
|
||||
|
||||
## Cross-refs & sources
|
||||
|
||||
- TODO #60 (audio tab, channel/My Content tabs, resolver), #16 (live audio),
|
||||
#17 (unified media grid), #24 (WoT override).
|
||||
- Sibling plans: `docs/shorts-plan.md`, `docs/live-ingest-plan.md`,
|
||||
`docs/live-publish-plan.md`, `docs/nip71-migration-plan.md`.
|
||||
- Specs (local mirror `docs/nips/`): NIP-A0 (Voice Messages 1222/1244), NIP-73
|
||||
(External Content IDs / podcast GUIDs), NIP-53 (Live 30311/30312), NIP-71
|
||||
(video + audio variants), NIP-94/96 + Blossom (blob hosting — where the audio
|
||||
files live, e.g. `relay.towardsliberty.com`).
|
||||
@@ -0,0 +1,167 @@
|
||||
# Bitcoin Connect — NWC wallet connect UX — Dev Plan
|
||||
|
||||
Status: **DECISIONS NEEDED (D1–D6)** — no code yet. Adds Alby's
|
||||
[Bitcoin Connect](https://bitcoin-connect.com/) as a smooth "connect your wallet"
|
||||
front-end for NWC, feeding bitvid's **existing** NWC pipeline. Manual URI entry
|
||||
stays as the backup/advanced path. Feature-flagged (off = no trace).
|
||||
|
||||
## Executive summary
|
||||
|
||||
bitvid already has a full NWC stack: manual URI entry (`#walletUri` in the Wallet
|
||||
Connect pane → `ProfileWalletController`), validation
|
||||
(`nwcSettingsService.validateWalletUri` — requires `nostr+walletconnect://`),
|
||||
per-npub storage (`nwcSettings.js`, IndexedDB + localStorage), opt-in encrypted
|
||||
cross-login sync, and the zap-split/pay_invoice payment pipeline
|
||||
(`js/payments/*`). The friction is purely the *connect* step — pasting a URI.
|
||||
|
||||
Bitcoin Connect solves exactly that: a polished modal that walks the user through
|
||||
connecting a wallet (CoinOS, Alby Hub, mutiny, etc.) and, for the NWC connector,
|
||||
**exposes the raw NWC pairing URI**. So we bolt it on as an alternate connect
|
||||
button that extracts the URI and hands it to bitvid's existing validate→store
|
||||
pipeline. **No change to how bitvid pays or stores** — one new way to obtain the
|
||||
same string. Manual entry stays for power users / unsupported wallets.
|
||||
|
||||
## How Bitcoin Connect works (verified against the v3 README)
|
||||
|
||||
- Package `@getalby/bitcoin-connect` (ESM; Lit web components + functions; bundles
|
||||
`@getalby/js-sdk` internally — no separate peer dep). Also loadable from
|
||||
`https://esm.sh/@getalby/bitcoin-connect@<pin>` (our CSP already allows esm.sh).
|
||||
- `init({ appName, filters: ["nwc"], persistConnection, providerConfig: { nwc: {
|
||||
authorizationUrlOptions: { requestMethods } } } })` — `filters:["nwc"]` restricts
|
||||
the modal to NWC; `requestMethods` requests least-privilege scopes.
|
||||
- `launchModal()` / `await requestProvider()` opens the connect flow.
|
||||
- `onConnected((provider) => …)` → a WebLN provider. **NWC extraction:**
|
||||
```js
|
||||
import { onConnected } from "@getalby/bitcoin-connect";
|
||||
import { WebLNProviders } from "@getalby/sdk"; // or the re-export
|
||||
onConnected((provider) => {
|
||||
if (provider instanceof WebLNProviders.NostrWebLNProvider) {
|
||||
const uri = provider.client.nostrWalletConnectUrl; // ← the NWC secret
|
||||
}
|
||||
});
|
||||
```
|
||||
- `disconnect()` clears its connection; `getConnectorConfig()` returns the saved
|
||||
connector config. Persistence to localStorage is automatic when
|
||||
`persistConnection: true`.
|
||||
- ⚠️ README says the lib is **Alpha** — pin an exact version.
|
||||
|
||||
## Decisions needed
|
||||
|
||||
> **DECISION 1 — Delivery. 🔵 NEEDED.**
|
||||
> - **A — Vendor + lazy `import()`:** esbuild-bundle a pinned `@getalby/bitcoin-connect`
|
||||
> into `vendor/bitcoin-connect.bundle.min.js` (same pattern as floating-ui in
|
||||
> `scripts/build-beacon.mjs`), dynamically imported only when the user opens the
|
||||
> Wallet pane / clicks Connect. Offline-safe, supply-chain-pinned, no main-bundle bloat.
|
||||
> - **B — Lazy-load from esm.sh** (CSP already allows it) at a pinned version.
|
||||
> Zero vendoring work, but a runtime CDN dependency + supply-chain trust.
|
||||
> _Recommendation: **A** — bitvid vendors its other heavy deps; Bitcoin Connect is
|
||||
> a Lit bundle (~hundreds of KB) so lazy-import is important either way._
|
||||
|
||||
> **DECISION 2 — Persistence ownership. 🔵 NEEDED.**
|
||||
> - **A — `persistConnection: false`:** Bitcoin Connect is purely the connect UI;
|
||||
> bitvid extracts the URI and is the ONLY store (its existing encrypted-sync /
|
||||
> per-npub model). No duplicate plaintext copy in `bc:*` localStorage.
|
||||
> - **B — let it persist** and rely on `disconnect()` on bitvid logout.
|
||||
> _Recommendation: **A** — one owner of the spending secret; call `disconnect()`
|
||||
> right after extracting to be safe._
|
||||
|
||||
> **DECISION 3 — Connector scope. 🔵 NEEDED.**
|
||||
> - **A — NWC only** (`filters:["nwc"]`). Yields a URI that flows straight into the
|
||||
> existing pipeline; zap-split/pay_invoice unchanged.
|
||||
> - **B — also allow WebLN / Alby-extension / Alby Hub** connectors. Smoother for
|
||||
> Alby users, but those give a WebLN *provider*, not an NWC URI — bitvid's raw-NWC
|
||||
> payment pipeline can't consume them without a second WebLN payment path.
|
||||
> _Recommendation: **A for v1** (matches the pipeline); a WebLN path is a separate
|
||||
> later effort if there's demand._
|
||||
|
||||
> **DECISION 4 — Requested scopes. 🔵 NEEDED.**
|
||||
> `requestMethods` to request from the wallet. bitvid needs **`pay_invoice`** (zaps);
|
||||
> `get_balance` (balance display), `make_invoice` / `lookup_invoice` (receipts/tally).
|
||||
> _Recommendation: `["pay_invoice","get_balance","make_invoice","lookup_invoice"]` —
|
||||
> least privilege for what bitvid actually calls. (The wallet ultimately decides.)_
|
||||
|
||||
> **DECISION 5 — UI placement vs manual mode. 🔵 NEEDED.**
|
||||
> _Recommendation: In the Wallet Connect pane, make **"Connect wallet"** (Bitcoin
|
||||
> Connect) the primary CTA, and move the raw-URI field under a collapsible
|
||||
> **"Advanced / paste a connection manually"** — manual stays a first-class backup,
|
||||
> just de-emphasized. On success, populate the same status/disconnect UI._
|
||||
|
||||
> **DECISION 6 — Feature flag. 🔵 NEEDED.**
|
||||
> _Recommendation: `FEATURE_BITCOIN_CONNECT` in `config/instance-config.js` (default
|
||||
> off = no trace), wired exactly like `FEATURE_AUDIO_INGEST`. With it off, only the
|
||||
> manual URI field shows — zero new code path or bundle load._
|
||||
|
||||
## Config flag (off = no trace)
|
||||
|
||||
- `FEATURE_BITCOIN_CONNECT` (default **false**) — gates the "Connect wallet" button
|
||||
+ the lazy Bitcoin Connect import. Off ⇒ manual URI entry only (today's behavior).
|
||||
|
||||
## Integration points (small, contained)
|
||||
|
||||
- `components/profile-modal.html` — Wallet Connect pane: add the "Connect wallet"
|
||||
button; wrap the existing `#walletUri` field as "Advanced/manual."
|
||||
- `js/ui/profileModal/ProfileWalletController.js` — a `handleBitcoinConnect()` that
|
||||
lazy-imports the lib, `init()`s once (NWC-only, least-privilege, no-persist),
|
||||
`launchModal()`, and on `onConnected` extracts `nostrWalletConnectUrl` →
|
||||
runs the SAME `validateWalletUri` + save path as manual "Save wallet" → then
|
||||
`disconnect()`. Reuses all existing status/disconnect/sync UI.
|
||||
- `config/instance-config.js` / `js/config.js` / `js/constants.js` — the flag.
|
||||
- `scripts/` + `vendor/` — the esbuild vendor bundle (D1-A).
|
||||
|
||||
## Architecture
|
||||
|
||||
Bitcoin Connect is a **connect adapter**, not a payment layer:
|
||||
```
|
||||
[Connect wallet] → BC modal (NWC) → onConnected(provider)
|
||||
→ provider.client.nostrWalletConnectUrl
|
||||
→ validateWalletUri() (existing, scheme check)
|
||||
→ nwcSettingsService save (existing: per-npub store + optional encrypted sync)
|
||||
→ BC disconnect() (bitvid now owns the secret)
|
||||
[zaps] → js/payments/nwcClient pay_invoice + zap-split (UNCHANGED)
|
||||
```
|
||||
The manual field writes to the exact same validate→store path, so the two entry
|
||||
points converge immediately and everything downstream is untouched.
|
||||
|
||||
## Security (NWC URI = bearer SPENDING secret)
|
||||
|
||||
- **Never log** `nostrWalletConnectUrl` (or the provider) — extract → validate →
|
||||
store via the existing (never-logged) pipeline; treat like the manual field.
|
||||
- **`persistConnection: false` + `disconnect()`** after extract so the secret isn't
|
||||
duplicated in Bitcoin Connect's own plaintext `bc:*` localStorage (D2).
|
||||
- **Pin the version + vendor it** (Alpha lib) — no unpinned CDN `@latest`.
|
||||
- **Least-privilege `requestMethods`** (D4) — request only what bitvid calls.
|
||||
- Re-run bitvid's `validateWalletUri` on the extracted string; don't trust the lib
|
||||
blindly. Keep the explicit "a wallet-connect URI can SPEND" warning on the
|
||||
encrypted-sync toggle.
|
||||
- Payment invariants unchanged: `PLATFORM_FEE_PERCENT=30` split, preimage
|
||||
verification, and the "a pay_invoice timeout must NEVER auto-resend" rule all live
|
||||
in `js/payments/*` and are not touched.
|
||||
|
||||
## Phases (flag-gated from day one)
|
||||
|
||||
- **Phase 0 — Flag + vendor bundle.** Add `FEATURE_BITCOIN_CONNECT` (off) and the
|
||||
esbuild vendor step. No UX.
|
||||
- **Phase 1 — Connect button (headline).** Wallet pane "Connect wallet" → BC NWC
|
||||
modal → extract → existing save path → disconnect. Manual field → "Advanced."
|
||||
- **Phase 2 — Polish.** Balance display (BC `showBalance` / get_balance), reconnect
|
||||
affordance, and a "test connection" ping. Optional.
|
||||
- **Phase 3 (only if wanted) — WebLN path** (D3-B): accept Alby-extension/Hub
|
||||
connectors via a WebLN payment adapter alongside raw NWC. Separate effort.
|
||||
|
||||
## Risks / watch-items
|
||||
|
||||
- **Alpha library / breaking changes** — pin + vendor; wrap behind our own thin
|
||||
adapter so an API change is a one-file fix.
|
||||
- **Bundle weight** — lazy-import only on Wallet-pane open / button click.
|
||||
- **Wallet grants fewer scopes than requested** — if `pay_invoice` isn't granted,
|
||||
zaps fail; surface a clear error and fall back to manual.
|
||||
- **Not all wallets support NWC** — manual mode + non-NWC connectors (D3-B) cover the gap.
|
||||
- **esm.sh path (D1-B)** would add a runtime third-party origin to the trust surface.
|
||||
|
||||
## Sources
|
||||
|
||||
- getAlby/bitcoin-connect README (v3) — install, `init` filters/providerConfig,
|
||||
`onConnected`, `NostrWebLNProvider.client.nostrWalletConnectUrl`, `disconnect`.
|
||||
- bitcoin-connect.com. Related bitvid: `js/services/nwcSettingsService.js`,
|
||||
`js/nwcSettings.js`, `js/ui/profileModal/ProfileWalletController.js`,
|
||||
`js/payments/*`, `docs/audio-integration-plan.md` (flag-wiring pattern).
|
||||
@@ -0,0 +1,224 @@
|
||||
# Blossom storage — first-class alongside R2 / S3 — Dev Plan
|
||||
|
||||
Status: **DECISIONS LOCKED (D1–D10, per recommendations, 2026-07-09)** — ready to build. Adds Blossom (nostr-native
|
||||
blob storage) as a first-class upload provider next to Cloudflare R2 / generic S3
|
||||
/ Backblaze B2, using the maintained `blossom-client-sdk`. Feature-flagged
|
||||
(off = no trace). Tracks TODO #30. Research: docs sources at the bottom; spec is
|
||||
`github.com/hzrd149/blossom` (BUDs). Sibling of the audio / Bitcoin Connect plans.
|
||||
|
||||
## Executive summary
|
||||
|
||||
bitvid's uploads already flow through one seam: `mediaUploader` selects a service
|
||||
by provider and calls `service.uploadFile({ … }) → { url, key, … }` (the `url` is
|
||||
what lands in the kind-30078 video event). Both `r2Service` and `s3UploadService`
|
||||
implement that `uploadFile` contract; routing is a one-liner
|
||||
(`isR2Provider(provider) ? r2Service : s3Service`, `mediaUploader.js:38`).
|
||||
|
||||
**Blossom = a new `blossomService` implementing the same `uploadFile` contract**,
|
||||
plus an `isBlossomProvider` branch. Blossom's upload is *simpler* than S3 (no
|
||||
SigV4, no multipart, no bucket creation, no client-side CORS provisioning) and its
|
||||
auth is a signed nostr event (kind 24242) — so it reuses **bitvid's existing
|
||||
signer** with zero new crypto. The one real cost is the Storage-pane config UI,
|
||||
which today hard-assumes S3 credential fields and needs a parallel **keyless,
|
||||
multi-server** form.
|
||||
|
||||
The payoff: for anyone who picks Blossom, the whole "store an upload secret in the
|
||||
browser + encrypted-sync it" surface **disappears** (there is no secret — only
|
||||
server URLs + the user's signer), and files gain **resilience via mirroring** to
|
||||
multiple servers, which is Blossom's actual advantage over a single S3 bucket.
|
||||
|
||||
## Landscape — the Blossom spec today (researched July 2026)
|
||||
|
||||
The spec has modularized since older write-ups; the auth and management endpoints
|
||||
moved into their own BUDs:
|
||||
|
||||
| BUD | Covers | Tier |
|
||||
| --- | --- | --- |
|
||||
| **BUD-01** | server requirements + blob retrieval (`GET`/`HEAD /<sha256>`) | core |
|
||||
| **BUD-02** | `PUT /upload` (returns a blob descriptor) | core |
|
||||
| **BUD-11** | **Nostr Authorization** — kind-24242 auth event | core |
|
||||
| **BUD-12** | `GET /list/<pubkey>` + `DELETE /<sha256>` (moved out of 02) | opt |
|
||||
| BUD-03 | server discovery via **kind-10063** user server list | recommended |
|
||||
| BUD-04 | cross-server **mirroring** (`PUT /mirror`) | recommended |
|
||||
| BUD-05 | `/media` endpoint (metadata strip / re-encode) | optional |
|
||||
| BUD-06 | upload **negotiation** (preflight before sending bytes) | recommended |
|
||||
| BUD-07 / 09 / 10 | payment-gated / reporting / URI scheme | optional |
|
||||
|
||||
**Core minimum a client should implement today: BUD-01 + BUD-02 + BUD-11.** Add
|
||||
BUD-03 (discovery) + BUD-06 (preflight) + BUD-04 (mirror) for a good client.
|
||||
|
||||
- **Client SDK:** `blossom-client-sdk` v5 (hzrd149, the spec author). ESM; only
|
||||
required dep is `@noble/hashes` — which **bitvid already vendors** (crypto-
|
||||
helpers). Optional peer deps (cashu, hls.js) are skipped. Small bundle.
|
||||
- **Auth (BUD-11):** a kind-24242 event with `t` (`get`/`upload`/`list`/`delete`/
|
||||
`media`), a **required NIP-40 `expiration`**, optional `server` (scope to a
|
||||
domain) and `x` (scope to a sha256), sent as
|
||||
`Authorization: Nostr <base64url event>`. The SDK's `createUploadAuth(signer,
|
||||
sha256, { type })` builds it; the `signer` it wants is exactly bitvid's shape
|
||||
(`async (eventTemplate) → signed event`).
|
||||
- **Blob descriptor** (`PUT /upload` response): `{ url, sha256, size, type,
|
||||
uploaded, … }` — and MAY include `magnet` / `infohash` / `ipfs`, a direct tie-in
|
||||
to bitvid's torrent story. `sha256` maps onto bitvid's existing `fileSha256`/`x`.
|
||||
|
||||
## Decisions needed
|
||||
|
||||
> **DECISION 1 — Client library. ✅ LOCKED (= recommendation below).**
|
||||
> - **A — Vendor `blossom-client-sdk` v5** (esbuild bundle like bitcoin-connect /
|
||||
> floating-ui; its `@noble/hashes` dep is already vendored). Tracks the spec's
|
||||
> ongoing BUD refactors; gives upload/mirror/discovery/negotiation for free.
|
||||
> - **B — Hand-roll** the ~3 HTTP calls + the kind-24242 auth event.
|
||||
> _Recommendation: **A** — the spec is actively modularizing (auth→BUD-11,
|
||||
> list/delete→BUD-12); the maintained SDK absorbs that churn, and it's small._
|
||||
|
||||
> **DECISION 2 — Single server vs multi-server + mirror. ✅ LOCKED (= recommendation below).**
|
||||
> - **A — Multi-server list** (`multiServerUpload`): upload to N servers (HEAD
|
||||
> preflight, `/mirror` to register on ones that already have the blob).
|
||||
> - **B — Single server** URL only.
|
||||
> _Recommendation: **A** — multiple live copies (resilience/censorship-resistance)
|
||||
> is the entire point of Blossom over one S3 bucket. Config becomes a server list._
|
||||
|
||||
> **DECISION 3 — kind-10063 server discovery/publish (BUD-03). ✅ LOCKED (= recommendation below).**
|
||||
> Read the user's published server list to pre-fill their servers, and publish/
|
||||
> update it so other clients can find bitvid-hosted media.
|
||||
> _Recommendation: **read in v1** (`getServersFromServerListEvent`), **publish in
|
||||
> v1.5** (a "publish my server list" action). Spec says clients SHOULD do both._
|
||||
|
||||
> **DECISION 4 — `/upload` vs `/media` (BUD-05). ✅ LOCKED (= recommendation below).**
|
||||
> `/media` strips metadata + may re-encode; `/upload` stores exact bytes.
|
||||
> _Recommendation: **`/upload` for videos** (keeps the exact file so the torrent
|
||||
> infohash/seed stays valid), optional **`/media` for thumbnails/avatars** later
|
||||
> (privacy/EXIF strip)._
|
||||
|
||||
> **DECISION 5 — Signer wiring. ✅ LOCKED (= recommendation below).**
|
||||
> _Recommendation: reuse bitvid's **active signer adapter** as the SDK `signer`
|
||||
> (NIP-07 / NIP-46 / nsec all already produce `async (tpl) → signed event`). No new
|
||||
> crypto. Respect the signer circuit-breaker; reuse one auth across a mirror set
|
||||
> (the SDK supports this) so a multi-server upload isn't N prompts._
|
||||
|
||||
> **DECISION 6 — Torrent parity. ✅ LOCKED (= recommendation below).**
|
||||
> S3 uploads also build a magnet + seed via WebTorrent. Keep that for Blossom?
|
||||
> _Recommendation: **Yes** — keep parity (Blossom URL as the hosted source +
|
||||
> magnet as the P2P fallback). If the server's blob descriptor already returns
|
||||
> `magnet`/`infohash`, prefer/verify it; else compute as today. sha256 = the
|
||||
> shared content id._
|
||||
|
||||
> **DECISION 7 — Management (list/delete/orphan-GC). ✅ LOCKED (= recommendation below).**
|
||||
> BUD-12 `GET /list/<pubkey>` + `DELETE /<sha256>` for the storage-management UI
|
||||
> (My Videos orphan tools #8/#13).
|
||||
> _Recommendation: **v2** — v1 ships upload; wire list/delete + orphan parity after._
|
||||
|
||||
> **DECISION 8 — Storage-pane config type. ✅ LOCKED (= recommendation below).**
|
||||
> _Recommendation: add a **"Blossom"** option to `#storageProvider` that swaps the
|
||||
> S3 fields (endpoint/region/access-key/secret/bucket + CORS helper) for a **keyless
|
||||
> multi-server list** (add/remove server URLs, mark default, "test", optional
|
||||
> "import from my kind-10063 list" / "publish my list"). New connection-payload
|
||||
> variant `{ servers: [...] }` in the per-provider slot model._
|
||||
|
||||
> **DECISION 9 — Feature flag. ✅ LOCKED (= recommendation below).**
|
||||
> _Recommendation: `FEATURE_BLOSSOM_STORAGE` in `config/instance-config.js`
|
||||
> (default off = no trace), wired like `FEATURE_AUDIO_INGEST` /
|
||||
> `FEATURE_BITCOIN_CONNECT`. Off ⇒ no Blossom option, SDK never imported._
|
||||
|
||||
> **DECISION 10 — Privacy posture. ✅ LOCKED (= recommendation below).**
|
||||
> Blossom blobs are **public + content-addressed** (anyone with the URL/sha256 can
|
||||
> fetch), same as a public S3 bucket. There are no private blobs in the base spec.
|
||||
> _Recommendation: treat Blossom as public hosting (bitvid videos are already
|
||||
> public); use `/media` to strip EXIF on images; do NOT route anything sensitive
|
||||
> (e.g. private DMs) through public Blossom without BUD-07-style gating — keep DM
|
||||
> attachments on the existing path until a private story exists._
|
||||
|
||||
## Config flag (off = no trace)
|
||||
|
||||
- `FEATURE_BLOSSOM_STORAGE` (default **false**) — gates the "Blossom" storage
|
||||
provider option, the blossomService, and the lazy SDK import. Off ⇒ Storage pane
|
||||
shows only R2 / S3 / B2 exactly as today.
|
||||
|
||||
## The `uploadFile` contract (the seam Blossom implements)
|
||||
|
||||
`mediaUploader` calls, for both thumbnail and video (4 call sites):
|
||||
```
|
||||
service.uploadFile({ file, key?, contentType?, onProgress, credentials, … })
|
||||
→ { url, key, storagePointer?, infoHash?, magnet?, torrentUrl?, … }
|
||||
```
|
||||
`blossomService.uploadFile` maps to:
|
||||
1. `sha256 = @noble/hashes` of the file (SDK helper) → the content id + `key`.
|
||||
2. `createUploadAuth(signer, sha256, { type })` → kind-24242 auth (scoped `x`,
|
||||
short `expiration`).
|
||||
3. `multiServerUpload(servers, file, { onAuth })` (BUD-06 preflight + BUD-04
|
||||
mirror) → blob descriptors; pick the primary `url` (default server), keep the
|
||||
mirror URLs as fallbacks (map into the video's imeta `sources`, #17/#20).
|
||||
4. Return `{ url, key: sha256, storagePointer, magnet?, infoHash? }` so the rest of
|
||||
the publish pipeline is untouched.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`js/services/blossomService.js`** — implements `uploadFile` (+ later
|
||||
`listObjects`/`deleteObject` for BUD-12). Wraps the vendored SDK; takes the app's
|
||||
active signer. No credentials object — just the server list + signer.
|
||||
- **`mediaUploader.js`** — add `isBlossomProvider`; route to `blossomService`.
|
||||
- **Storage pane** (`components/profile-modal.html`, `ProfileStorageController`,
|
||||
`storageConnections`) — Blossom provider type: keyless multi-server form +
|
||||
payload variant `{ servers }`; hide S3 fields + CORS helper for Blossom.
|
||||
- **Discovery** — `getServersFromServerListEvent()` + `USER_BLOSSOM_SERVER_LIST_KIND`
|
||||
(kind 10063) to read; a publish action to write (BUD-03).
|
||||
- **Flag** — `config/instance-config.js` → `js/config.js` → `js/constants.js`.
|
||||
- **Vendor** — `scripts/build-blossom-sdk.mjs` → `vendor/blossom-client-sdk.bundle.min.js`
|
||||
(lazy-imported only when a Blossom upload runs).
|
||||
|
||||
## Security
|
||||
|
||||
- **No stored secret for Blossom** — only public server URLs + the user's signer.
|
||||
This *removes* the encrypted-cred-sync surface (#15) for Blossom users; nothing
|
||||
to leak, sync, or lock.
|
||||
- **Auth is short-lived + scoped** — kind-24242 events carry a required NIP-40
|
||||
`expiration` and MUST be scoped (`t`, `x` to the blob's sha256, optional `server`
|
||||
to the domain). Build fresh per action; never a long-lived token.
|
||||
- **Signer discipline** — uploads sign per action; reuse one auth across a mirror
|
||||
set (SDK) so a multi-server upload isn't N signer prompts, and respect the NIP-07
|
||||
circuit-breaker / decrypt-budget invariants. Never log the signed auth event's
|
||||
contents beyond what devLogger already gates.
|
||||
- **Integrity** — verify the returned `sha256` matches the uploaded bytes; on
|
||||
download, clients SHOULD verify the hash (BUD-01).
|
||||
- **Public data only** — Blossom blobs are world-readable; keep private content off
|
||||
it (D10).
|
||||
|
||||
## Phases (each flag-gated from day one)
|
||||
|
||||
- **Phase 0 — Flag + vendor SDK + service skeleton.** `FEATURE_BLOSSOM_STORAGE`,
|
||||
`scripts/build-blossom-sdk.mjs`, a `blossomService` stub. No UX.
|
||||
- **Phase 1 — Upload (headline).** `blossomService.uploadFile` via
|
||||
`multiServerUpload` + signer auth + BUD-06 preflight; `isBlossomProvider` routing;
|
||||
Storage-pane keyless multi-server form + "test"; torrent parity (D6). Video +
|
||||
thumbnail upload land in the same publish pipeline.
|
||||
- **Phase 1.5 — Discovery.** Read kind-10063 to pre-fill servers; a "publish my
|
||||
server list" action (BUD-03).
|
||||
- **Phase 2 — Management.** BUD-12 `list`/`delete`, orphan-GC parity (#8/#13),
|
||||
mirror controls (BUD-04), optional `/media` for thumbnails (BUD-05).
|
||||
- **Phase 3 — Extras.** DM attachments (only with a private story), BUD-09
|
||||
reporting, richer multi-server health/status.
|
||||
|
||||
## Risks / watch-items
|
||||
|
||||
- **Server availability/churn** → mitigated by multi-server + mirror (D2); surface
|
||||
per-server status.
|
||||
- **Spec still evolving** (BUD-11/12 refactor is recent) → the SDK tracks it; pin
|
||||
the exact version (like bitcoin-connect) and wrap it behind our thin service.
|
||||
- **Large videos = one `PUT`** (no S3 multipart); some servers cap size → BUD-06
|
||||
preflight + fall over to another server; surface clear size errors.
|
||||
- **Server CORS** is the operator's job, not the client's — a misconfigured server
|
||||
blocks browser upload; detect + suggest known-good servers.
|
||||
- **Signer prompts** on multi-server → reuse one auth across the mirror set.
|
||||
- **Bundle** — lazy-import the SDK only when a Blossom upload actually runs.
|
||||
|
||||
## Sources
|
||||
|
||||
- Spec + BUDs: `github.com/hzrd149/blossom` (BUD-01/02/03/04/05/06/11/12).
|
||||
- `github.com/hzrd149/blossom-client-sdk` (v5): `createUploadAuth`, `uploadBlob`,
|
||||
`mirrorBlob`, `multiServerUpload`, `multiServerMediaUpload`, `listBlobs`,
|
||||
`getServersFromServerListEvent`, `USER_BLOSSOM_SERVER_LIST_KIND`,
|
||||
`encodeAuthorizationHeader`.
|
||||
- NIP-B7 (Blossom media in notes), NIP-40 (expiration), NIP-98 (HTTP auth lineage).
|
||||
- bitvid: `js/ui/components/mediaUploader.js` (uploadFile seam),
|
||||
`js/services/{r2Service,s3UploadService}.js`, `js/ui/profileModal/{ProfileStorageController,storageConnections}.js`,
|
||||
`components/profile-modal.html` (Storage pane), `docs/bitcoin-connect-plan.md`
|
||||
(flag + vendor pattern), TODO #30.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Blossom videos — WebTorrent metadata via a companion Nostr event — Dev Plan
|
||||
|
||||
Status: **DECISIONS LOCKED (D1–D6, per recommendations, 2026-07-10)** — ready to
|
||||
build. Restores P2P/WebTorrent playback for Blossom-hosted videos without hosting a
|
||||
`.torrent` file, by publishing the torrent piece-map as a separate, infohash-keyed
|
||||
Nostr event that bitvid fetches lazily only when the torrent path actually runs.
|
||||
|
||||
Depends on: `docs/blossom-plan.md` (Blossom storage) and the existing WebTorrent
|
||||
playback path (`js/webtorrent.js`, `js/services/playbackService.js`).
|
||||
|
||||
## Executive summary
|
||||
|
||||
Blossom servers accept media types only and reject a `.torrent`
|
||||
(`application/x-bittorrent` → `415 "File type not allowed"`, verified live). A magnet
|
||||
therefore ships with a webseed (`ws=` = the Blossom URL) but **no `xs=`** (no hosted
|
||||
`.torrent`). WebTorrent cannot start from a webseed alone — it needs the torrent
|
||||
**metadata** (the `info` dict / piece map) from either the hosted `.torrent` or a live
|
||||
peer. So Blossom videos currently publish **URL-only** (they play via direct HTML5,
|
||||
which works — verified: `video/mp4`, CORS `*`, Range `206` through blossom.band's
|
||||
`307`→`video.nostr.build`).
|
||||
|
||||
This plan restores P2P by carrying the piece-map **on Nostr**: at upload bitvid already
|
||||
generates the `.torrent` (that's how it gets the infohash), so it publishes that
|
||||
`.torrent` as a **companion event addressed by infohash**. bitvid fetches it **only when
|
||||
a viewer starts the torrent path**, verifies it, then `client.add(torrentBuffer, {
|
||||
urlList:[videoUrl] })` — WebTorrent now has the map and streams the bytes from the
|
||||
Blossom webseed over HTTP Range.
|
||||
|
||||
Why a companion event rather than inlining the blob in the video event: the constraint
|
||||
that bites bitvid is the **feed**, not a single note. bitvid pulls hundreds of video
|
||||
events per feed load and >90% are watched via direct URL and never touch the torrent
|
||||
path. Inlining taxes every feed event for a feature most playbacks never use. A separate,
|
||||
lazily-fetched event keeps the feed lean, removes any practical size cap (the blob is off
|
||||
the feed), and is nostr-idiomatic: the metadata is **content-addressed by its own hash**.
|
||||
|
||||
## Background — the graceful ladder
|
||||
|
||||
For a video with a valid infohash, bitvid picks the best available P2P story:
|
||||
|
||||
1. **`.torrent` hosted (`xs=`)** — R2/S3, or a self-hosted/paid Blossom server that
|
||||
accepts `.torrent`. Full magnet, playable in any torrent client. *(unchanged today)*
|
||||
2. **Companion event** — Blossom URL-only case: `ws=` magnet + an infohash-keyed
|
||||
metadata event. Playable **in bitvid** (which knows to do the lookup). *(this plan)*
|
||||
3. **URL-only** — flag off, over cap, or publish failed. Direct HTML5 playback.
|
||||
*(current Blossom behavior; also the fallback for 1 and 2)*
|
||||
|
||||
The webseed (`ws=`) URL is also just the plain video URL, so **direct playback always
|
||||
works** regardless of the torrent story — P2P is strictly additive.
|
||||
|
||||
## Applicability across media types
|
||||
|
||||
The trigger keys on **(a valid infohash + a hosted webseed URL)**, not on the "video"
|
||||
label — so it is inherited by any media that flows through bitvid's upload + torrent seam
|
||||
(`blossomService.uploadVideo`, i.e. "upload a media blob → generate a `.torrent`"), and
|
||||
skipped by anything that doesn't.
|
||||
|
||||
| Type | Inherits it? | Why |
|
||||
|---|---|---|
|
||||
| **Regular video** | ✅ yes | The seam itself. |
|
||||
| **Shorts** | ✅ yes, free | A Short is a regular video with `format:"short"` on the same upload+torrent path (`docs/shorts-plan.md` keeps "bitvid's WebTorrent advantage"). Uploaded to Blossom ⇒ same companion. |
|
||||
| **Audio — publish** (`FEATURE_AUDIO_PUBLISH`, later) | ✅ yes, if it reuses the seam | WebTorrent is file-type-agnostic — an audio blob torrents/webseeds exactly like video. As long as audio *upload* routes through the shared upload+torrent path, no extra work. |
|
||||
| **Audio — ingest** (`FEATURE_AUDIO_INGEST`) | ❌ no | Surfaces *others'* externally-hosted audio; bitvid uploads nothing, so there is no infohash/webseed to describe. |
|
||||
| **Live streams** (`FEATURE_LIVE_INGEST`) | ❌ no | An HLS `.m3u8` stream isn't a static file — no fixed byte layout, no deterministic infohash, nothing on Blossom to seed. A **saved VOD later re-uploaded as a normal video** would qualify as a regular video, not as "the live stream." |
|
||||
|
||||
Design implication: keep the torrent + companion orchestration at the **generic
|
||||
media-blob seam** (it already is — `uploadVideo` is "upload blob + optional torrent",
|
||||
just video-flavored in name), so new *upload-based* types inherit it for free and
|
||||
*ingest/stream* types are correctly excluded. No per-type wiring, no per-type schema — the
|
||||
companion event is infohash-keyed and media-type-agnostic (`d = bitvid:torrent:<infohash>`).
|
||||
|
||||
## Why not NIP-35 (kinds 2003/2004)?
|
||||
|
||||
Nostr already has a torrent NIP — **NIP-35**: `kind 2003` (torrent) + `kind 2004`
|
||||
(torrent comment). It carries a torrent's **`x` (infohash)**, `title`, `file` list,
|
||||
`tracker` list, external-DB `i` references, and category `t` tags. It is deliberately
|
||||
**not** used here, because it solves a *different* problem:
|
||||
|
||||
- NIP-35 is a torrent **announcement / index** format (a Nostr-native tracker listing).
|
||||
It records metadata *about* a torrent plus the infohash — but **not the piece hashes /
|
||||
the bencoded `info` dict / the `.torrent` bytes**. A client that has only a NIP-35 event
|
||||
still has to obtain the actual torrent metadata from **peers / DHT / trackers**.
|
||||
- Our problem is exactly that a fresh Blossom video has **no seeders and no hosted
|
||||
`.torrent`**, so there is nowhere to fetch the piece map from. The companion event exists
|
||||
to **transport the piece map itself** (the full `.torrent`, base64) so WebTorrent can
|
||||
reconstruct the torrent and stream from the webseed with **zero peers**. NIP-35 omits the
|
||||
one field that makes that possible.
|
||||
|
||||
So NIP-35 can't replace the companion event for *playback*. The two are complementary: this
|
||||
plan handles playback bootstrap; a `kind 2003` could be layered on later purely for
|
||||
**cross-client discovery** (see Phase 3) without changing this mechanism. Caveat for that
|
||||
future work: bitvid's webseed-first, no-seeder model doesn't map cleanly onto NIP-35's
|
||||
"here's a torrent to download" expectation, and emitting a 2003 per video risks noise —
|
||||
so treat it as opt-in reach, not a default.
|
||||
|
||||
## Decisions
|
||||
|
||||
> **DECISION 1 — Event kind + addressing. ✅ LOCKED.**
|
||||
> Reuse **NIP-78 `kind:30078`** (bitvid already uses it for `bitvid:storage-connections`)
|
||||
> as a parameterized-replaceable event with `d = "bitvid:torrent:<infohash>"` (infohash
|
||||
> lowercase hex). bitvid derives the key from the magnet's `btih` and queries
|
||||
> `authors:[videoAuthor], kinds:[30078], #d:["bitvid:torrent:<infohash>"]`.
|
||||
> _Rationale: no new kind to define; matches bitvid's existing 30078 namespacing; keyed
|
||||
> by infohash ⇒ self-verifying and content-addressed. A **dedicated kind** for
|
||||
> cross-client discoverability is a future option (D6/Phase 3), not MVP._
|
||||
|
||||
> **DECISION 2 — Payload. ✅ LOCKED.**
|
||||
> `content` is a small JSON envelope `{"v":1,"infohash":"<hex>","torrent":"<base64>"}`
|
||||
> where `torrent` is the **full `.torrent` file bytes, base64** (preserves the infohash
|
||||
> exactly; `client.add` accepts the raw buffer). Tags: `["d","bitvid:torrent:<infohash>"]`,
|
||||
> `["x","<infohash>"]` (NIP-35/94 convention), `["client","bitvid"]`, and a back-reference
|
||||
> `["e","<videoEventId>"]` for debug/discovery (not used for lookup).
|
||||
> _Rationale: raw `.torrent` = zero reconstruction risk to the infohash; the `v` envelope
|
||||
> gives forward-compat; the `x` tag makes it conventionally queryable._
|
||||
|
||||
> **DECISION 3 — Publish trigger. ✅ LOCKED.**
|
||||
> Publish the companion **only** when: the video has a valid infohash **and** the
|
||||
> `.torrent` could **not** be hosted (`torrentUrl === ""`, i.e. the Blossom URL-only case)
|
||||
> **and** `FEATURE_BLOSSOM_TORRENT_METADATA` is on **and** the base64 payload ≤ the cap
|
||||
> (D4). R2/S3 and `.torrent`-accepting Blossom servers keep using `xs=` and publish no
|
||||
> companion.
|
||||
> _Rationale: the companion exists to cover exactly the gap Blossom creates; never
|
||||
> duplicate what `xs=` already provides._
|
||||
|
||||
> **DECISION 4 — Size cap (safety, not the feed gate). ✅ LOCKED.**
|
||||
> Gate on the **actual base64 payload size**, not a file-size proxy (metadata scales
|
||||
> sub-linearly with file size as WebTorrent grows the piece length; the encoded size is
|
||||
> known exactly at upload). Default cap **65536 bytes (64 KB)** base64 → URL-only above
|
||||
> it. A companion event ~this size is off the feed, so the cap is a pathological-case
|
||||
> guard, not a feature limiter (metadata plateaus ~20–40 KB even for multi-GB videos).
|
||||
> _Rationale: exact, robust, and generous enough to cover realistic videos._
|
||||
|
||||
> **DECISION 5 — Playback: lazy fetch + verify + trusted webseed. ✅ LOCKED.**
|
||||
> On the torrent path, when the magnet has `btih` but no usable `xs=`: query the companion
|
||||
> by infohash (author-scoped in MVP), base64-decode, **verify the reconstructed torrent's
|
||||
> infohash === the magnet's `btih`**, then `client.add(buffer, { urlList:[videoUrl],
|
||||
> announce: WSS_TRACKERS })`. The **webseed URL comes from the trusted video event's `url`
|
||||
> field** — bitvid **ignores any url-list baked into the `.torrent`**. Cache by infohash.
|
||||
> Any miss/mismatch/timeout ⇒ URL-only fallback (already the current behavior).
|
||||
> _Rationale: content from other users is public + untrusted; the infohash check makes
|
||||
> tampering un-actionable, and sourcing the webseed from the video event prevents a
|
||||
> malicious companion from redirecting the byte source._
|
||||
|
||||
> **DECISION 6 — Config flag + scope. ✅ LOCKED.**
|
||||
> `FEATURE_BLOSSOM_TORRENT_METADATA` in `config/instance-config.js` (default **false**),
|
||||
> wired like `FEATURE_BLOSSOM_STORAGE` / `FEATURE_BITCOIN_CONNECT`. Off ⇒ no companion is
|
||||
> published and the playback lookup is skipped (Blossom stays URL-only). MVP is
|
||||
> **author-scoped** lookup (same pubkey as the video). Crowd-sourced metadata (accept a
|
||||
> valid companion from **any** author, since it's self-verifying) and a dedicated,
|
||||
> cross-client kind are **Phase 3**.
|
||||
|
||||
## Config flag (off = no trace)
|
||||
|
||||
- `FEATURE_BLOSSOM_TORRENT_METADATA` (default **false**) — gates both the publish side
|
||||
(no companion event) and the playback lookup (skip the query). Off ⇒ Blossom behaves
|
||||
exactly as it does today (URL-only). Constant `BLOSSOM_TORRENT_METADATA_MAX_BASE64 =
|
||||
65536` lives in `js/constants.js`.
|
||||
|
||||
## Event shape
|
||||
|
||||
```jsonc
|
||||
// kind 30078 (NIP-78), parameterized-replaceable, signed by the video author
|
||||
{
|
||||
"kind": 30078,
|
||||
"tags": [
|
||||
["d", "bitvid:torrent:<infohash-lowercase-hex>"],
|
||||
["x", "<infohash-lowercase-hex>"],
|
||||
["client", "bitvid"],
|
||||
["e", "<video-event-id>"] // back-reference, debug/discovery only
|
||||
],
|
||||
"content": "{\"v\":1,\"infohash\":\"<hex>\",\"torrent\":\"<base64 .torrent bytes>\"}"
|
||||
}
|
||||
```
|
||||
|
||||
Builder lives in `js/nostrEventSchemas.js` (source of truth) as
|
||||
`buildTorrentMetadataEvent({ pubkey, created_at, infoHash, torrentFileBytes })`, alongside
|
||||
the existing builders.
|
||||
|
||||
## Publish flow (Phase 1)
|
||||
|
||||
In `blossomService.uploadVideo`, inside the existing `if (torrent?.hasValidInfoHash …)`
|
||||
block, extend the "`.torrent` not hosted" branch:
|
||||
|
||||
1. Compute `b64 = base64(torrent.torrentFile bytes)`.
|
||||
2. If `FEATURE_BLOSSOM_TORRENT_METADATA` and `b64.length ≤ MAX`: build the companion event
|
||||
(schema above), sign via the active signer, publish to the user's write relays (reuse
|
||||
the existing publish helper), and set the result to the **companion tier**:
|
||||
`magnet = magnet:?xt=urn:btih:<infoHash>&dn=<name>&ws=<videoUrl>` (no `xs=`),
|
||||
`hasValidInfoHash = true`, `infoHash = <hex>`, `torrentUrl = ""`.
|
||||
3. Else (flag off / over cap / publish failed): stay **URL-only** as today
|
||||
(`magnet = ""`, `hasValidInfoHash = false`). The local `.torrent` is still retained on
|
||||
`result.torrentFile`.
|
||||
|
||||
Orchestration note: `blossomService` stays WebTorrent-free; the companion publish uses an
|
||||
injected `publishEvent`/signer callback (same injection pattern as `generateTorrent`), so
|
||||
the service remains unit-testable in Node.
|
||||
|
||||
## Playback flow (Phase 2)
|
||||
|
||||
Add a `torrentMetadataService` (fetch/verify/cache) consumed by the WebTorrent add path
|
||||
(`js/webtorrent.js`) or `playbackService`:
|
||||
|
||||
1. Torrent path selected; magnet has `btih:<infohash>` and no usable `xs=`.
|
||||
2. Flag on → `getTorrentMetadata({ infoHash, author })`: relay query
|
||||
`kinds:[30078], authors:[author], #d:["bitvid:torrent:<infohash>"]`, newest wins.
|
||||
Cache the decoded buffer by infohash (memory; optional IndexedDB later).
|
||||
3. Decode envelope → base64-decode → `.torrent` buffer → parse → **assert infohash ===
|
||||
btih** (reject + log on mismatch).
|
||||
4. `client.add(buffer, { urlList: [videoUrl /* from the video event */], announce:
|
||||
WSS_TRACKERS })`. WebTorrent has the map; the webseed serves bytes over Range.
|
||||
5. Any failure ⇒ URL-only (unchanged fallback). Never block first paint: URL-first plays
|
||||
immediately while this runs in the background.
|
||||
|
||||
## Security
|
||||
|
||||
- **Untrusted content:** verify reconstructed infohash === `btih` before use; on mismatch,
|
||||
discard and fall back. The infohash is the query key *and* the integrity check.
|
||||
- **Webseed source:** always the video event's own `url`; never the `.torrent`'s embedded
|
||||
url-list — prevents byte-source redirection by a malicious companion.
|
||||
- **Size guard:** refuse to decode a `content` whose base64 exceeds the cap.
|
||||
- **Author scope (MVP):** query only the video's author, so no cross-author injection.
|
||||
Crowd-sourcing (Phase 3) stays safe *because* it's self-verifying, but is opt-in later.
|
||||
- **No new signing surface:** reuse the active signer adapter; respect its circuit-breaker.
|
||||
|
||||
## Phases (flag-gated from day one)
|
||||
|
||||
- **Phase 0 — Flag + schema.** `FEATURE_BLOSSOM_TORRENT_METADATA` wired through
|
||||
instance-config → config → constants; `BLOSSOM_TORRENT_METADATA_MAX_BASE64`;
|
||||
`buildTorrentMetadataEvent` in `nostrEventSchemas.js` + docs in
|
||||
`docs/nostr-event-schemas.md`. No behavior change.
|
||||
- **Phase 1 — Publish.** Companion publish in the Blossom URL-only branch (D3/D4); promote
|
||||
Blossom to the **companion tier** magnet when published. Unit tests: builder round-trip,
|
||||
cap gate, tier selection.
|
||||
- **Phase 2 — Playback.** `torrentMetadataService` (fetch/verify/cache) + wire into the
|
||||
WebTorrent add path with trusted-webseed sourcing and URL-only fallback. Unit tests:
|
||||
infohash-mismatch rejection, webseed sourcing, cache hit. Live check: webseed follows the
|
||||
Blossom `307` redirect.
|
||||
- **Phase 3 — Reach (later).** Crowd-sourced lookup (any author), a dedicated cross-client
|
||||
kind, republish-on-mirror, IndexedDB metadata cache, and an **optional NIP-35 `kind 2003`
|
||||
companion for cross-client discovery** (complementary to — not a replacement for — the
|
||||
piece-map event; see "Why not NIP-35" above).
|
||||
|
||||
## Test scenarios (scenario-first; see Dark Factory rules)
|
||||
|
||||
- **Builder round-trip:** a `.torrent` buffer → `buildTorrentMetadataEvent` → event with
|
||||
`d=bitvid:torrent:<infohash>`, `x` tag, and `content.torrent` base64 that decodes back to
|
||||
the exact input bytes.
|
||||
- **Cap gate:** payload > `MAX` ⇒ no companion, `hasValidInfoHash=false` (URL-only);
|
||||
≤ `MAX` ⇒ companion built, `hasValidInfoHash=true`, `ws=`-only magnet, no `xs=`.
|
||||
- **Verify — reject tampering:** a fetched companion whose reconstructed infohash ≠ `btih`
|
||||
⇒ rejected, URL-only fallback taken.
|
||||
- **Webseed sourcing:** even when the `.torrent` embeds url-list `X`, the add path uses the
|
||||
video event's `url` as the sole webseed.
|
||||
- **Round-trip discovery:** publish (mocked relay) → fetch by infohash → reconstruct →
|
||||
infohash matches → `client.add` invoked with the trusted `urlList`.
|
||||
|
||||
## Risks / watch-items
|
||||
|
||||
- **Companion availability** — if it isn't on the queried relays, P2P is skipped and the
|
||||
video still plays URL-only. Publish to the user's write relays; consider republish on
|
||||
re-open (Phase 3).
|
||||
- **Extra signer prompt at upload** — one more sign (the companion) after the video event.
|
||||
Reuse the live session; acceptable. Surface it in the upload progress copy.
|
||||
- **Standalone-client incompatibility** — a `ws=`-only magnet is playable **in bitvid**
|
||||
(which does the lookup), not in a generic torrent client. Guaranteed generic P2P needs a
|
||||
hosted `.torrent` (`xs=`, tier 1). Accepted tradeoff.
|
||||
- **Webseed `307` redirect** — WebTorrent's HTTP webseed must follow blossom.band →
|
||||
video.nostr.build; browser `fetch` does, but verify live in Phase 2.
|
||||
- **Very large / pathological piece counts** — metadata may exceed the 64 KB cap ⇒
|
||||
URL-only fallback (correct, not a failure). Optional future lever: raise the generated
|
||||
piece length to shrink metadata (harmless with a webseed serving pieces over Range).
|
||||
|
||||
## Rollback
|
||||
|
||||
Flip `FEATURE_BLOSSOM_TORRENT_METADATA = false`: no companion is published and the
|
||||
playback lookup is skipped — Blossom instantly reverts to URL-only. Already-published
|
||||
companion events are inert (bitvid won't query them); they're standard replaceable 30078
|
||||
events the author can overwrite/delete via normal Nostr means.
|
||||
@@ -197,8 +197,11 @@ 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 |
|
||||
| Torrent metadata (`buildTorrentMetadataEvent`) | `30078` (addressable, `d = bitvid:torrent:<infohash>`) | `['d', 'bitvid:torrent:<infohash>']`, `['x', <infohash>]`, `['client', 'bitvid']`, optional `['e', <video event id>]` | JSON envelope `{ v:1, infohash:<hex>, torrent:<base64 .torrent bytes> }`. The WebTorrent piece-map for a Blossom-hosted video (whose magnet is webseed-only because Blossom rejects `.torrent`). Fetched lazily on the torrent path and **verified: reconstructed infohash must equal the magnet's `btih`**; the webseed URL comes from the video event, never the embedded url-list. Gated by `FEATURE_BLOSSOM_TORRENT_METADATA`. See `docs/blossom-torrent-metadata-plan.md`. |
|
||||
|
||||
### Share notes (kind `1`)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||
FEED_TYPES,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
} from "./constants.js";
|
||||
import torrentMetadataService from "./services/torrentMetadataService.js";
|
||||
import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js";
|
||||
import { collectVideoTags } from "./utils/videoTags.js";
|
||||
import { normalizeHashtag } from "./utils/hashtagNormalization.js";
|
||||
@@ -102,6 +104,8 @@ import {
|
||||
import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js";
|
||||
import { rankHashtagsByFrequency } from "./utils/hashtagSuggestions.js";
|
||||
import { launchBitvidTour } from "./ui/onboarding/bitvidTour.js";
|
||||
import { shouldOfferOnboarding } from "./services/onboardingService.js";
|
||||
import { showConfirm } from "./ui/confirmDialog.js";
|
||||
import {
|
||||
decideSignerEnsure,
|
||||
isSignerCapable,
|
||||
@@ -175,6 +179,10 @@ import {
|
||||
setModerationOverride,
|
||||
clearModerationOverride,
|
||||
loadModerationOverridesFromStorage,
|
||||
getAuthorModerationOverridesList,
|
||||
setAuthorModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
loadAuthorModerationOverridesFromStorage,
|
||||
getModerationSettings,
|
||||
getDefaultModerationSettings,
|
||||
setModerationSettings,
|
||||
@@ -636,6 +644,11 @@ class Application {
|
||||
userLogger,
|
||||
nostrClient,
|
||||
torrentClient,
|
||||
// Torrent piece-map lookup for Blossom videos — passed only when the flag
|
||||
// is on, so its presence in the coordinator IS the gate.
|
||||
torrentMetadataService: FEATURE_BLOSSOM_TORRENT_METADATA
|
||||
? torrentMetadataService
|
||||
: null,
|
||||
emit,
|
||||
accessControl,
|
||||
isValidMagnetUri,
|
||||
@@ -916,6 +929,7 @@ class Application {
|
||||
}
|
||||
|
||||
loadModerationOverridesFromStorage();
|
||||
loadAuthorModerationOverridesFromStorage();
|
||||
loadModerationSettingsFromStorage();
|
||||
loadDmPrivacySettingsFromStorage();
|
||||
this.moderationSettings = this.normalizeModerationSettings(
|
||||
@@ -1759,13 +1773,40 @@ class Application {
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
// Best-effort, never blocks login: offer to restore encrypted settings
|
||||
// synced from another device.
|
||||
this.maybeOfferSettingsRestore(pubkey);
|
||||
// Order matters: offer the tour FIRST so it opens the "tour gate"
|
||||
// synchronously when it will run. The settings-restore prompt then waits
|
||||
// on that gate before showing its dialog, so it can't pop over the tour.
|
||||
this.maybeOfferOnboardingTour(pubkey);
|
||||
this.maybeOfferSettingsRestore(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// A gate the settings-restore prompt awaits so its confirmation dialog never
|
||||
// pops over the onboarding tour. Opened synchronously when the tour will run,
|
||||
// resolved when the tour finishes/exits (or fails to start).
|
||||
_openTourGate() {
|
||||
if (this._tourGateResolve) {
|
||||
return;
|
||||
}
|
||||
this._tourGate = new Promise((resolve) => {
|
||||
this._tourGateResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
_closeTourGate() {
|
||||
const resolve = this._tourGateResolve;
|
||||
this._tourGateResolve = null;
|
||||
this._tourGate = null;
|
||||
if (typeof resolve === "function") {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves immediately unless the onboarding tour is (about to be) active.
|
||||
whenOnboardingTourInactive() {
|
||||
return this._tourGate || Promise.resolve();
|
||||
}
|
||||
|
||||
// First login of this pubkey on this device: offer the guided tour
|
||||
// (docs/onboarding-plan.md). Deferred so the feed has rendered and the
|
||||
// settings-restore prompt (2s window; only fires for accounts with synced
|
||||
@@ -1775,14 +1816,26 @@ class Application {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
// If the tour will actually run, open the gate NOW (synchronously) so the
|
||||
// settings-restore prompt waits — even though the tour itself starts on a
|
||||
// delay. If it won't run, leave the gate closed so restore proceeds normally.
|
||||
if (!shouldOfferOnboarding(key)) {
|
||||
return;
|
||||
}
|
||||
this._openTourGate();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (!this.isUserLoggedIn()) {
|
||||
this._closeTourGate();
|
||||
return;
|
||||
}
|
||||
this.startOnboardingTour({ pubkey: key, force: false });
|
||||
const started = this.startOnboardingTour({ pubkey: key, force: false });
|
||||
if (!started) {
|
||||
this._closeTourGate();
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[Application] Onboarding tour offer failed:", error);
|
||||
this._closeTourGate();
|
||||
}
|
||||
}, 3500);
|
||||
}
|
||||
@@ -1795,7 +1848,7 @@ class Application {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
return launchBitvidTour({
|
||||
const started = launchBitvidTour({
|
||||
pubkey: key,
|
||||
force,
|
||||
openProfilePane: (pane) => {
|
||||
@@ -1805,7 +1858,13 @@ class Application {
|
||||
devLogger.warn("[Application] Tour deep-link failed:", error);
|
||||
}
|
||||
},
|
||||
// Release the settings-restore prompt once the tour is done/exited.
|
||||
onEnd: () => this._closeTourGate(),
|
||||
});
|
||||
if (!started) {
|
||||
this._closeTourGate();
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
// One-time offer to restore encrypted storage/wallet settings on login (todo
|
||||
@@ -1826,6 +1885,13 @@ class Application {
|
||||
walletSync: createWalletSyncService({
|
||||
nwcSettings: this.nwcSettingsService,
|
||||
}),
|
||||
// Never show the restore dialog over the onboarding tour — wait for
|
||||
// it to finish/exit first. (The remote existence check still runs in
|
||||
// parallel; only the confirmation dialog is held back.)
|
||||
confirm: async (message) => {
|
||||
await this.whenOnboardingTourInactive();
|
||||
return showConfirm(message, { confirmLabel: "Restore" });
|
||||
},
|
||||
});
|
||||
}
|
||||
Promise.resolve(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createPlaybackCoordinator(deps) {
|
||||
userLogger,
|
||||
nostrClient,
|
||||
torrentClient,
|
||||
torrentMetadataService,
|
||||
emit,
|
||||
accessControl,
|
||||
isValidMagnetUri,
|
||||
@@ -382,6 +383,52 @@ export function createPlaybackCoordinator(deps) {
|
||||
? hostedUrlWebSeed.trim()
|
||||
: "";
|
||||
|
||||
// Blossom videos ship a webseed-only magnet (no xs=). When the piece-map
|
||||
// lookup is wired (flag on) and the magnet lacks xs=, fetch the verified
|
||||
// .torrent from the video's infohash-keyed companion event so WebTorrent has
|
||||
// metadata to bootstrap from. Best-effort + verified upstream: null ⇒ the
|
||||
// bare magnet is used (may still work via peers, else the URL fallback runs).
|
||||
let companionTorrentBytes = null;
|
||||
if (
|
||||
torrentMetadataService &&
|
||||
typeof extractBtihFromMagnet === "function" &&
|
||||
typeof magnet === "string" &&
|
||||
!/[?&]xs=/.test(magnet)
|
||||
) {
|
||||
const infoHash = extractBtihFromMagnet(magnet);
|
||||
const author =
|
||||
this.currentVideo && typeof this.currentVideo.pubkey === "string"
|
||||
? this.currentVideo.pubkey
|
||||
: "";
|
||||
if (infoHash && author) {
|
||||
try {
|
||||
const relays =
|
||||
Array.isArray(nostrClient?.readRelays) &&
|
||||
nostrClient.readRelays.length
|
||||
? nostrClient.readRelays
|
||||
: nostrClient?.relays || [];
|
||||
const resolved = await torrentMetadataService.fetch({
|
||||
infoHash,
|
||||
author,
|
||||
// Route relay reads through the subscription manager (one-shot,
|
||||
// deduped, timeout-bounded) — never pool.list directly.
|
||||
queryEvents: (filter) =>
|
||||
nostrClient
|
||||
.getSubscriptionManager()
|
||||
.list({ filters: [filter], relays }),
|
||||
});
|
||||
if (resolved?.torrentBytes) {
|
||||
companionTorrentBytes = resolved.torrentBytes;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger?.warn?.(
|
||||
"[playback] companion torrent-metadata lookup failed:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStream = async (candidate) => {
|
||||
const trimmedCandidate =
|
||||
typeof candidate === "string" ? candidate.trim() : "";
|
||||
@@ -429,6 +476,9 @@ export function createPlaybackCoordinator(deps) {
|
||||
{
|
||||
urlList: sanitizedUrlList,
|
||||
hostedUrlWebSeed: sanitizedHostedWebSeed,
|
||||
// Verified piece-map from the companion event (Blossom videos); when
|
||||
// present, streamVideo adds this buffer instead of the bare magnet.
|
||||
torrentFileBytes: companionTorrentBytes,
|
||||
hooks: {
|
||||
// Surface a mid-stream stall instead of silently freezing on a
|
||||
// buffering frame: tell the user whether the swarm is just slow
|
||||
|
||||
@@ -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;
|
||||
+128
-30
@@ -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) {
|
||||
@@ -5526,46 +5539,124 @@ async function loadUserVideos(pubkey) {
|
||||
// 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71.
|
||||
const filters = buildChannelVideoFilters(pubkey);
|
||||
|
||||
// 2) Collect raw events from all relays
|
||||
// Accumulate raw events across every source (deduped by id) so the grid can
|
||||
// paint PROGRESSIVELY as each relay/source returns, instead of blocking on
|
||||
// the slowest relay's 8s timeout.
|
||||
const events = [];
|
||||
const seenEventIds = new Set();
|
||||
const addEvents = (list) => {
|
||||
let added = 0;
|
||||
for (const evt of Array.isArray(list) ? list : []) {
|
||||
if (evt && evt.id && !seenEventIds.has(evt.id)) {
|
||||
seenEventIds.add(evt.id);
|
||||
events.push(evt);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
// Serialized, coalesced re-render. Mid-flight renders NEVER pass
|
||||
// allowEmptyMessage, so a partial/slow fetch can't flash "No videos" — that
|
||||
// message is reserved for the single final render below.
|
||||
let renderInFlight = null;
|
||||
let renderPending = false;
|
||||
const scheduleRender = () => {
|
||||
renderPending = true;
|
||||
if (renderInFlight) {
|
||||
return renderInFlight;
|
||||
}
|
||||
renderInFlight = (async () => {
|
||||
while (renderPending) {
|
||||
renderPending = false;
|
||||
if (loadToken !== currentVideoLoadToken || !container) {
|
||||
break;
|
||||
}
|
||||
const partial = buildRenderableChannelVideos({
|
||||
events: mergeChannelVideoSources(
|
||||
events,
|
||||
getCachedChannelVideoEvents(pubkey)
|
||||
),
|
||||
app,
|
||||
});
|
||||
const paintedNow = await renderChannelVideosFromList({
|
||||
videos: partial,
|
||||
container,
|
||||
app,
|
||||
loadToken,
|
||||
allowEmptyMessage: false,
|
||||
});
|
||||
if (paintedNow) {
|
||||
hasVisibleContent = true;
|
||||
renderedFromCache = true;
|
||||
}
|
||||
}
|
||||
renderInFlight = null;
|
||||
})();
|
||||
return renderInFlight;
|
||||
};
|
||||
|
||||
const knownRelays = Array.isArray(nostrClient.relays)
|
||||
? nostrClient.relays
|
||||
: Array.from(nostrClient.relays || []);
|
||||
const relayList = knownRelays.filter((url) => isValidRelayUrl(url));
|
||||
// ALWAYS include the bundled defaults. A creator's videos can live only on
|
||||
// a default relay (e.g. a NIP-71 video that's only on nos.lol) that the
|
||||
// user's capped NIP-65 read set excludes — otherwise a known, whitelisted
|
||||
// video never shows on the channel because we never query the one relay
|
||||
// that has it.
|
||||
const relayList = [
|
||||
...new Set([...knownRelays, ...DEFAULT_RELAY_URLS]),
|
||||
].filter((url) => isValidRelayUrl(url));
|
||||
|
||||
// Route one fetch through the app's PROVEN path (the same one "My Videos"
|
||||
// uses on refresh). It streams into the shared cache, so this grid, its
|
||||
// revisits, and every other grid all benefit — then re-render from it.
|
||||
const sharedFetch = (async () => {
|
||||
try {
|
||||
if (typeof app?.nostrService?.fetchVideosByAuthors === "function") {
|
||||
await app.nostrService.fetchVideosByAuthors([pubkey]);
|
||||
await scheduleRender();
|
||||
}
|
||||
} catch (error) {
|
||||
// best-effort; the scoped relay fetch below still populates the grid
|
||||
}
|
||||
})();
|
||||
|
||||
// Scoped relay fetch (also covers NIP-71 cross-posts), rendered
|
||||
// progressively as each relay returns rather than awaiting them all.
|
||||
if (relayList.length === 0) {
|
||||
try {
|
||||
const fallbackEvents = await listRelayWithTimeout(
|
||||
Array.from(DEFAULT_RELAY_URLS),
|
||||
filters
|
||||
);
|
||||
if (Array.isArray(fallbackEvents)) {
|
||||
events.push(...fallbackEvents);
|
||||
if (addEvents(fallbackEvents) > 0) {
|
||||
await scheduleRender();
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.error("Relay error (default pool):", error);
|
||||
}
|
||||
} else {
|
||||
const settled = await pMapSettled(
|
||||
await pMapSettled(
|
||||
relayList,
|
||||
(url) => listRelayWithTimeout([url], filters),
|
||||
async (url) => {
|
||||
try {
|
||||
const relayEvents = await listRelayWithTimeout([url], filters);
|
||||
if (addEvents(relayEvents) > 0) {
|
||||
await scheduleRender();
|
||||
}
|
||||
return relayEvents;
|
||||
} catch (error) {
|
||||
userLogger.error(`Relay error (${url}):`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
{ concurrency: RELAY_BACKGROUND_CONCURRENCY }
|
||||
);
|
||||
settled.forEach((result, index) => {
|
||||
const relayUrl = relayList[index];
|
||||
|
||||
if (result.status === "fulfilled") {
|
||||
const relayEvents = Array.isArray(result.value) ? result.value : [];
|
||||
events.push(...relayEvents);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.reason) {
|
||||
userLogger.error(`Relay error (${relayUrl}):`, result.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await sharedFetch;
|
||||
await renderInFlight;
|
||||
await ensureAccessPromise;
|
||||
|
||||
if (loadToken !== currentVideoLoadToken) {
|
||||
@@ -5577,21 +5668,28 @@ async function loadUserVideos(pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app });
|
||||
saveCachedChannelVideos(pubkey, videos);
|
||||
const rendered = await renderChannelVideosFromList({
|
||||
videos,
|
||||
container,
|
||||
// Persist the merged set. Only do a final render when nothing painted during
|
||||
// progressive rendering — that render carries the empty message, shown ONLY
|
||||
// now that every source has genuinely settled.
|
||||
const videos = buildRenderableChannelVideos({
|
||||
events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)),
|
||||
app,
|
||||
loadToken,
|
||||
allowEmptyMessage: !hasVisibleContent
|
||||
});
|
||||
saveCachedChannelVideos(pubkey, videos);
|
||||
|
||||
if (rendered) {
|
||||
hasVisibleContent = true;
|
||||
if (!hasVisibleContent) {
|
||||
const rendered = await renderChannelVideosFromList({
|
||||
videos,
|
||||
container,
|
||||
app,
|
||||
loadToken,
|
||||
allowEmptyMessage: true,
|
||||
});
|
||||
if (rendered) {
|
||||
hasVisibleContent = true;
|
||||
}
|
||||
renderedFromCache = rendered || renderedFromCache;
|
||||
}
|
||||
|
||||
renderedFromCache = rendered || renderedFromCache;
|
||||
} catch (err) {
|
||||
if (
|
||||
loadToken === currentVideoLoadToken &&
|
||||
|
||||
@@ -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;
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
ALLOW_NSFW_CONTENT,
|
||||
FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
CARD_LIVENESS_POLICY,
|
||||
LIVENESS_PROBE_PREFETCH_MARGIN,
|
||||
DEFAULT_TRUST_SEED_NPUBS,
|
||||
@@ -121,6 +125,10 @@ export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources";
|
||||
export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
|
||||
export { ALLOW_NSFW_CONTENT };
|
||||
export { FEATURE_NIP71_INGEST };
|
||||
export { FEATURE_AUDIO_INGEST };
|
||||
export { FEATURE_BITCOIN_CONNECT };
|
||||
export { FEATURE_BLOSSOM_STORAGE };
|
||||
export { FEATURE_BLOSSOM_TORRENT_METADATA };
|
||||
export { CARD_LIVENESS_POLICY };
|
||||
export { LIVENESS_PROBE_PREFETCH_MARGIN };
|
||||
export { DEFAULT_TRUST_SEED_NPUBS };
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
DEFAULT_PLAYBACK_SOURCE,
|
||||
DEFAULT_PLAYBACK_START_TIMEOUT,
|
||||
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
|
||||
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
|
||||
IS_DEV_MODE,
|
||||
@@ -194,6 +198,21 @@ const DEFAULT_FLAGS = Object.freeze({
|
||||
// Inbound ingest of NIP-71 videos published by other Nostr apps. Sourced from
|
||||
// the admin instance config so a deployer flips it in one place.
|
||||
FEATURE_NIP71_INGEST: CONFIG_FEATURE_NIP71_INGEST !== false,
|
||||
// Audio / Music / Podcast surfaces (docs/audio-integration-plan.md, TODO #60).
|
||||
// Default OFF ("off = no trace") — only an explicit `true` in the instance
|
||||
// config lights it up.
|
||||
FEATURE_AUDIO_INGEST: CONFIG_FEATURE_AUDIO_INGEST === true,
|
||||
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
|
||||
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
|
||||
// Blossom videos: publish/lookup the WebTorrent piece-map as a companion Nostr
|
||||
// event so P2P works without a hosted .torrent (docs/blossom-torrent-metadata-plan.md).
|
||||
// Default OFF; only an explicit `true` in the instance config enables it.
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA:
|
||||
CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA === true,
|
||||
FEATURE_HASHTAG_PREFERENCES: false,
|
||||
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: true,
|
||||
@@ -201,6 +220,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,
|
||||
@@ -218,6 +239,11 @@ const runtimeFlags = (() => {
|
||||
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
|
||||
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
|
||||
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
|
||||
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
|
||||
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
|
||||
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
|
||||
FEATURE_BLOSSOM_TORRENT_METADATA:
|
||||
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
|
||||
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
|
||||
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
|
||||
@@ -226,6 +252,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],
|
||||
@@ -261,6 +289,31 @@ export let FEATURE_NIP71_INGEST = coerceBoolean(
|
||||
DEFAULT_FLAGS.FEATURE_NIP71_INGEST
|
||||
);
|
||||
|
||||
export let FEATURE_AUDIO_INGEST = coerceBoolean(
|
||||
runtimeFlags.FEATURE_AUDIO_INGEST,
|
||||
DEFAULT_FLAGS.FEATURE_AUDIO_INGEST
|
||||
);
|
||||
|
||||
export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
|
||||
runtimeFlags.FEATURE_BITCOIN_CONNECT,
|
||||
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
|
||||
);
|
||||
|
||||
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
|
||||
runtimeFlags.FEATURE_BLOSSOM_STORAGE,
|
||||
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
|
||||
);
|
||||
|
||||
export let FEATURE_BLOSSOM_TORRENT_METADATA = coerceBoolean(
|
||||
runtimeFlags.FEATURE_BLOSSOM_TORRENT_METADATA,
|
||||
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA
|
||||
);
|
||||
|
||||
// Max base64 size of an embedded .torrent piece-map before bitvid falls back to
|
||||
// URL-only (the companion event is off the feed, so this is a pathological-case
|
||||
// guard, not a feature limiter). See docs/blossom-torrent-metadata-plan.md (D4).
|
||||
export const BLOSSOM_TORRENT_METADATA_MAX_BASE64 = 65536;
|
||||
|
||||
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
|
||||
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
|
||||
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
|
||||
@@ -281,6 +334,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 +661,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(
|
||||
|
||||
+8
-3
@@ -25,6 +25,7 @@ import {
|
||||
buildNip71MetadataTags,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo as mergeNip71MetadataIntoVideoHelper,
|
||||
@@ -4469,9 +4470,13 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
// Filter audio-only notes (podcasts/music) here too, not just at ingest:
|
||||
// video objects rehydrated from the persisted cache bypass
|
||||
// convertEventToVideo, so a note cached before the audio guard existed would
|
||||
// otherwise still reach the feed. See TODO #60.
|
||||
return Array.from(this.activeMap.values())
|
||||
.filter((video) => !isAudioOnlyVideoObject(video))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
// Optimistically add a just-published video event to the local caches so it
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
buildNip71VideoEvent,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventsCacheStore } from "./EventsCacheStore.js";
|
||||
import { isAudioOnlyVideoObject } from "../nip71.js";
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
import { isDevMode } from "../../config.js";
|
||||
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
|
||||
@@ -154,6 +155,12 @@ export class PersistenceManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop audio-only notes (podcasts/music) cached before the audio guard
|
||||
// existed so they don't get rehydrated into the feed. See TODO #60.
|
||||
if (isAudioOnlyVideoObject(video)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.applyRootCreatedAt(video);
|
||||
const activeKey = this.client.getActiveKey(video);
|
||||
|
||||
|
||||
@@ -1461,6 +1461,70 @@ function inferMimeTypeFromUrl(url) {
|
||||
* @param {import("nostr-tools").Event} event - The raw event.
|
||||
* @returns {Object} The normalized video object, or { invalid: true, reason: string }.
|
||||
*/
|
||||
// Inspect an event's `imeta` tags for declared media types. Native bitvid notes
|
||||
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
|
||||
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
|
||||
// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
|
||||
function detectImetaMediaKinds(tags = []) {
|
||||
let hasVideo = false;
|
||||
let hasAudio = false;
|
||||
for (const tag of Array.isArray(tags) ? tags : []) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "imeta") {
|
||||
continue;
|
||||
}
|
||||
for (let i = 1; i < tag.length; i += 1) {
|
||||
const entry = tag[i];
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!/^m\s+/i.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
|
||||
if (mime.startsWith("video/")) {
|
||||
hasVideo = true;
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
hasAudio = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasVideo, hasAudio };
|
||||
}
|
||||
|
||||
// Unambiguous audio-only file extensions (the fallback when an event carries no
|
||||
// `imeta m audio/*` hint). `.ogg` is deliberately excluded — it can be video
|
||||
// (Theora) and bitvid maps it to video/ogg — so `.ogg` is only treated as audio
|
||||
// when an `imeta m audio/*` tag says so. `.f4a`/`.m4b` are AAC-in-MP4 audio
|
||||
// (podcasts/audiobooks) — NOT `.mp4`/`.m4v`, which are video.
|
||||
const AUDIO_ONLY_URL_PATTERN =
|
||||
/\.(mp3|m4a|m4b|f4a|aac|wav|flac|opus|oga|weba|mka)(?:[?#]|$)/i;
|
||||
|
||||
// Whether a (converted) video object is an audio-only note — a podcast/music
|
||||
// note published as a native kind-30078. bitvid has no <audio> player, so these
|
||||
// render as a broken <video> and must be kept out of the video feeds. Operates
|
||||
// on a video object (has `url`, `magnet`, `tags`) so it works on BOTH freshly
|
||||
// converted events AND video objects rehydrated from the persisted cache (which
|
||||
// bypass convertEventToVideo). Audio-only = no magnet (a torrent could be video)
|
||||
// AND no `imeta m video/*` variant AND (an `imeta m audio/*` variant OR an
|
||||
// unambiguous audio URL). See TODO #60.
|
||||
export function isAudioOnlyVideoObject(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
const url = typeof video.url === "string" ? video.url.trim() : "";
|
||||
const magnet = typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
if (magnet) {
|
||||
return false;
|
||||
}
|
||||
const { hasVideo, hasAudio } = detectImetaMediaKinds(video.tags);
|
||||
if (hasVideo) {
|
||||
return false;
|
||||
}
|
||||
const urlLooksAudioOnly = url ? AUDIO_ONLY_URL_PATTERN.test(url) : false;
|
||||
return hasAudio || urlLooksAudioOnly;
|
||||
}
|
||||
|
||||
export function convertEventToVideo(event = {}) {
|
||||
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
@@ -1507,6 +1571,13 @@ export function convertEventToVideo(event = {}) {
|
||||
return { id: event.id, invalid: true, reason: "missing playable source" };
|
||||
}
|
||||
|
||||
// Keep audio-only notes (podcasts/music published as native video events) out
|
||||
// of the video feed: bitvid has no <audio> player, so they render as a broken
|
||||
// <video>. See TODO #60 for the future dedicated Audio tab.
|
||||
if (isAudioOnlyVideoObject({ url, magnet, tags })) {
|
||||
return { id: event.id, invalid: true, reason: "audio-only source (no video)" };
|
||||
}
|
||||
|
||||
const thumbnail = safeTrim(parsedContent.thumbnail);
|
||||
const description = safeTrim(parsedContent.description);
|
||||
const rawMode = safeTrim(parsedContent.mode);
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -2281,6 +2493,126 @@ export function buildViewEvent(params) {
|
||||
return event;
|
||||
}
|
||||
|
||||
// --- WebTorrent piece-map companion event (NIP-78 app data) ------------------
|
||||
// Restores P2P for Blossom-hosted videos (whose magnet is webseed-only because
|
||||
// Blossom rejects a .torrent) by publishing the torrent metadata as a separate,
|
||||
// infohash-keyed event that bitvid fetches lazily on the torrent path.
|
||||
// See docs/blossom-torrent-metadata-plan.md.
|
||||
export const TORRENT_METADATA_KIND = 30078;
|
||||
export const TORRENT_METADATA_DTAG_PREFIX = "bitvid:torrent:";
|
||||
export const TORRENT_METADATA_ENVELOPE_VERSION = 1;
|
||||
|
||||
// Normalize an infohash to the lowercase-hex form used in the addressing key.
|
||||
// BitTorrent v1 (WebTorrent) infohashes are 40 hex chars.
|
||||
export function normalizeInfoHash(infoHash) {
|
||||
const hex = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
|
||||
return /^[0-9a-f]{40}$/.test(hex) ? hex : "";
|
||||
}
|
||||
|
||||
// The parameterized-replaceable `d`-tag value that addresses a video's piece-map.
|
||||
export function torrentMetadataDTag(infoHash) {
|
||||
const hex = normalizeInfoHash(infoHash);
|
||||
return hex ? `${TORRENT_METADATA_DTAG_PREFIX}${hex}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the WebTorrent piece-map companion event (kind 30078). The `content` is a
|
||||
* versioned JSON envelope carrying the full `.torrent` file bytes (base64); the
|
||||
* `info` dict inside is what determines the infohash, so storing the raw bytes
|
||||
* keeps the reconstructed infohash exact. Addressed by `d = bitvid:torrent:<hex>`
|
||||
* so bitvid can look it up from a magnet's `btih`.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.pubkey - Author (the video's author) hex pubkey.
|
||||
* @param {number} params.created_at - Unix seconds.
|
||||
* @param {string} params.infoHash - BitTorrent v1 infohash (40 hex chars).
|
||||
* @param {string} params.torrentBase64 - base64 of the full `.torrent` file bytes.
|
||||
* @param {string} [params.videoEventId] - The video event id (back-reference only).
|
||||
* @returns {Object} Unsigned event.
|
||||
*/
|
||||
export function buildTorrentMetadataEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
infoHash,
|
||||
torrentBase64,
|
||||
videoEventId = "",
|
||||
} = params || {};
|
||||
|
||||
const hex = normalizeInfoHash(infoHash);
|
||||
if (!hex) {
|
||||
throw new Error(
|
||||
"buildTorrentMetadataEvent requires a valid 40-hex-char infoHash."
|
||||
);
|
||||
}
|
||||
if (typeof torrentBase64 !== "string" || !torrentBase64) {
|
||||
throw new Error(
|
||||
"buildTorrentMetadataEvent requires a non-empty base64 torrent payload."
|
||||
);
|
||||
}
|
||||
|
||||
const tags = [
|
||||
["d", `${TORRENT_METADATA_DTAG_PREFIX}${hex}`],
|
||||
["x", hex],
|
||||
["client", "bitvid"],
|
||||
];
|
||||
if (typeof videoEventId === "string" && videoEventId.trim()) {
|
||||
tags.push(["e", videoEventId.trim()]);
|
||||
}
|
||||
|
||||
const content = JSON.stringify({
|
||||
v: TORRENT_METADATA_ENVELOPE_VERSION,
|
||||
infohash: hex,
|
||||
torrent: torrentBase64,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: TORRENT_METADATA_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: ensureValidUtf8Content(content),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Blossom server list (BUD-03, kind 10063) --------------------------------
|
||||
// A user's preferred Blossom media servers, in order. Lets other clients discover
|
||||
// where a user hosts blobs, and lets bitvid pre-fill its storage pane from it.
|
||||
export const BLOSSOM_SERVER_LIST_KIND = 10063;
|
||||
|
||||
/**
|
||||
* Builds a Blossom server-list event (BUD-03, kind 10063): one `['server', <url>]`
|
||||
* tag per server, in preference order, de-duped.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.pubkey
|
||||
* @param {number} params.created_at
|
||||
* @param {string[]} params.servers - Ordered server URLs.
|
||||
* @returns {Object} Unsigned event.
|
||||
*/
|
||||
export function buildBlossomServerListEvent(params) {
|
||||
const { pubkey, created_at, servers } = params || {};
|
||||
const seen = new Set();
|
||||
const tags = [];
|
||||
if (Array.isArray(servers)) {
|
||||
for (const entry of servers) {
|
||||
const url = typeof entry === "string" ? entry.trim() : "";
|
||||
if (url && !seen.has(url)) {
|
||||
seen.add(url);
|
||||
tags.push(["server", url]);
|
||||
}
|
||||
}
|
||||
}
|
||||
tags.push(["client", "bitvid"]);
|
||||
return {
|
||||
kind: BLOSSOM_SERVER_LIST_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zap Request event (Kind 9734).
|
||||
* Used to request an invoice from a Lightning Service Provider (LSP).
|
||||
|
||||
+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,498 @@
|
||||
// Blossom (nostr-native blob storage) upload service.
|
||||
//
|
||||
// Owns the Blossom upload orchestration so mediaUploader stays thin and this
|
||||
// logic is unit-testable without pulling WebTorrent. Implements the same result
|
||||
// shape as the S3/R2 path (see js/ui/components/mediaUploader.js) so a
|
||||
// `provider === "blossom"` connection flows through publish unchanged. Auth is a
|
||||
// signed kind-24242 nostr event (BUD-11) from bitvid's existing signer — no
|
||||
// access keys. Uploads mirror to multiple servers for resilience (BUD-04).
|
||||
// See docs/blossom-plan.md / TODO #30.
|
||||
import {
|
||||
FEATURE_BLOSSOM_STORAGE,
|
||||
BLOSSOM_TORRENT_METADATA_MAX_BASE64,
|
||||
} from "../constants.js";
|
||||
import { buildStoragePointerValue } from "../utils/storagePointer.js";
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
|
||||
// base64 of a File/Blob's bytes (browser + Node 20+). Used to embed the
|
||||
// .torrent piece-map in the companion event. See docs/blossom-torrent-metadata-plan.md.
|
||||
async function fileToBase64(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
// Vendored, pinned blossom-client-sdk bundle (scripts/build-blossom-sdk.mjs).
|
||||
// Lazy-imported so its (small) weight never touches the main bundle.
|
||||
const BLOSSOM_SDK_BUNDLE_URL = "../../vendor/blossom-client-sdk.bundle.min.js";
|
||||
|
||||
export const BLOSSOM_PROVIDER = "blossom";
|
||||
|
||||
export function isBlossomProvider(provider) {
|
||||
return provider === BLOSSOM_PROVIDER;
|
||||
}
|
||||
|
||||
// Extract a clean, de-duped server-URL list from a connection's credentials
|
||||
// (servers live in the plaintext connection meta — they are public, not secret).
|
||||
export function resolveBlossomServers(credentials) {
|
||||
const source = credentials || {};
|
||||
const raw = Array.isArray(source.servers)
|
||||
? source.servers
|
||||
: Array.isArray(source.meta?.servers)
|
||||
? source.meta.servers
|
||||
: [];
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const entry of raw) {
|
||||
const url = typeof entry === "string" ? entry.trim() : "";
|
||||
if (url && !seen.has(url)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Common media extensions → MIME type. Browsers leave File.type empty for some
|
||||
// containers (.m4v/.mkv) and for files dragged in without an OS MIME mapping.
|
||||
// The Blossom SDK uploads `body: file` with no explicit Content-Type, so an empty
|
||||
// File.type means the PUT declares no type — and a type-allowlisting server (e.g.
|
||||
// blossom.band) then answers 415. We re-declare the type from the filename.
|
||||
const MIME_BY_EXTENSION = {
|
||||
mp4: "video/mp4",
|
||||
m4v: "video/mp4",
|
||||
mov: "video/quicktime",
|
||||
webm: "video/webm",
|
||||
mkv: "video/x-matroska",
|
||||
ogv: "video/ogg",
|
||||
mp3: "audio/mpeg",
|
||||
m4a: "audio/mp4",
|
||||
m4b: "audio/mp4",
|
||||
aac: "audio/aac",
|
||||
wav: "audio/wav",
|
||||
flac: "audio/flac",
|
||||
opus: "audio/opus",
|
||||
oga: "audio/ogg",
|
||||
ogg: "audio/ogg",
|
||||
weba: "audio/webm",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
avif: "image/avif",
|
||||
torrent: "application/x-bittorrent",
|
||||
};
|
||||
|
||||
function inferMimeFromName(name) {
|
||||
const ext =
|
||||
typeof name === "string" ? name.split(".").pop()?.toLowerCase() : "";
|
||||
return (ext && MIME_BY_EXTENSION[ext]) || "";
|
||||
}
|
||||
|
||||
// Types we should never trust for a media upload — a recognized extension wins
|
||||
// over these. Browsers report "" for some containers, and apps/WebTorrent often
|
||||
// stamp "application/octet-stream" on media files; strict Blossom servers reject
|
||||
// both with 415/400 even though they accept the real type (e.g. video/mp4).
|
||||
const GENERIC_TYPES = new Set(["", "application/octet-stream", "binary/octet-stream"]);
|
||||
|
||||
// Guarantee the blob carries a correct Content-Type. When the filename has a
|
||||
// recognized media extension, trust it over an empty or generic browser type and
|
||||
// re-wrap the blob with the inferred type (bytes are unchanged, so the sha256 is
|
||||
// too). A specific, non-generic browser type is left as-is.
|
||||
export function ensureTypedFile(file) {
|
||||
if (!file || typeof File === "undefined") {
|
||||
return file;
|
||||
}
|
||||
const name = typeof file.name === "string" ? file.name : "upload";
|
||||
const inferred = inferMimeFromName(name);
|
||||
const current = typeof file.type === "string" ? file.type : "";
|
||||
// Nothing to infer, or the browser already declares the exact inferred type.
|
||||
if (!inferred || current === inferred) {
|
||||
return file;
|
||||
}
|
||||
// Keep a specific, non-generic browser type; only override empty/generic ones.
|
||||
if (current && !GENERIC_TYPES.has(current)) {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
return new File([file], name, { type: inferred });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the blob id (sha256, 64 hex) from a Blossom URL — the last path segment
|
||||
// minus any extension (`https://<host>/<sha256>.mp4` → `<sha256>`). Returns "" when
|
||||
// the URL doesn't point at a Blossom-style content-addressed blob.
|
||||
export function blobSha256FromUrl(url) {
|
||||
const raw = typeof url === "string" ? url.trim() : "";
|
||||
if (!raw) return "";
|
||||
try {
|
||||
const path = new URL(raw).pathname;
|
||||
const last = path.split("/").pop() || "";
|
||||
const hex = last.split(".")[0].toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(hex) ? hex : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Read the server URLs from a user's kind-10063 Blossom server-list event (BUD-03):
|
||||
// one `["server", <url>]` tag per server, in preference order. De-duped + trimmed.
|
||||
export function serversFromServerListEvent(event) {
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
const urls = tags
|
||||
.filter((t) => Array.isArray(t) && t[0] === "server" && typeof t[1] === "string")
|
||||
.map((t) => t[1]);
|
||||
return resolveBlossomServers({ servers: urls });
|
||||
}
|
||||
|
||||
export class BlossomService {
|
||||
constructor(deps = {}) {
|
||||
this.deps = deps;
|
||||
this._sdkPromise = null;
|
||||
}
|
||||
|
||||
// On/off gate — off ⇒ the SDK is never imported.
|
||||
isAvailable() {
|
||||
return FEATURE_BLOSSOM_STORAGE === true;
|
||||
}
|
||||
|
||||
async loadSdk() {
|
||||
if (this._sdkPromise) {
|
||||
return this._sdkPromise;
|
||||
}
|
||||
this._sdkPromise = import(BLOSSOM_SDK_BUNDLE_URL).catch((error) => {
|
||||
this._sdkPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return this._sdkPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single blob to one or more Blossom servers (mirrored). Returns the
|
||||
* primary blob descriptor's `url` (`<server>/<sha256>`) and `key` (sha256).
|
||||
*/
|
||||
async uploadFile({ file, servers, signer, type = "upload", onProgress } = {}) {
|
||||
if (!file) {
|
||||
throw new Error("Blossom upload requires a file.");
|
||||
}
|
||||
const serverList = resolveBlossomServers({ servers });
|
||||
if (serverList.length === 0) {
|
||||
throw new Error("Blossom upload requires at least one server URL.");
|
||||
}
|
||||
if (typeof signer !== "function") {
|
||||
throw new Error("Blossom upload requires a signer (a signed-event function).");
|
||||
}
|
||||
|
||||
const sdk = await this.loadSdk();
|
||||
const { uploadBlob, createUploadAuth } = sdk;
|
||||
if (
|
||||
typeof uploadBlob !== "function" ||
|
||||
typeof createUploadAuth !== "function"
|
||||
) {
|
||||
throw new Error("Blossom SDK is missing expected exports.");
|
||||
}
|
||||
|
||||
// Ensure the blob declares a Content-Type — an empty File.type otherwise
|
||||
// uploads as a typeless PUT and strict servers reject it with 415.
|
||||
const typedFile = ensureTypedFile(file);
|
||||
|
||||
// Surface exactly what we're about to send so a 415 is never a guess:
|
||||
// the effective Content-Type (from typedFile.type), original type, name, size.
|
||||
devLogger.log(
|
||||
`[Blossom] uploading "${typeof typedFile?.name === "string" ? typedFile.name : "(no name)"}" — ` +
|
||||
`sending Content-Type="${typedFile?.type || "(empty)"}" ` +
|
||||
`(original File.type="${file?.type || "(empty)"}", ${typedFile?.size ?? "?"} bytes) ` +
|
||||
`to ${serverList.length} server(s): ${serverList.join(", ")}`,
|
||||
);
|
||||
|
||||
// Upload DIRECTLY to each server (BUD-02 `PUT /upload`), in parallel, rather
|
||||
// than the SDK's upload-one-then-`/mirror` flow — the server-to-server mirror
|
||||
// step fails from a browser (CORS) on many servers. A fresh signed upload auth
|
||||
// (BUD-11, scoped to the blob's sha256) is created per request.
|
||||
const onAuth = (server, sha256) =>
|
||||
createUploadAuth(signer, sha256, { type });
|
||||
const settled = await Promise.allSettled(
|
||||
serverList.map((server) =>
|
||||
uploadBlob(server, typedFile, {
|
||||
onAuth,
|
||||
onProgress: typeof onProgress === "function" ? onProgress : undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const descriptors = [];
|
||||
const failures = [];
|
||||
settled.forEach((r, i) => {
|
||||
if (r.status === "fulfilled" && r.value && typeof r.value.url === "string") {
|
||||
descriptors.push(r.value);
|
||||
} else {
|
||||
const reason = r.reason;
|
||||
// The SDK's HTTPError carries `.status`; surface it so 415 (media type)
|
||||
// vs 413 (too large) vs 402 (payment) is unambiguous in the message.
|
||||
const status =
|
||||
reason && typeof reason.status === "number"
|
||||
? `HTTP ${reason.status}: `
|
||||
: "";
|
||||
const message = reason?.message || reason || "no blob URL returned";
|
||||
failures.push(`${serverList[i]}: ${status}${message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const primary = descriptors.find((d) => typeof d?.url === "string" && d.url);
|
||||
if (!primary) {
|
||||
throw new Error(
|
||||
`Blossom upload failed on all server(s) — ${failures.join("; ")}. ` +
|
||||
`Common causes: the file exceeds the server's size limit (blossom.band ` +
|
||||
`caps free uploads at 20 MiB), the server rejects the media type, blocks ` +
|
||||
`browser uploads (CORS), or requires payment/allowlisting.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: primary.url,
|
||||
key: primary.sha256 || "",
|
||||
servers: serverList,
|
||||
descriptors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full video upload: the blob, then (if `generateTorrent` yields one) the
|
||||
* `.torrent` as a second blob, wired into a magnet (ws = the Blossom video URL,
|
||||
* xs = the Blossom .torrent URL). Returns the same shape mediaUploader's S3 path
|
||||
* returns so publish is untouched. `generateTorrent` is injected by the caller
|
||||
* so this module never imports WebTorrent (keeps it unit-testable).
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {File|Blob} params.file
|
||||
* @param {string[]} params.servers
|
||||
* @param {(draft:object)=>Promise<object>} params.signer
|
||||
* @param {(args:{file:File,videoPublicUrl:string})=>Promise<{hasValidInfoHash:boolean,infoHash?:string,torrentFile?:File}>} [params.generateTorrent]
|
||||
* @param {(fraction:number)=>void} [params.onProgress]
|
||||
* @param {(args:{infoHash:string,torrentBase64:string})=>Promise<void>} [params.publishTorrentMetadata]
|
||||
* Publishes the torrent piece-map as a companion Nostr event (Tier 2). When
|
||||
* provided and `FEATURE_BLOSSOM_TORRENT_METADATA` is on and the `.torrent`
|
||||
* couldn't be hosted (no `xs=`), a successful publish yields a `ws=`-only magnet
|
||||
* bitvid can bootstrap. Any failure / over-cap ⇒ URL-only.
|
||||
*/
|
||||
async uploadVideo({
|
||||
file,
|
||||
servers,
|
||||
signer,
|
||||
generateTorrent,
|
||||
onProgress,
|
||||
publishTorrentMetadata,
|
||||
} = {}) {
|
||||
const serverList = resolveBlossomServers({ servers });
|
||||
if (serverList.length === 0) {
|
||||
throw new Error("No Blossom servers configured for this connection.");
|
||||
}
|
||||
|
||||
const uploaded = await this.uploadFile({
|
||||
file,
|
||||
servers: serverList,
|
||||
signer,
|
||||
type: "upload",
|
||||
onProgress,
|
||||
});
|
||||
const videoPublicUrl = uploaded.url; // <server>/<sha256>
|
||||
|
||||
const result = {
|
||||
url: videoPublicUrl,
|
||||
key: uploaded.key,
|
||||
storagePointer: buildStoragePointerValue({
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
prefix: serverList[0], // where the blob lives, for later delete/list (BUD-12)
|
||||
}),
|
||||
infoHash: "",
|
||||
magnet: "",
|
||||
torrentUrl: "",
|
||||
torrentFile: null,
|
||||
hasValidInfoHash: false,
|
||||
};
|
||||
|
||||
let torrent = null;
|
||||
if (typeof generateTorrent === "function") {
|
||||
torrent = await generateTorrent({ file, videoPublicUrl });
|
||||
}
|
||||
|
||||
if (torrent?.hasValidInfoHash && torrent.torrentFile) {
|
||||
const name = typeof file?.name === "string" ? file.name : "video";
|
||||
// Retain the generated .torrent locally so a future/other storage target
|
||||
// could host it — but don't advertise a magnet yet (see below).
|
||||
result.torrentFile = torrent.torrentFile;
|
||||
|
||||
// Try to host the .torrent alongside the video so the magnet carries xs=
|
||||
// (the metadata source WebTorrent needs to start). Best-effort: most Blossom
|
||||
// servers accept media types only and reject a .torrent (415). Only when the
|
||||
// .torrent is actually hosted do we publish a magnet — a webseed-only magnet
|
||||
// (no xs=) can't bootstrap WebTorrent and would be a dead share link, so we
|
||||
// publish URL-only instead. Servers that DO accept .torrent (self-hosted or
|
||||
// paid) automatically yield a full, P2P-playable magnet with no other change.
|
||||
let torrentUrl = "";
|
||||
try {
|
||||
const torrentUploaded = await this.uploadFile({
|
||||
file: torrent.torrentFile,
|
||||
servers: serverList,
|
||||
signer,
|
||||
type: "upload",
|
||||
});
|
||||
torrentUrl = torrentUploaded.url;
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
`[Blossom] Video uploaded. The .torrent sidecar wasn't hosted ` +
|
||||
`(Blossom servers accept media only); publishing URL-only — a ` +
|
||||
`webseed-only magnet can't bootstrap WebTorrent.`,
|
||||
);
|
||||
}
|
||||
|
||||
const wsMagnet = () =>
|
||||
`magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(name)}` +
|
||||
`&ws=${encodeURIComponent(videoPublicUrl)}`;
|
||||
|
||||
if (torrentUrl) {
|
||||
// Tier 1 — the .torrent is hosted (xs=): a full magnet, playable in any
|
||||
// torrent client.
|
||||
result.infoHash = torrent.infoHash;
|
||||
result.torrentUrl = torrentUrl;
|
||||
result.magnet = `${wsMagnet()}&xs=${encodeURIComponent(torrentUrl)}`;
|
||||
result.hasValidInfoHash = true;
|
||||
} else if (typeof publishTorrentMetadata === "function") {
|
||||
// Tier 2 — publish the piece-map as a companion Nostr event (D3/D4) so
|
||||
// bitvid can bootstrap WebTorrent from the infohash without a hosted
|
||||
// .torrent. The publisher is only wired when FEATURE_BLOSSOM_TORRENT_METADATA
|
||||
// is on (that flag gate lives at the mediaUploader seam), so its presence
|
||||
// IS the gate here. Gate on the exact encoded size (off the feed, but a
|
||||
// guard); over cap or a failed publish falls through to URL-only.
|
||||
try {
|
||||
const torrentBase64 = await fileToBase64(torrent.torrentFile);
|
||||
if (torrentBase64.length > BLOSSOM_TORRENT_METADATA_MAX_BASE64) {
|
||||
devLogger.warn(
|
||||
`[Blossom] Video uploaded. Torrent piece-map is ${torrentBase64.length} ` +
|
||||
`base64 bytes (> ${BLOSSOM_TORRENT_METADATA_MAX_BASE64}); publishing ` +
|
||||
`URL-only.`,
|
||||
);
|
||||
} else {
|
||||
await publishTorrentMetadata({
|
||||
infoHash: torrent.infoHash,
|
||||
torrentBase64,
|
||||
});
|
||||
result.infoHash = torrent.infoHash;
|
||||
result.magnet = wsMagnet(); // ws= only; metadata is on Nostr
|
||||
result.hasValidInfoHash = true;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
`[Blossom] Video uploaded, but publishing the torrent metadata ` +
|
||||
`companion failed; publishing URL-only. Reason: ${
|
||||
error?.message || error
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// else: URL-only (flag off / no publisher wired) — video already succeeded.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a blob from one Blossom server (BUD-12 `DELETE /<sha256>`), authorized
|
||||
* by a signed kind-24242 delete auth (BUD-11). Returns true on success.
|
||||
*/
|
||||
async deleteFile({ server, sha256, signer } = {}) {
|
||||
const url = typeof server === "string" ? server.trim() : "";
|
||||
const hash = typeof sha256 === "string" ? sha256.trim() : "";
|
||||
if (!url || !hash) {
|
||||
throw new Error("Blossom delete requires a server and a sha256.");
|
||||
}
|
||||
if (typeof signer !== "function") {
|
||||
throw new Error("Blossom delete requires a signer.");
|
||||
}
|
||||
const sdk = await this.loadSdk();
|
||||
const { deleteBlob, createDeleteAuth } = sdk;
|
||||
if (
|
||||
typeof deleteBlob !== "function" ||
|
||||
typeof createDeleteAuth !== "function"
|
||||
) {
|
||||
throw new Error("Blossom SDK is missing delete exports.");
|
||||
}
|
||||
return deleteBlob(url, hash, {
|
||||
onAuth: () => createDeleteAuth(signer, hash),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the blob behind a Blossom URL from every configured server
|
||||
* (best-effort). Used by orphan cleanup when a Blossom-hosted video/thumbnail is
|
||||
* superseded or deleted. A non-Blossom URL (no 64-hex blob id) is a no-op.
|
||||
*
|
||||
* @returns {Promise<{sha256:string, deleted:string[], failed:{server:string,error:string}[]}>}
|
||||
*/
|
||||
async deleteByUrl({ url, servers, signer } = {}) {
|
||||
const sha256 = blobSha256FromUrl(url);
|
||||
if (!sha256) {
|
||||
return { sha256: "", deleted: [], failed: [] };
|
||||
}
|
||||
let serverList = resolveBlossomServers({ servers });
|
||||
if (serverList.length === 0) {
|
||||
// No explicit servers — delete from the blob URL's own origin (the server
|
||||
// that served it). Callers with the full mirror set should pass `servers`.
|
||||
try {
|
||||
serverList = [new URL(url).origin];
|
||||
} catch {
|
||||
serverList = [];
|
||||
}
|
||||
}
|
||||
const deleted = [];
|
||||
const failed = [];
|
||||
await Promise.allSettled(
|
||||
serverList.map(async (server) => {
|
||||
try {
|
||||
await this.deleteFile({ server, sha256, signer });
|
||||
deleted.push(server);
|
||||
} catch (error) {
|
||||
failed.push({ server, error: error?.message || String(error) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { sha256, deleted, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* List a user's blobs on a Blossom server (BUD-12 `GET /list/<pubkey>`). Some
|
||||
* servers require a signed list auth; provide the signer to cover those.
|
||||
* Returns the array of blob descriptors.
|
||||
*/
|
||||
async listFiles({ server, pubkey, signer } = {}) {
|
||||
const url = typeof server === "string" ? server.trim() : "";
|
||||
const pk = typeof pubkey === "string" ? pubkey.trim() : "";
|
||||
if (!url || !pk) {
|
||||
throw new Error("Blossom list requires a server and a pubkey.");
|
||||
}
|
||||
const sdk = await this.loadSdk();
|
||||
const { listBlobs, createListAuth } = sdk;
|
||||
if (typeof listBlobs !== "function") {
|
||||
throw new Error("Blossom SDK is missing list exports.");
|
||||
}
|
||||
const opts =
|
||||
typeof signer === "function" && typeof createListAuth === "function"
|
||||
? { onAuth: () => createListAuth(signer) }
|
||||
: {};
|
||||
return listBlobs(url, pk, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const blossomService = new BlossomService();
|
||||
|
||||
export default blossomService;
|
||||
@@ -59,6 +59,22 @@ export function createEncryptedSyncItem({
|
||||
return Boolean(result?.exists);
|
||||
}
|
||||
|
||||
// Does this device already have the item locally? (A non-null payload means
|
||||
// there's something to sync — e.g. a connected wallet / configured storage.)
|
||||
// Used by the login offer to re-surface a restore when the item is MISSING
|
||||
// here but a Nostr backup exists.
|
||||
async function hasLocal(pubkey) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Boolean(await buildPayload(key));
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function push(pubkey, { confirmOverwrite } = {}) {
|
||||
const key = normalizePubkey(pubkey);
|
||||
if (!key) {
|
||||
@@ -151,6 +167,7 @@ export function createEncryptedSyncItem({
|
||||
isAvailable,
|
||||
isEnabled,
|
||||
hasRemote,
|
||||
hasLocal,
|
||||
push,
|
||||
pull,
|
||||
autoPullIfNewer,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getTrustedMuteHideThreshold, getTrustedSpamHideThreshold, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD } from "../constants.js";
|
||||
import { formatShortNpub } from "../utils/formatters.js";
|
||||
import { getModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
|
||||
import { getModerationOverride, getAuthorModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
|
||||
/**
|
||||
@@ -403,14 +403,22 @@ export class ModerationDecorator {
|
||||
|
||||
const computedHidden = hideTriggered && !hideBypass;
|
||||
|
||||
const authorPubkey = video.pubkey || video.author?.pubkey || "";
|
||||
const overrideEntry = getModerationOverride({
|
||||
eventId: video.id,
|
||||
authorPubkey: video.pubkey || video.author?.pubkey || "",
|
||||
authorPubkey,
|
||||
});
|
||||
const overrideActive = overrideEntry?.showAnyway === true;
|
||||
// Account-level ("trusted creator") override: suppresses the WoT warning for
|
||||
// ALL of this author's videos, exactly like a per-video "show anyway".
|
||||
const authorOverrideEntry = getAuthorModerationOverride(authorPubkey);
|
||||
const overrideActive =
|
||||
overrideEntry?.showAnyway === true ||
|
||||
authorOverrideEntry?.showAnyway === true;
|
||||
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
|
||||
? Math.floor(overrideEntry.updatedAt)
|
||||
: Date.now();
|
||||
: Number.isFinite(authorOverrideEntry?.updatedAt)
|
||||
? Math.floor(authorOverrideEntry.updatedAt)
|
||||
: Date.now();
|
||||
|
||||
const originalHideCounts = hideCounts
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nostrClient } from "../nostrClientFacade.js";
|
||||
import { convertEventToVideo } from "../nostr/index.js";
|
||||
import { convertEventToVideo, isAudioOnlyVideoObject } from "../nostr/index.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
@@ -1502,6 +1502,13 @@ export class NostrService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Audio-only notes (podcasts/music) have no <video> to play — keep them out
|
||||
// of every video grid, even the viewer's own channel. Catches objects
|
||||
// rehydrated from a pre-guard cache that skipped convertEventToVideo (#60).
|
||||
if (isAudioOnlyVideoObject(video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewerIsAuthor = this.isViewerVideoAuthor(video);
|
||||
|
||||
if (viewerIsAuthor) {
|
||||
|
||||
@@ -1288,8 +1288,19 @@ class PlaybackSession extends SimpleEventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to URL
|
||||
if (httpsUrl && forcedSource !== "torrent") {
|
||||
// Fallback to URL. Even when P2P was explicitly forced, a hosted URL is a
|
||||
// better outcome than a hard "No playable source found" if the torrent
|
||||
// DEFINITIVELY failed (e.g. a transient companion-lookup failure or a dead
|
||||
// swarm). The torrent-first attempt above already ran — and, when there was
|
||||
// no URL plan B, waited indefinitely — so we only reach here on real
|
||||
// failure, not on a slow-but-working swarm.
|
||||
// Rollback: restore `&& forcedSource !== "torrent"` to make forced P2P hard-fail.
|
||||
if (httpsUrl) {
|
||||
if (forcedSource === "torrent") {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Forced torrent failed; falling back to the hosted URL."
|
||||
);
|
||||
}
|
||||
const urlResult = await attemptHostedPlayback();
|
||||
if (urlResult && urlResult.source === "url") {
|
||||
return urlResult;
|
||||
|
||||
@@ -10,13 +10,16 @@ import { showConfirm } from "../ui/confirmDialog.js";
|
||||
import { setSyncEnabled } from "./settingsSyncFlags.js";
|
||||
|
||||
const OFFERED_KEY = "bitvid:settings-sync:offered:v1";
|
||||
// Set when the user explicitly declines a restore offer, so the "re-offer while
|
||||
// the item is missing locally" behavior below doesn't nag them on every login.
|
||||
const DISMISSED_KEY = "bitvid:settings-sync:dismissed:v1";
|
||||
|
||||
function readOffered() {
|
||||
function readMap(storageKey) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return {};
|
||||
}
|
||||
const raw = localStorage.getItem(OFFERED_KEY);
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
@@ -24,6 +27,31 @@ function readOffered() {
|
||||
}
|
||||
}
|
||||
|
||||
function readOffered() {
|
||||
return readMap(OFFERED_KEY);
|
||||
}
|
||||
|
||||
function isDismissed(pubkey, kind) {
|
||||
const entry = readMap(DISMISSED_KEY)[pubkey];
|
||||
return Boolean(entry && typeof entry === "object" && entry[kind]);
|
||||
}
|
||||
|
||||
function markDismissed(pubkey, kind) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
const map = readMap(DISMISSED_KEY);
|
||||
const kinds =
|
||||
map[pubkey] && typeof map[pubkey] === "object" ? { ...map[pubkey] } : {};
|
||||
kinds[kind] = true;
|
||||
map[pubkey] = kinds;
|
||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
// Best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Per-pubkey, PER-ITEM offered state. Tracking only per-pubkey meant the first
|
||||
// item offered (usually storage) flagged the whole pubkey, so a wallet note
|
||||
// published later was never offered — "the wallet doesn't restore like storage".
|
||||
@@ -78,13 +106,29 @@ export function createSettingsRestorePrompt({
|
||||
["wallet", walletSync],
|
||||
];
|
||||
for (const [kind, service] of services) {
|
||||
// Enabled items auto-pull silently elsewhere; declined items stay quiet.
|
||||
if (
|
||||
!service?.isAvailable?.() ||
|
||||
service.isEnabled(pubkey) ||
|
||||
hasOffered(pubkey, kind)
|
||||
isDismissed(pubkey, kind)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Re-offer whenever the item is MISSING locally (e.g. a fresh device or a
|
||||
// cleared wallet) so a Nostr backup gets surfaced — "restore like storage".
|
||||
// Only when we DO have it locally do we respect the once-per-device flag
|
||||
// (no nagging). Unknown (no hasLocal) → assume present → keep old behavior.
|
||||
let localCopy = true;
|
||||
if (typeof service.hasLocal === "function") {
|
||||
try {
|
||||
localCopy = Boolean(await service.hasLocal(pubkey));
|
||||
} catch (error) {
|
||||
localCopy = true;
|
||||
}
|
||||
}
|
||||
if (localCopy && hasOffered(pubkey, kind)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await service.hasRemote()) {
|
||||
candidates.push(kind);
|
||||
@@ -147,6 +191,10 @@ export function createSettingsRestorePrompt({
|
||||
`Restore ${candidates.length > 1 ? "them" : "it"} to this device?`
|
||||
);
|
||||
if (!accepted) {
|
||||
// Remember the decline so we don't re-offer a missing item every login.
|
||||
for (const kind of candidates) {
|
||||
markDismissed(key, kind);
|
||||
}
|
||||
return { offered: true, accepted: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,31 @@ export class StorageService {
|
||||
this.dbPromise = null;
|
||||
// Cache decrypted master keys in memory: Map<pubkey, CryptoKey>
|
||||
this.masterKeys = new Map();
|
||||
// Observers notified when a pubkey's connection set changes (e.g. a Nostr
|
||||
// sync import), so open UIs (the upload modal) can refresh live.
|
||||
this._changeListeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to connection-set changes. The listener receives `{ pubkey }`.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onConnectionsChanged(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
this._changeListeners.add(listener);
|
||||
return () => this._changeListeners.delete(listener);
|
||||
}
|
||||
|
||||
_emitConnectionsChanged(pubkey) {
|
||||
for (const listener of this._changeListeners) {
|
||||
try {
|
||||
listener({ pubkey });
|
||||
} catch (error) {
|
||||
// A misbehaving observer must never break a storage write.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -591,6 +616,41 @@ export class StorageService {
|
||||
userLogger.log(`[StorageService] Saved connection ${connectionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a KEYLESS connection (e.g. Blossom): all config lives in the plaintext
|
||||
* meta and there is no secret to encrypt, so this does NOT require unlock().
|
||||
* Used for providers whose auth is the user's Nostr signer, not a stored key.
|
||||
*/
|
||||
async saveKeylessConnection(pubkey, connectionId, meta = {}) {
|
||||
const account = (await this._getAccount(pubkey)) || {
|
||||
pubkey,
|
||||
connections: {},
|
||||
};
|
||||
if (!account.connections) {
|
||||
account.connections = {};
|
||||
}
|
||||
if (meta.defaultForUploads) {
|
||||
for (const key in account.connections) {
|
||||
const conn = account.connections[key];
|
||||
if (conn.meta) {
|
||||
conn.meta.defaultForUploads = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
account.connections[connectionId] = {
|
||||
id: connectionId,
|
||||
provider: meta.provider || connectionId,
|
||||
meta: {
|
||||
...meta,
|
||||
lastSaved: Date.now(),
|
||||
provider: meta.provider || connectionId,
|
||||
},
|
||||
encrypted: null,
|
||||
};
|
||||
await this._saveAccount(account);
|
||||
userLogger.log(`[StorageService] Saved keyless connection ${connectionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts a connection.
|
||||
* Requires unlock() to be called first.
|
||||
@@ -675,13 +735,29 @@ export class StorageService {
|
||||
if (!pubkey) {
|
||||
throw new Error("Pubkey required to import storage account record.");
|
||||
}
|
||||
if (!record || typeof record !== "object" || !record.encryptedMasterKey) {
|
||||
if (!record || typeof record !== "object") {
|
||||
throw new Error("Invalid storage account record.");
|
||||
}
|
||||
// A Blossom-only account is keyless (no encrypted payloads), so it legitimately
|
||||
// has no encryptedMasterKey. Require the envelope ONLY when the record actually
|
||||
// carries an encrypted connection that would need it to unlock — otherwise a
|
||||
// Blossom-only account can't sync across devices.
|
||||
const connections =
|
||||
record.connections && typeof record.connections === "object"
|
||||
? record.connections
|
||||
: {};
|
||||
const hasEncryptedConnection = Object.values(connections).some(
|
||||
(conn) => conn && conn.encrypted != null,
|
||||
);
|
||||
if (hasEncryptedConnection && !record.encryptedMasterKey) {
|
||||
throw new Error("Invalid storage account record.");
|
||||
}
|
||||
const toSave = { ...record, pubkey };
|
||||
await this._saveAccount(toSave);
|
||||
// Drop any in-memory master key so the next unlock uses the imported envelope.
|
||||
this.lock(pubkey);
|
||||
// Let open UIs (e.g. the upload modal) reflect the newly-synced connections.
|
||||
this._emitConnectionsChanged(pubkey);
|
||||
return toSave;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Torrent piece-map lookup for Blossom videos (Phase 2 of the WebTorrent-metadata
|
||||
// companion event). Given a magnet's infohash + the video's author, fetches the
|
||||
// infohash-keyed kind-30078 companion event, decodes the embedded `.torrent`, and
|
||||
// VERIFIES that the reconstructed infohash equals the requested one before handing
|
||||
// the bytes to WebTorrent. Untrusted content: a mismatch (tamper / wrong event) is
|
||||
// rejected → the caller falls back to URL-only. See docs/blossom-torrent-metadata-plan.md.
|
||||
import {
|
||||
TORRENT_METADATA_KIND,
|
||||
torrentMetadataDTag,
|
||||
normalizeInfoHash,
|
||||
} from "../nostrEventSchemas.js";
|
||||
|
||||
// base64 → Uint8Array (browser + Node).
|
||||
export function base64ToBytes(b64) {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(b64, "base64"));
|
||||
}
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
hex += bytes[i].toString(16).padStart(2, "0");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
// Advance past one bencoded value beginning at `i`; returns the index after it.
|
||||
// Handles integers (`i…e`), byte strings (`<len>:…`), lists (`l…e`) and dicts
|
||||
// (`d…e`). Throws on anything malformed (callers treat that as "unverifiable").
|
||||
function skipValue(b, i) {
|
||||
if (i >= b.length) throw new Error("bencode: truncated");
|
||||
const c = b[i];
|
||||
if (c === 0x69 /* i */) {
|
||||
i += 1;
|
||||
while (i < b.length && b[i] !== 0x65 /* e */) i += 1;
|
||||
if (i >= b.length) throw new Error("bencode: unterminated int");
|
||||
return i + 1;
|
||||
}
|
||||
if (c === 0x6c /* l */ || c === 0x64 /* d */) {
|
||||
i += 1;
|
||||
while (i < b.length && b[i] !== 0x65 /* e */) i = skipValue(b, i);
|
||||
if (i >= b.length) throw new Error("bencode: unterminated container");
|
||||
return i + 1;
|
||||
}
|
||||
// byte string: <len>:<bytes>
|
||||
let len = 0;
|
||||
let sawDigit = false;
|
||||
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
|
||||
len = len * 10 + (b[i] - 0x30);
|
||||
sawDigit = true;
|
||||
i += 1;
|
||||
}
|
||||
if (!sawDigit || b[i] !== 0x3a /* : */) throw new Error("bencode: bad string");
|
||||
i += 1;
|
||||
if (i + len > b.length) throw new Error("bencode: string overruns buffer");
|
||||
return i + len;
|
||||
}
|
||||
|
||||
// Return the raw bytes of the top-level `info` value in a `.torrent`, or null.
|
||||
function extractInfoDictBytes(b) {
|
||||
if (!(b instanceof Uint8Array) || b[0] !== 0x64 /* d */) return null;
|
||||
let i = 1;
|
||||
const decoder = new TextDecoder();
|
||||
while (i < b.length && b[i] !== 0x65 /* e */) {
|
||||
// key (byte string)
|
||||
let len = 0;
|
||||
let sawDigit = false;
|
||||
while (i < b.length && b[i] >= 0x30 && b[i] <= 0x39) {
|
||||
len = len * 10 + (b[i] - 0x30);
|
||||
sawDigit = true;
|
||||
i += 1;
|
||||
}
|
||||
if (!sawDigit || b[i] !== 0x3a) return null;
|
||||
i += 1;
|
||||
if (i + len > b.length) return null;
|
||||
const key = decoder.decode(b.subarray(i, i + len));
|
||||
i += len;
|
||||
const valueStart = i;
|
||||
i = skipValue(b, i);
|
||||
if (key === "info") return b.subarray(valueStart, i);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The BitTorrent v1 infohash (SHA-1 of the bencoded `info` dict) of a `.torrent`
|
||||
// buffer, lowercase hex, or "" if it can't be parsed/hashed.
|
||||
export async function infoHashFromTorrent(bytes) {
|
||||
try {
|
||||
const info = extractInfoDictBytes(bytes);
|
||||
if (!info) return "";
|
||||
const digest = await crypto.subtle.digest("SHA-1", info);
|
||||
return bytesToHex(new Uint8Array(digest));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export class TorrentMetadataService {
|
||||
constructor() {
|
||||
// Cache the verified piece-map by infohash so repeated plays don't re-query.
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + verify a video's torrent piece-map from its companion event.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.infoHash - The magnet's `btih` (40 hex).
|
||||
* @param {string} [params.author] - The video's author hex pubkey (scopes the query).
|
||||
* @param {(filter:object)=>Promise<Array<object>>} params.queryEvents - Relay query.
|
||||
* @returns {Promise<{infoHash:string,torrentBytes:Uint8Array}|null>} null ⇒ URL-only.
|
||||
*/
|
||||
async fetch({ infoHash, author, queryEvents } = {}) {
|
||||
const hex = normalizeInfoHash(infoHash);
|
||||
if (!hex || typeof queryEvents !== "function") return null;
|
||||
if (this.cache.has(hex)) return this.cache.get(hex);
|
||||
|
||||
const filter = {
|
||||
kinds: [TORRENT_METADATA_KIND],
|
||||
"#d": [torrentMetadataDTag(hex)],
|
||||
};
|
||||
if (author) filter.authors = [author];
|
||||
|
||||
let events;
|
||||
try {
|
||||
events = await queryEvents(filter);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(events) || events.length === 0) return null;
|
||||
|
||||
// Newest event wins.
|
||||
const newest = events
|
||||
.filter((e) => e && typeof e.content === "string")
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
||||
if (!newest) return null;
|
||||
|
||||
let bytes;
|
||||
try {
|
||||
const envelope = JSON.parse(newest.content);
|
||||
if (!envelope || typeof envelope.torrent !== "string") return null;
|
||||
bytes = base64ToBytes(envelope.torrent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SECURITY: only trust the piece-map if it reconstructs to the requested
|
||||
// infohash. A tampered/wrong event fails this and is discarded.
|
||||
const actual = await infoHashFromTorrent(bytes);
|
||||
if (actual !== hex) return null;
|
||||
|
||||
const result = { infoHash: hex, torrentBytes: bytes };
|
||||
this.cache.set(hex, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMetadataService = new TorrentMetadataService();
|
||||
export default torrentMetadataService;
|
||||
@@ -33,6 +33,12 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
|
||||
|
||||
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2";
|
||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
|
||||
// Account-level ("trusted creator") web-of-trust overrides, keyed by author hex.
|
||||
// A showAnyway entry suppresses the WoT blur/hide/warning for ALL of that
|
||||
// author's videos until the viewer resets it in the Safety tab.
|
||||
const AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY =
|
||||
"bitvid:moderationAuthorOverrides:v1";
|
||||
const AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION = 1;
|
||||
const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1";
|
||||
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
|
||||
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
|
||||
@@ -198,6 +204,7 @@ const profileCache = new Map();
|
||||
const urlHealthCache = new Map();
|
||||
const urlHealthInFlight = new Map();
|
||||
const moderationOverrides = new Map();
|
||||
const authorModerationOverrides = new Map();
|
||||
|
||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||
if (previousProfiles === nextProfiles) {
|
||||
@@ -1720,6 +1727,169 @@ export function clearModerationOverride(
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ---- Account-level ("trusted creator") web-of-trust overrides ---------------
|
||||
// Keyed by author hex; a showAnyway entry suppresses the WoT blur/hide/warning
|
||||
// for EVERY video by that author until reset. Mirrors the per-video store above.
|
||||
|
||||
function normalizeAuthorOverrideHex(pubkey) {
|
||||
const hex = normalizeHexPubkey(pubkey);
|
||||
return hex ? hex.toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function getAuthorModerationOverridesList() {
|
||||
return Array.from(authorModerationOverrides.values()).map((entry) => ({
|
||||
...entry,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAuthorModerationOverride(pubkey) {
|
||||
const hex = normalizeAuthorOverrideHex(pubkey);
|
||||
if (!hex) {
|
||||
return null;
|
||||
}
|
||||
const entry = authorModerationOverrides.get(hex);
|
||||
return entry ? { ...entry } : null;
|
||||
}
|
||||
|
||||
export function hasAuthorModerationOverride(pubkey) {
|
||||
const hex = normalizeAuthorOverrideHex(pubkey);
|
||||
if (!hex) {
|
||||
return false;
|
||||
}
|
||||
return authorModerationOverrides.get(hex)?.showAnyway === true;
|
||||
}
|
||||
|
||||
export function setAuthorModerationOverride(
|
||||
pubkey,
|
||||
override = {},
|
||||
{ persist = true } = {},
|
||||
) {
|
||||
const hex = normalizeAuthorOverrideHex(pubkey);
|
||||
if (!hex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showAnyway = override?.showAnyway === true;
|
||||
if (!showAnyway) {
|
||||
const removed = authorModerationOverrides.delete(hex);
|
||||
if (removed && persist) {
|
||||
persistAuthorModerationOverridesToStorage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedAt = Number.isFinite(override?.updatedAt)
|
||||
? Math.floor(override.updatedAt)
|
||||
: Date.now();
|
||||
const existing = authorModerationOverrides.get(hex);
|
||||
const nextEntry = { authorPubkey: hex, showAnyway: true, updatedAt };
|
||||
const changed =
|
||||
!existing ||
|
||||
existing.showAnyway !== true ||
|
||||
existing.updatedAt !== updatedAt;
|
||||
|
||||
authorModerationOverrides.set(hex, nextEntry);
|
||||
if (persist && changed) {
|
||||
persistAuthorModerationOverridesToStorage();
|
||||
}
|
||||
return { ...nextEntry };
|
||||
}
|
||||
|
||||
export function clearAuthorModerationOverride(pubkey, { persist = true } = {}) {
|
||||
const hex = normalizeAuthorOverrideHex(pubkey);
|
||||
if (!hex) {
|
||||
return false;
|
||||
}
|
||||
const removed = authorModerationOverrides.delete(hex);
|
||||
if (removed && persist) {
|
||||
persistAuthorModerationOverridesToStorage();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function loadAuthorModerationOverridesFromStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
const raw = localStorage.getItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
payload.version !== AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION ||
|
||||
!Array.isArray(payload.entries)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
authorModerationOverrides.clear();
|
||||
for (const entry of payload.entries) {
|
||||
if (!entry || entry.showAnyway !== true) {
|
||||
continue;
|
||||
}
|
||||
const hex = normalizeAuthorOverrideHex(entry.authorPubkey || entry.pubkey);
|
||||
if (!hex) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = Number.isFinite(entry.updatedAt)
|
||||
? Math.floor(entry.updatedAt)
|
||||
: Date.now();
|
||||
authorModerationOverrides.set(hex, {
|
||||
authorPubkey: hex,
|
||||
showAnyway: true,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
authorModerationOverrides.clear();
|
||||
userLogger.warn(
|
||||
"[cache.loadAuthorModerationOverridesFromStorage] Failed to parse payload:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAuthorModerationOverridesToStorage() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
const entries = [];
|
||||
for (const entry of authorModerationOverrides.values()) {
|
||||
if (!entry || entry.showAnyway !== true) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
authorPubkey: entry.authorPubkey,
|
||||
showAnyway: true,
|
||||
updatedAt: Number.isFinite(entry.updatedAt)
|
||||
? Math.floor(entry.updatedAt)
|
||||
: Date.now(),
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (entries.length === 0) {
|
||||
localStorage.removeItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
version: AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION,
|
||||
savedAt: Date.now(),
|
||||
entries,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[cache.persistAuthorModerationOverridesToStorage] Failed to persist overrides:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrlProbeKey(url, options = {}) {
|
||||
const trimmed = typeof url === "string" ? url : "";
|
||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// 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;
|
||||
const LOCAL_RESOLVED_KEY = "bitvid:admin:submissions-resolved-ids";
|
||||
|
||||
function normHex(value) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
// A device-local, ever-growing set of resolved submission ids. It backs up the
|
||||
// relay resolved-set two ways: (1) it hides handled items even if a cold-load
|
||||
// relay read momentarily misses the list, and (2) it makes the read-modify-write
|
||||
// in markSubmissionResolved loss-proof — a missed read can't republish a shrunk
|
||||
// list. Cleared with site data (that's fine; the mirrored relay list restores).
|
||||
function getLocalResolvedIds() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return new Set();
|
||||
}
|
||||
const raw = localStorage.getItem(LOCAL_RESOLVED_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return new Set(
|
||||
Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string" && id) : [],
|
||||
);
|
||||
} catch (error) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function addLocalResolvedIds(ids) {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
const set = getLocalResolvedIds();
|
||||
let changed = false;
|
||||
for (const id of ids || []) {
|
||||
if (id && !set.has(id)) {
|
||||
set.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
localStorage.setItem(LOCAL_RESOLVED_KEY, JSON.stringify([...set]));
|
||||
}
|
||||
} catch (error) {
|
||||
// best effort — local backup must never break the flow
|
||||
}
|
||||
}
|
||||
|
||||
function getManagerAndRelays(client) {
|
||||
const manager =
|
||||
typeof client?.getSubscriptionManager === "function"
|
||||
? client.getSubscriptionManager()
|
||||
: null;
|
||||
const relays = Array.isArray(client?.relays) ? client.relays : [];
|
||||
return { manager, relays };
|
||||
}
|
||||
|
||||
// Re-broadcast signed events to our relays so aggressive relay pruning doesn't
|
||||
// quietly drop them. Used for both pending submissions (ephemeral-authored, no
|
||||
// social graph → pruned fast) and the admin resolved-set (so the "handled" list
|
||||
// stays alive). A signed event can be re-published by anyone. Best-effort and
|
||||
// fire-and-forget — never blocks or fails a fetch.
|
||||
function mirrorEventsToRelays(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 anything already handled — via the
|
||||
* relay resolved-set, the device-local resolved cache, OR (for applications) an
|
||||
* applicant already on the whitelist.
|
||||
* @param {{
|
||||
* adminHex: string, editorHexes?: string[], whitelistNpubs?: string[], client?: any,
|
||||
* }} opts
|
||||
* @returns {Promise<Array>} pending submissions, newest-first
|
||||
*/
|
||||
export async function fetchPendingSubmissions({
|
||||
adminHex,
|
||||
editorHexes = [],
|
||||
whitelistNpubs = [],
|
||||
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 [];
|
||||
}
|
||||
|
||||
// Handled = union of (relay resolved-sets) + (device-local resolved cache).
|
||||
const resolvedEvents = await fetchResolvedSets({
|
||||
editorHexes: [admin, ...editorHexes],
|
||||
manager,
|
||||
relays,
|
||||
});
|
||||
const relayResolvedIds = collectResolvedIds(resolvedEvents);
|
||||
// Persist what the relays know into the local cache (so a later prune or a
|
||||
// cold read can't resurface them), then hide by the union of both.
|
||||
addLocalResolvedIds([...relayResolvedIds]);
|
||||
const resolvedIds = new Set([...relayResolvedIds, ...getLocalResolvedIds()]);
|
||||
// Keep the resolved-set alive on relays too — it prunes just like submissions.
|
||||
mirrorEventsToRelays(resolvedEvents, client);
|
||||
|
||||
// Belt-and-suspenders: an application whose applicant is already whitelisted
|
||||
// is durably handled even if the resolved-set were lost entirely.
|
||||
const whitelisted = new Set(
|
||||
(Array.isArray(whitelistNpubs) ? whitelistNpubs : [])
|
||||
.map(normNpub)
|
||||
.filter(Boolean),
|
||||
);
|
||||
const isHandled = (submission) => {
|
||||
if (resolvedIds.has(submission.eventId)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
submission.type === "application" &&
|
||||
submission.applicant &&
|
||||
whitelisted.has(normNpub(submission.applicant))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const parsed = (Array.isArray(subEvents) ? subEvents : [])
|
||||
.map(parseSubmissionEvent)
|
||||
.filter((s) => s && s.eventId && !isHandled(s))
|
||||
.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),
|
||||
);
|
||||
mirrorEventsToRelays(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 "";
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch every editor's resolved-set (raw events, so the caller can also mirror
|
||||
// them to keep them alive).
|
||||
async function fetchResolvedSets({ editorHexes, manager, relays }) {
|
||||
const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))];
|
||||
if (!authors.length) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const events = await manager.list({
|
||||
relays,
|
||||
filters: [
|
||||
{
|
||||
kinds: [RESOLVED_LIST_KIND],
|
||||
authors,
|
||||
"#d": [SUBMISSIONS_RESOLVED_DTAG],
|
||||
limit: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
return Array.isArray(events) ? events : [];
|
||||
} catch (error) {
|
||||
devLogger.warn("[submissions] Failed to list resolved-sets:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Union the `e`-tag ids across a set of resolved-set events.
|
||||
function collectResolvedIds(events) {
|
||||
const ids = new Set();
|
||||
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;
|
||||
}
|
||||
|
||||
// Record locally FIRST so this id can never be lost, even if the read below
|
||||
// misses and the republish would otherwise shrink the list.
|
||||
addLocalResolvedIds([eventId]);
|
||||
|
||||
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();
|
||||
// 1) Preserve existing entries (with their status/applicant audit tags).
|
||||
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] || ""]);
|
||||
}
|
||||
}
|
||||
// 2) The submission being resolved right now, with its audit metadata.
|
||||
if (!seen.has(eventId)) {
|
||||
seen.add(eventId);
|
||||
tags.push(["e", eventId, status, submission.applicant || ""]);
|
||||
}
|
||||
// 3) Loss-proofing: fold in every id this device knows was resolved, so a
|
||||
// missed read can never republish a list smaller than what we've handled.
|
||||
for (const id of getLocalResolvedIds()) {
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
tags.push(["e", id]);
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
+192
-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";
|
||||
@@ -57,6 +58,9 @@ import {
|
||||
getDmPrivacySettings,
|
||||
setDmPrivacySettings,
|
||||
clearModerationOverride,
|
||||
getAuthorModerationOverridesList,
|
||||
setAuthorModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
persistSavedProfiles,
|
||||
getSavedProfiles,
|
||||
getActiveProfilePubkey,
|
||||
@@ -83,6 +87,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,
|
||||
@@ -90,6 +96,30 @@ import {
|
||||
} from "./components/staticModalAccessibility.js";
|
||||
import ModalManager from "./ModalManager.js";
|
||||
|
||||
// After an account-level ("trusted creator") override is set/cleared, refresh
|
||||
// the feed so every video by that author re-decorates (warning on/off), and
|
||||
// fire the shared moderation-override event so the Safety tab list refreshes.
|
||||
function notifyAuthorModerationOverrideChanged(app, pubkey) {
|
||||
try {
|
||||
if (typeof document !== "undefined") {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("video:moderation-override", {
|
||||
detail: { authorPubkey: pubkey || "", accountLevel: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
if (typeof app?.onVideosShouldRefresh === "function") {
|
||||
void app.onVideosShouldRefresh({ reason: "author-moderation-override" });
|
||||
}
|
||||
} catch (error) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export default class ApplicationBootstrap {
|
||||
constructor({
|
||||
app,
|
||||
@@ -547,6 +577,22 @@ export default class ApplicationBootstrap {
|
||||
getModerationOverrides: () => getModerationOverridesList(),
|
||||
clearModerationOverride: (descriptor) =>
|
||||
clearModerationOverride(descriptor),
|
||||
// Account-level ("trusted creator") WoT overrides. Setting/clearing
|
||||
// one re-renders the feed so every video by that author updates, and
|
||||
// fires the shared event so the Safety tab list refreshes.
|
||||
getAuthorModerationOverrides: () => getAuthorModerationOverridesList(),
|
||||
setAuthorModerationOverride: (pubkey) => {
|
||||
const entry = setAuthorModerationOverride(pubkey, {
|
||||
showAnyway: true,
|
||||
});
|
||||
notifyAuthorModerationOverrideChanged(app, pubkey);
|
||||
return entry;
|
||||
},
|
||||
clearAuthorModerationOverride: (pubkey) => {
|
||||
const removed = clearAuthorModerationOverride(pubkey);
|
||||
notifyAuthorModerationOverrideChanged(app, pubkey);
|
||||
return removed;
|
||||
},
|
||||
loadVideos: (forceFetch, context) =>
|
||||
app.loadVideos(forceFetch, context),
|
||||
onVideosShouldRefresh: (context) =>
|
||||
@@ -858,21 +904,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 +994,69 @@ 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 whitelistNpubs = Array.from(accessControl.getWhitelist?.() || []);
|
||||
const list = await fetchPendingSubmissions({
|
||||
adminHex,
|
||||
editorHexes,
|
||||
whitelistNpubs,
|
||||
});
|
||||
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 +1103,57 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
// #24: "Always show this creator" overlay button → set the account-level
|
||||
// web-of-trust override for that author + refresh the feed. Fired by the
|
||||
// moderation badge on video cards / the player / similar cards.
|
||||
if (typeof document !== "undefined") {
|
||||
const onTrustAuthor = (event) => {
|
||||
const pubkey =
|
||||
typeof event?.detail?.pubkey === "string" ? event.detail.pubkey : "";
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setAuthorModerationOverride(pubkey, { showAnyway: true });
|
||||
notifyAuthorModerationOverrideChanged(app, pubkey);
|
||||
};
|
||||
document.addEventListener("video:trust-author", onTrustAuthor);
|
||||
nostrUnsubscribes.push(() =>
|
||||
document.removeEventListener("video:trust-author", onTrustAuthor),
|
||||
);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,8 @@ import { createModalAccessibility } from "./modalAccessibility.js";
|
||||
import { Nip71FormManager } from "./nip71FormManager.js";
|
||||
import { MediaUploader } from "./mediaUploader.js";
|
||||
import { initEditModalUpload } from "./editModalUpload.js";
|
||||
import { getActiveSigner } from "../../nostrClientRegistry.js";
|
||||
import { nostrClient } from "../../nostrClientFacade.js";
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
|
||||
export class EditModal {
|
||||
@@ -51,6 +53,8 @@ export class EditModal {
|
||||
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
|
||||
safeEncodeNpub:
|
||||
typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p,
|
||||
getSigner: () => getActiveSigner(),
|
||||
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
|
||||
});
|
||||
this.getMode =
|
||||
typeof getMode === "function"
|
||||
|
||||
@@ -134,6 +134,8 @@ export class SimilarContentCard {
|
||||
this.handleModerationHideClick(event);
|
||||
this.boundModerationBlockHandler = (event) =>
|
||||
this.handleModerationBlockClick(event);
|
||||
this.boundTrustAuthorHandler = (event) =>
|
||||
this.handleTrustAuthorClick(event);
|
||||
|
||||
this.build();
|
||||
}
|
||||
@@ -524,6 +526,43 @@ export class SimilarContentCard {
|
||||
return button;
|
||||
}
|
||||
|
||||
createModerationTrustAuthorButton() {
|
||||
const button = this.document.createElement("button");
|
||||
button.classList.add(
|
||||
"moderation-badge__action",
|
||||
"flex-shrink-0",
|
||||
"text-xs",
|
||||
"py-1",
|
||||
"px-2"
|
||||
);
|
||||
button.type = "button";
|
||||
button.dataset.moderationAction = "trust-author";
|
||||
button.setAttribute("aria-label", "Always show videos from this creator");
|
||||
button.textContent = "Always show creator";
|
||||
button.addEventListener("click", this.boundTrustAuthorHandler);
|
||||
button.classList.add("pointer-events-auto");
|
||||
return button;
|
||||
}
|
||||
|
||||
handleTrustAuthorClick(event) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
|
||||
const pubkey =
|
||||
this.video?.pubkey || this.video?.author?.pubkey || "";
|
||||
if (!pubkey || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("video:trust-author", { detail: { pubkey } })
|
||||
);
|
||||
} catch (error) {
|
||||
// Non-fatal: trust-author is a convenience action.
|
||||
}
|
||||
}
|
||||
|
||||
handleShowAnywayClick(event) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
@@ -720,6 +759,7 @@ export class SimilarContentCard {
|
||||
actions.appendChild(showButton);
|
||||
this.moderationActionButton = showButton;
|
||||
this.moderationActionButtonMode = "override";
|
||||
actions.appendChild(this.createModerationTrustAuthorButton());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,17 @@ import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
|
||||
import { buildR2Key, buildPublicUrl } from "../../r2.js";
|
||||
import { buildS3ObjectUrl } from "../../services/s3Service.js";
|
||||
import { PROVIDERS } from "../../services/storageService.js";
|
||||
import {
|
||||
BLOSSOM_PROVIDER,
|
||||
isBlossomProvider,
|
||||
} from "../../services/blossomService.js";
|
||||
import {
|
||||
buildStoragePointerValue,
|
||||
buildStoragePrefixFromKey,
|
||||
deriveStoragePointerFromUrl,
|
||||
} from "../../utils/storagePointer.js";
|
||||
import {
|
||||
nostrClient,
|
||||
getActiveSigner,
|
||||
requestDefaultExtensionPermissions,
|
||||
} from "../../nostrClientFacade.js";
|
||||
@@ -96,6 +101,8 @@ export class UploadModal {
|
||||
getCurrentPubkey: () =>
|
||||
this.getCurrentPubkey ? this.getCurrentPubkey() : null,
|
||||
safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey),
|
||||
getSigner: () => getActiveSigner(),
|
||||
signAndPublishEvent: (event) => nostrClient.signAndPublishEvent(event),
|
||||
});
|
||||
this.eventTarget =
|
||||
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
|
||||
@@ -118,9 +125,24 @@ export class UploadModal {
|
||||
this.activeProvider = null;
|
||||
this.isStorageUnlocked = false;
|
||||
this.storageConfigured = false;
|
||||
// Blossom connections have no encrypted secret, so they never need a PIN
|
||||
// unlock — the upload authorizes with the Nostr signer instead.
|
||||
this.activeProviderIsBlossom = false;
|
||||
// #44: per-modal upload-destination override (null = account default).
|
||||
this.selectedConnectionId = null;
|
||||
|
||||
// Live-refresh the storage destination if connections change under an open
|
||||
// modal — e.g. credentials synced from Nostr — so the user doesn't have to
|
||||
// reopen. Scoped to the active user + open state.
|
||||
if (this.storageService?.onConnectionsChanged) {
|
||||
this.storageService.onConnectionsChanged(({ pubkey } = {}) => {
|
||||
if (!this.isVisible) return;
|
||||
const current = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
if (pubkey && current && pubkey !== current) return;
|
||||
void this.refreshStorageFromSync(current);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload State
|
||||
this.videoUploadState = {
|
||||
status: 'idle', // idle, uploading, complete, error
|
||||
@@ -555,9 +577,10 @@ export class UploadModal {
|
||||
e.target.value = ""; // Clear selection
|
||||
return;
|
||||
}
|
||||
if (!this.isStorageUnlocked) {
|
||||
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
|
||||
// #56: unlock inline (silent restore or one passphrase prompt) and
|
||||
// continue with this same file pick instead of erroring.
|
||||
// continue with this same file pick instead of erroring. Blossom has no
|
||||
// secret to unlock — it authorizes with the Nostr signer at upload time.
|
||||
const unlocked = await this.ensureStorageUnlockedForUpload();
|
||||
if (!unlocked) {
|
||||
e.target.value = "";
|
||||
@@ -663,8 +686,9 @@ export class UploadModal {
|
||||
this.resetThumbnailPicker();
|
||||
return;
|
||||
}
|
||||
if (!this.isStorageUnlocked) {
|
||||
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
|
||||
// #56: prompt to unlock inline instead of silently dropping the pick.
|
||||
// Blossom needs no unlock (signer-authorized at upload time).
|
||||
const unlocked = await this.ensureStorageUnlockedForUpload();
|
||||
if (!unlocked) {
|
||||
this.resetThumbnailPicker();
|
||||
@@ -912,6 +936,26 @@ export class UploadModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Re-derive the storage destination after connections change under an open
|
||||
// modal (e.g. a Nostr sync import). Mirrors the open-time flow: a sync import
|
||||
// may re-lock storage with a new master-key envelope, so re-attempt auto-unlock
|
||||
// (Blossom stays keyless), recompute the unlocked state, then reload + repaint.
|
||||
async refreshStorageFromSync(pubkey) {
|
||||
if (!this.storageService || !this.isVisible) return;
|
||||
const key =
|
||||
pubkey || (this.getCurrentPubkey ? this.getCurrentPubkey() : null);
|
||||
try {
|
||||
await this.maybeAutoUnlockStorage(key);
|
||||
this.isStorageUnlocked = key
|
||||
? this.storageService.isUnlocked(key)
|
||||
: false;
|
||||
await this.loadFromStorage();
|
||||
this.updateLockUi();
|
||||
} catch (error) {
|
||||
devLogger.warn?.("[UploadModal] storage-sync refresh failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromStorage() {
|
||||
if (!this.storageService) return;
|
||||
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
|
||||
@@ -933,12 +977,20 @@ export class UploadModal {
|
||||
if (targetConn) {
|
||||
this.storageConfigured = true;
|
||||
this.activeProvider = targetConn.provider;
|
||||
this.activeProviderIsBlossom = isBlossomProvider(targetConn.provider);
|
||||
|
||||
// UI Updates
|
||||
this.toggleStorageView("summary");
|
||||
const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider);
|
||||
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
|
||||
const urlStyle = this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
|
||||
const blossomServers = Array.isArray(targetConn.meta?.servers)
|
||||
? targetConn.meta.servers
|
||||
: [];
|
||||
const bucketName = this.activeProviderIsBlossom
|
||||
? `${blossomServers.length} server${blossomServers.length === 1 ? "" : "s"}`
|
||||
: targetConn.meta?.bucket || "Unknown Bucket";
|
||||
const urlStyle = this.activeProviderIsBlossom
|
||||
? "Nostr-native (content-addressed)"
|
||||
: this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
|
||||
|
||||
if (this.statusText.summaryProvider) {
|
||||
this.statusText.summaryProvider.textContent = providerName;
|
||||
@@ -954,7 +1006,14 @@ export class UploadModal {
|
||||
}
|
||||
if (this.statusText.summaryCopy) this.statusText.summaryCopy.textContent = this.getSummaryCopy(targetConn.provider);
|
||||
|
||||
if (this.isStorageUnlocked) {
|
||||
if (this.activeProviderIsBlossom) {
|
||||
// No secret to decrypt — servers live in the plaintext meta.
|
||||
this.activeCredentials = {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
servers: blossomServers,
|
||||
meta: targetConn.meta || {},
|
||||
};
|
||||
} else if (this.isStorageUnlocked) {
|
||||
const details = await this.storageService.getConnection(pubkey, targetConn.id);
|
||||
if (details) {
|
||||
this.activeCredentials = details;
|
||||
@@ -969,6 +1028,7 @@ export class UploadModal {
|
||||
this.storageConfigured = false;
|
||||
this.activeCredentials = null;
|
||||
this.activeProvider = null;
|
||||
this.activeProviderIsBlossom = false;
|
||||
this.toggleStorageView("empty");
|
||||
}
|
||||
this.updateLockUi();
|
||||
@@ -1063,7 +1123,8 @@ export class UploadModal {
|
||||
}
|
||||
|
||||
updateLockUi() {
|
||||
const locked = !this.isStorageUnlocked;
|
||||
// Blossom has no secret to unlock — never show it as "locked".
|
||||
const locked = !this.isStorageUnlocked && !this.activeProviderIsBlossom;
|
||||
if (this.statusText?.storageLock) {
|
||||
this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓";
|
||||
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
|
||||
@@ -1079,6 +1140,9 @@ export class UploadModal {
|
||||
}
|
||||
|
||||
getProviderLabel(provider) {
|
||||
if (isBlossomProvider(provider)) {
|
||||
return "Blossom";
|
||||
}
|
||||
if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") {
|
||||
return "Cloudflare R2";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -155,6 +156,8 @@ export class VideoCard {
|
||||
this.moderationBadgeSlot = null;
|
||||
this.hiddenSummaryEl = null;
|
||||
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
|
||||
this.boundTrustAuthorHandler = (event) =>
|
||||
this.handleTrustAuthorClick(event);
|
||||
this.boundModerationBlockHandler = (event) =>
|
||||
this.handleModerationBlockClick(event);
|
||||
this.boundModerationHideHandler = (event) =>
|
||||
@@ -1004,7 +1007,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;
|
||||
@@ -1205,6 +1212,44 @@ export class VideoCard {
|
||||
return button;
|
||||
}
|
||||
|
||||
// Account-level ("trusted creator") action shown alongside "Show anyway":
|
||||
// turns off the web-of-trust warning for EVERY video by this creator. Fires a
|
||||
// document event that the app handles (sets the per-author override + refresh)
|
||||
// — no per-renderer callback plumbing needed.
|
||||
createModerationTrustAuthorButton() {
|
||||
const button = this.createElement("button", {
|
||||
classNames: ["moderation-badge__action", "flex-shrink-0"],
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-moderation-action": "trust-author",
|
||||
"aria-describedby": this.getModerationBadgeId(),
|
||||
"aria-label": "Always show videos from this creator",
|
||||
},
|
||||
textContent: "Always show creator",
|
||||
});
|
||||
button.addEventListener("click", this.boundTrustAuthorHandler);
|
||||
return button;
|
||||
}
|
||||
|
||||
handleTrustAuthorClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
const pubkey =
|
||||
this.video?.pubkey || this.video?.author?.pubkey || "";
|
||||
if (!pubkey || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("video:trust-author", { detail: { pubkey } }),
|
||||
);
|
||||
} catch (error) {
|
||||
userLogger.warn("[VideoCard] trust-author dispatch failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
createModerationHideButton() {
|
||||
const { text: label, ariaLabel } = getModerationOverrideActionLabels({
|
||||
overrideActive: true,
|
||||
@@ -1633,6 +1678,7 @@ export class VideoCard {
|
||||
actions.appendChild(showButton);
|
||||
this.moderationActionButton = showButton;
|
||||
this.moderationActionButtonMode = "override";
|
||||
actions.appendChild(this.createModerationTrustAuthorButton());
|
||||
}
|
||||
hasActions = true;
|
||||
} else {
|
||||
|
||||
@@ -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";
|
||||
@@ -250,6 +251,7 @@ export class VideoModal {
|
||||
this.moderationBadgeText = null;
|
||||
this.moderationActionsContainer = null;
|
||||
this.moderationPrimaryButton = null;
|
||||
this.moderationTrustAuthorButton = null;
|
||||
this.moderationBlockButton = null;
|
||||
this.moderationPrimaryMode = "";
|
||||
this.moderationBadgeId = "";
|
||||
@@ -267,6 +269,8 @@ export class VideoModal {
|
||||
this.handleModerationHideClick.bind(this);
|
||||
this.handleModerationBlockClick =
|
||||
this.handleModerationBlockClick.bind(this);
|
||||
this.handleModerationTrustAuthorClick =
|
||||
this.handleModerationTrustAuthorClick.bind(this);
|
||||
this.handleGlobalModerationOverride =
|
||||
this.handleGlobalModerationOverride.bind(this);
|
||||
this.handleGlobalModerationBlock =
|
||||
@@ -555,6 +559,12 @@ export class VideoModal {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this.moderationTrustAuthorButton) {
|
||||
this.moderationTrustAuthorButton.removeEventListener(
|
||||
"click",
|
||||
this.handleModerationTrustAuthorClick,
|
||||
);
|
||||
}
|
||||
if (this.moderationBlockButton) {
|
||||
this.moderationBlockButton.removeEventListener(
|
||||
"click",
|
||||
@@ -567,6 +577,7 @@ export class VideoModal {
|
||||
this.moderationActionsContainer = null;
|
||||
this.moderationPrimaryButton = null;
|
||||
this.moderationPrimaryMode = "";
|
||||
this.moderationTrustAuthorButton = null;
|
||||
this.moderationBlockButton = null;
|
||||
|
||||
const previousScrollRegion = this.scrollRegion;
|
||||
@@ -2008,8 +2019,28 @@ export class VideoModal {
|
||||
}
|
||||
actionsAttached = true;
|
||||
}
|
||||
|
||||
if (mode === "override") {
|
||||
const trustButton = this.ensureModerationTrustAuthorButton();
|
||||
if (trustButton) {
|
||||
trustButton.disabled = false;
|
||||
trustButton.removeAttribute("aria-busy");
|
||||
if (badgeId) {
|
||||
trustButton.setAttribute("aria-describedby", badgeId);
|
||||
} else {
|
||||
trustButton.removeAttribute("aria-describedby");
|
||||
}
|
||||
if (actions && trustButton.parentElement !== actions) {
|
||||
actions.appendChild(trustButton);
|
||||
}
|
||||
actionsAttached = true;
|
||||
}
|
||||
} else {
|
||||
this.removeModerationTrustAuthorButton();
|
||||
}
|
||||
} else {
|
||||
this.removeModerationPrimaryButton();
|
||||
this.removeModerationTrustAuthorButton();
|
||||
}
|
||||
|
||||
if (this.shouldShowModerationBlockAction(context)) {
|
||||
@@ -2159,6 +2190,65 @@ export class VideoModal {
|
||||
this.moderationPrimaryMode = "";
|
||||
}
|
||||
|
||||
ensureModerationTrustAuthorButton() {
|
||||
if (!this.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.moderationTrustAuthorButton) {
|
||||
const button = this.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "moderation-badge__action flex-shrink-0";
|
||||
button.dataset.moderationAction = "trust-author";
|
||||
button.textContent = "Always show creator";
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
"Always show videos from this creator"
|
||||
);
|
||||
button.addEventListener("click", this.handleModerationTrustAuthorClick);
|
||||
this.moderationTrustAuthorButton = button;
|
||||
}
|
||||
|
||||
return this.moderationTrustAuthorButton;
|
||||
}
|
||||
|
||||
removeModerationTrustAuthorButton() {
|
||||
const button = this.moderationTrustAuthorButton;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.removeEventListener("click", this.handleModerationTrustAuthorClick);
|
||||
if (button.parentElement) {
|
||||
button.parentElement.removeChild(button);
|
||||
}
|
||||
|
||||
this.moderationTrustAuthorButton = null;
|
||||
}
|
||||
|
||||
handleModerationTrustAuthorClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
|
||||
const pubkey =
|
||||
this.activeVideo?.pubkey ||
|
||||
this.activeVideo?.author?.pubkey ||
|
||||
"";
|
||||
if (!pubkey || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("video:trust-author", { detail: { pubkey } })
|
||||
);
|
||||
} catch (error) {
|
||||
devLogger?.warn?.("[VideoModal] trust-author dispatch failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
ensureModerationBlockButton() {
|
||||
if (!this.document) {
|
||||
return null;
|
||||
@@ -2464,6 +2554,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 || "";
|
||||
@@ -3356,6 +3450,81 @@ export class VideoModal {
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click handler for the share popover's menu-action buttons
|
||||
// (Copy URL / Copy Magnet / Copy CDN / Share on Nostr). The forced-source
|
||||
// test links self-wire their own copy, so they are skipped here.
|
||||
bindShareMenuActions(panel) {
|
||||
if (!panel || typeof panel.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
panel.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
const button =
|
||||
target && typeof target.closest === "function"
|
||||
? target.closest("button[data-action]")
|
||||
: null;
|
||||
if (!button || button.disabled) {
|
||||
return;
|
||||
}
|
||||
const action = button.dataset?.action || "";
|
||||
if (action === "copy-webtorrent-url" || action === "copy-cdn-url") {
|
||||
return; // self-wired in createVideoShareMenuPanel
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.handleShareMenuAction(action);
|
||||
this.modalSharePopover?.close?.();
|
||||
});
|
||||
}
|
||||
|
||||
handleShareMenuAction(action) {
|
||||
const video = this.activeVideo;
|
||||
switch (action) {
|
||||
case "copy-magnet":
|
||||
this.dispatch("video:copy-magnet", { video });
|
||||
break;
|
||||
case "copy-cdn":
|
||||
this.dispatch("video:copy-cdn", { video });
|
||||
break;
|
||||
case "share-nostr":
|
||||
this.dispatch("video:share-nostr", {
|
||||
video,
|
||||
trigger: this.shareBtn || null,
|
||||
});
|
||||
break;
|
||||
case "share":
|
||||
// "Copy URL" — the shareable deep link to this video.
|
||||
this.dispatch("video:copy-url", {
|
||||
video,
|
||||
url: this.buildShareUrl(video),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Plain `?v=<nevent>` share link (no forced playback source).
|
||||
buildShareUrl(video) {
|
||||
const eventId =
|
||||
video && typeof video.id === "string" ? video.id.trim() : "";
|
||||
if (!eventId) {
|
||||
return "";
|
||||
}
|
||||
const win = this.window || globalThis;
|
||||
const neventEncode = win?.NostrTools?.nip19?.neventEncode;
|
||||
const loc = win?.location;
|
||||
if (typeof neventEncode !== "function" || !loc) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const nevent = neventEncode({ id: eventId });
|
||||
return `${loc.origin}${loc.pathname}?v=${encodeURIComponent(nevent)}`;
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
setupModalSharePopover() {
|
||||
if (!this.playerModal || !this.shareBtn) {
|
||||
return;
|
||||
@@ -3389,6 +3558,10 @@ export class VideoModal {
|
||||
this.buildForcedSourceShareUrl(this.activeVideo, playback),
|
||||
});
|
||||
if (panel) {
|
||||
// The share menu's Copy URL / Copy Magnet / Copy CDN / Share on Nostr
|
||||
// items only set `data-action` (like the ⋯ menu) — unlike the ⋯ menu,
|
||||
// nothing wired their clicks, so they were dead. Delegate them here.
|
||||
this.bindShareMenuActions(panel);
|
||||
container.appendChild(panel);
|
||||
}
|
||||
return panel;
|
||||
|
||||
@@ -24,6 +24,13 @@ import {
|
||||
buildStoragePointerValue,
|
||||
buildStoragePrefixFromKey,
|
||||
} from "../../utils/storagePointer.js";
|
||||
import blossomServiceDefault, {
|
||||
BLOSSOM_PROVIDER,
|
||||
isBlossomProvider,
|
||||
resolveBlossomServers,
|
||||
} from "../../services/blossomService.js";
|
||||
import { buildTorrentMetadataEvent } from "../../nostrEventSchemas.js";
|
||||
import { FEATURE_BLOSSOM_TORRENT_METADATA } from "../../constants.js";
|
||||
|
||||
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
||||
|
||||
@@ -46,6 +53,9 @@ export class MediaUploader {
|
||||
storageService,
|
||||
getCurrentPubkey,
|
||||
safeEncodeNpub,
|
||||
blossomService,
|
||||
getSigner,
|
||||
signAndPublishEvent,
|
||||
} = {}) {
|
||||
this.r2Service = r2Service || null;
|
||||
this.s3Service = s3Service || null;
|
||||
@@ -54,6 +64,15 @@ export class MediaUploader {
|
||||
typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
|
||||
this.safeEncodeNpub =
|
||||
typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
|
||||
// Blossom (nostr-native) upload path. Only exercised when a connection's
|
||||
// provider is "blossom", so S3/R2 uploads are completely unaffected.
|
||||
this.blossomService = blossomService || blossomServiceDefault;
|
||||
this.getSigner = typeof getSigner === "function" ? getSigner : null;
|
||||
// Sign + publish an unsigned Nostr event to the user's relays. Used only to
|
||||
// publish the WebTorrent piece-map companion event for Blossom videos when
|
||||
// FEATURE_BLOSSOM_TORRENT_METADATA is on. See docs/blossom-torrent-metadata-plan.md.
|
||||
this.signAndPublishEvent =
|
||||
typeof signAndPublishEvent === "function" ? signAndPublishEvent : null;
|
||||
}
|
||||
|
||||
serviceFor(provider) {
|
||||
@@ -99,6 +118,21 @@ export class MediaUploader {
|
||||
base.configured = true;
|
||||
base.provider = targetConn.provider;
|
||||
|
||||
// Blossom has no encrypted secret — its server list lives in the plaintext
|
||||
// connection meta, so credentials are available without unlocking storage
|
||||
// (uploads authorize with the Nostr signer, not a stored key).
|
||||
if (isBlossomProvider(targetConn.provider)) {
|
||||
base.unlocked = true;
|
||||
base.credentials = {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
servers: Array.isArray(targetConn.meta?.servers)
|
||||
? targetConn.meta.servers
|
||||
: [],
|
||||
meta: targetConn.meta || {},
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
if (base.unlocked) {
|
||||
const details = await this.storageService.getConnection(
|
||||
pubkey,
|
||||
@@ -120,6 +154,9 @@ export class MediaUploader {
|
||||
// routing when the modal's provider state is stale.
|
||||
const effectiveProvider =
|
||||
credentials?.provider || credentials?.meta?.provider || provider;
|
||||
if (isBlossomProvider(effectiveProvider)) {
|
||||
return this.uploadThumbnailToBlossom(file, { credentials, onProgress });
|
||||
}
|
||||
const service = this.serviceFor(effectiveProvider);
|
||||
|
||||
const { settings, bucketEntry } = await service.prepareUpload(npub, {
|
||||
@@ -214,6 +251,9 @@ export class MediaUploader {
|
||||
// R2 connection (accountId, no endpoint) through the S3 path — or vice-versa.
|
||||
const effectiveProvider =
|
||||
credentials?.provider || credentials?.meta?.provider || provider;
|
||||
if (isBlossomProvider(effectiveProvider)) {
|
||||
return this.uploadVideoToBlossom(file, { credentials, onProgress });
|
||||
}
|
||||
const service = this.serviceFor(effectiveProvider);
|
||||
|
||||
const { settings, bucketEntry } = await service.prepareUpload(npub, {
|
||||
@@ -313,6 +353,92 @@ export class MediaUploader {
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Blossom (nostr-native blob storage) path --------------------------------
|
||||
// Thin wiring only: resolve servers + signer, then delegate the orchestration
|
||||
// to blossomService (which owns upload + torrent + magnet + storagePointer and
|
||||
// stays WebTorrent-free / unit-testable). The torrent generator is injected so
|
||||
// blossomService never imports WebTorrent. See docs/blossom-plan.md.
|
||||
|
||||
// Adapt bitvid's active signer (object with signEvent) to the SDK's signer
|
||||
// shape: an async (eventTemplate) → signed event.
|
||||
async resolveBlossomSigner() {
|
||||
const signer = this.getSigner ? await this.getSigner() : null;
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
throw new Error("A Nostr signer is required to upload to Blossom.");
|
||||
}
|
||||
return (draft) => signer.signEvent(draft);
|
||||
}
|
||||
|
||||
async uploadThumbnailToBlossom(file, { credentials, onProgress } = {}) {
|
||||
const servers = resolveBlossomServers(credentials);
|
||||
if (servers.length === 0) {
|
||||
throw new Error("No Blossom servers configured for this connection.");
|
||||
}
|
||||
const signer = await this.resolveBlossomSigner();
|
||||
const uploaded = await this.blossomService.uploadFile({
|
||||
file,
|
||||
servers,
|
||||
signer,
|
||||
type: "upload",
|
||||
onProgress: (fraction) => {
|
||||
if (typeof onProgress === "function") onProgress(fraction);
|
||||
},
|
||||
});
|
||||
return { url: uploaded.url, key: uploaded.key };
|
||||
}
|
||||
|
||||
// Build the flag-gated companion-metadata publisher passed to blossomService
|
||||
// (Tier 2). Returns undefined unless FEATURE_BLOSSOM_TORRENT_METADATA is on, a
|
||||
// publish path is wired, and the active pubkey is known — in which case
|
||||
// blossomService only calls it when the .torrent couldn't be hosted.
|
||||
resolveTorrentMetadataPublisher() {
|
||||
if (!FEATURE_BLOSSOM_TORRENT_METADATA || !this.signAndPublishEvent) {
|
||||
return undefined;
|
||||
}
|
||||
const pubkey =
|
||||
typeof this.getCurrentPubkey === "function" ? this.getCurrentPubkey() : "";
|
||||
if (!pubkey || typeof pubkey !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return async ({ infoHash, torrentBase64 }) => {
|
||||
const event = buildTorrentMetadataEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
infoHash,
|
||||
torrentBase64,
|
||||
});
|
||||
await this.signAndPublishEvent(event);
|
||||
};
|
||||
}
|
||||
|
||||
async uploadVideoToBlossom(file, { credentials, onProgress } = {}) {
|
||||
const emit = (fraction, label) => {
|
||||
if (typeof onProgress === "function") onProgress({ fraction, label });
|
||||
};
|
||||
const servers = resolveBlossomServers(credentials);
|
||||
if (servers.length === 0) {
|
||||
throw new Error("No Blossom servers configured for this connection.");
|
||||
}
|
||||
const signer = await this.resolveBlossomSigner();
|
||||
|
||||
emit(0, "Uploading video to Blossom...");
|
||||
const result = await this.blossomService.uploadVideo({
|
||||
file,
|
||||
servers,
|
||||
signer,
|
||||
generateTorrent: (args) => this.generateTorrentMetadata(args),
|
||||
onProgress: (fraction) => emit(fraction, null),
|
||||
publishTorrentMetadata: this.resolveTorrentMetadataPublisher(),
|
||||
});
|
||||
emit(
|
||||
1,
|
||||
result.hasValidInfoHash
|
||||
? "Ready to publish!"
|
||||
: "Upload complete (No torrent fallback).",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external
|
||||
* link keeps the P2P benefit. Best-effort: fetches the remote file (CORS-
|
||||
@@ -400,4 +526,4 @@ export class MediaUploader {
|
||||
}
|
||||
}
|
||||
|
||||
export { isR2Provider, normalizeInfoHash, isValidInfoHash };
|
||||
export { isR2Provider, isBlossomProvider, normalizeInfoHash, isValidInfoHash };
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { devLogger } from "../utils/logger.js";
|
||||
import r2Service from "../services/r2Service.js";
|
||||
import blossomService, {
|
||||
blobSha256FromUrl,
|
||||
} from "../services/blossomService.js";
|
||||
import { getActiveSigner } from "../nostrClientRegistry.js";
|
||||
import { readRelayPublishSummary } from "../nostrPublish.js";
|
||||
|
||||
export default class EditModalController {
|
||||
@@ -125,6 +129,26 @@ export default class EditModalController {
|
||||
`[edit] Removed ${cleanup.deleted.length} superseded storage object(s).`
|
||||
);
|
||||
}
|
||||
|
||||
// Blossom orphans are content-addressed blobs, not R2/S3 keys — delete
|
||||
// them via BUD-12 (best-effort, from each blob URL's own origin).
|
||||
const blossomUrls = orphans
|
||||
.map((o) => o.url || o.thumbnail || "")
|
||||
.filter((u) => blobSha256FromUrl(u));
|
||||
if (blossomUrls.length) {
|
||||
const activeSigner = getActiveSigner();
|
||||
if (activeSigner && typeof activeSigner.signEvent === "function") {
|
||||
const signer = (draft) => activeSigner.signEvent(draft);
|
||||
for (const url of blossomUrls) {
|
||||
const res = await blossomService.deleteByUrl({ url, signer });
|
||||
if (res.deleted?.length) {
|
||||
devLogger.log(
|
||||
`[edit] Deleted superseded Blossom blob ${res.sha256}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
devLogger.warn(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,19 +74,32 @@ export function launchBitvidTour({
|
||||
openProfilePane,
|
||||
document: doc,
|
||||
force = false,
|
||||
onEnd,
|
||||
} = {}) {
|
||||
if (!force && !shouldOfferOnboarding(pubkey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fire onEnd on both finish and skip so callers can release anything they
|
||||
// deferred for the tour's duration (e.g. the settings-restore prompt).
|
||||
const end = (status) => {
|
||||
markOnboarding(pubkey, status);
|
||||
try {
|
||||
onEnd?.(status);
|
||||
} catch (error) {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
const tour = createTour({
|
||||
steps: buildBitvidTourSteps({ openProfilePane }),
|
||||
document: doc,
|
||||
onFinish: () => markOnboarding(pubkey, "completed"),
|
||||
onSkip: () => markOnboarding(pubkey, "skipped"),
|
||||
onFinish: () => end("completed"),
|
||||
onSkip: () => end("skipped"),
|
||||
});
|
||||
|
||||
return Boolean(tour?.start());
|
||||
const started = Boolean(tour?.start());
|
||||
return started;
|
||||
}
|
||||
|
||||
export default launchBitvidTour;
|
||||
@@ -57,6 +57,20 @@ const TOUR_CSS = `
|
||||
padding: 1rem;
|
||||
transition: left 0.28s ease, top 0.28s ease;
|
||||
}
|
||||
/* Mobile: dock the popover as a bottom sheet instead of anchoring it to the
|
||||
target. Anchored placement is fragile on phones (short viewports, wrapping
|
||||
buttons, URL-bar resize) — a fixed sheet is always in a predictable spot and
|
||||
the spotlight still highlights the (centered-scrolled) target above it. */
|
||||
.bv-tour-popover.bv-tour-popover--sheet {
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: auto;
|
||||
bottom: max(0.75rem, env(safe-area-inset-bottom, 0px));
|
||||
transform: none;
|
||||
width: auto;
|
||||
max-width: 32rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.bv-tour-popover h3 { font-size: 1rem; font-weight: 700; margin: 0 0 0.375rem; }
|
||||
.bv-tour-popover p { font-size: 0.875rem; margin: 0 0 0.75rem; opacity: 0.9; }
|
||||
.bv-tour-dots { display: flex; gap: 0.375rem; margin-bottom: 0.75rem; }
|
||||
@@ -171,10 +185,13 @@ export function createTour({
|
||||
}
|
||||
};
|
||||
|
||||
const isMobileViewport = () => (doc.defaultView?.innerWidth || 1200) < 640;
|
||||
|
||||
function measure(step) {
|
||||
const target = step.target ? doc.querySelector(step.target) : null;
|
||||
if (!target) {
|
||||
// Centered card: zero-size cutout in the middle (pure dim).
|
||||
state.popover.classList.remove("bv-tour-popover--sheet");
|
||||
setVar(state.scrim, "--bv-tour-x", "50vw");
|
||||
setVar(state.scrim, "--bv-tour-y", "50vh");
|
||||
setVar(state.scrim, "--bv-tour-w", "0px");
|
||||
@@ -187,8 +204,13 @@ export function createTour({
|
||||
return;
|
||||
}
|
||||
|
||||
const mobile = isMobileViewport();
|
||||
try {
|
||||
target.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
// Center the target on mobile so it sits above the bottom sheet.
|
||||
target.scrollIntoView({
|
||||
block: mobile ? "center" : "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
} catch (error) {
|
||||
// older engines
|
||||
}
|
||||
@@ -200,11 +222,23 @@ export function createTour({
|
||||
setVar(state.scrim, "--bv-tour-w", `${rect.width + pad * 2}px`);
|
||||
setVar(state.scrim, "--bv-tour-h", `${rect.height + pad * 2}px`);
|
||||
|
||||
if (mobile) {
|
||||
// Bottom sheet — no anchored-placement math to get wrong.
|
||||
state.popover.classList.add("bv-tour-popover--sheet");
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: anchor, but measure the ACTUAL popover so the fit checks are
|
||||
// accurate (the old 240px/336px guesses pushed tall popovers off-screen).
|
||||
state.popover.classList.remove("bv-tour-popover--sheet");
|
||||
const popRect = state.popover.getBoundingClientRect();
|
||||
const placementResult = computeTourPlacement({
|
||||
rect,
|
||||
viewportWidth: doc.defaultView?.innerWidth || 1200,
|
||||
viewportHeight: doc.defaultView?.innerHeight || 800,
|
||||
preferred: step.placement || "",
|
||||
popWidth: popRect.width || 336,
|
||||
popHeight: popRect.height || 240,
|
||||
});
|
||||
setVar(state.popover, "--bv-tour-px", `${placementResult.px}px`);
|
||||
setVar(state.popover, "--bv-tour-py", `${placementResult.py}px`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -161,8 +161,7 @@ export class ProfileAdminRenderer {
|
||||
|
||||
values.forEach((npub) => {
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
|
||||
item.className = "card profile-account-card";
|
||||
|
||||
const normalizedNpub = typeof npub === "string" ? npub.trim() : "";
|
||||
const comparableNpub =
|
||||
@@ -216,8 +215,7 @@ export class ProfileAdminRenderer {
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className =
|
||||
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
|
||||
actions.className = "profile-account-card__actions";
|
||||
|
||||
const viewButton = this.mainController.createViewChannelButton({
|
||||
targetNpub: encodedNpub,
|
||||
|
||||
@@ -282,8 +282,7 @@ export class ProfileBlockListController {
|
||||
|
||||
deduped.forEach(({ hex, label }) => {
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"card flex items-center justify-between gap-4 p-4";
|
||||
item.className = "card profile-account-card";
|
||||
|
||||
let cachedProfile = null;
|
||||
if (hex) {
|
||||
@@ -311,7 +310,7 @@ export class ProfileBlockListController {
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex flex-wrap items-center justify-end gap-2";
|
||||
actions.className = "profile-account-card__actions";
|
||||
|
||||
const viewButton = this.mainController.createViewChannelButton({
|
||||
targetNpub: encodedNpub,
|
||||
|
||||
@@ -47,6 +47,11 @@ export class ProfileModerationController {
|
||||
this.moderationStatusText = document.getElementById("profileModerationStatus") || null;
|
||||
this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null;
|
||||
this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null;
|
||||
this.authorOverrideInput = document.getElementById("profileAuthorOverrideInput") || null;
|
||||
this.authorOverrideAddBtn = document.getElementById("profileAuthorOverrideAddBtn") || null;
|
||||
this.authorOverrideStatus = document.getElementById("profileAuthorOverrideStatus") || null;
|
||||
this.authorOverridesList = document.getElementById("profileAuthorOverridesList") || null;
|
||||
this.authorOverridesEmpty = document.getElementById("profileAuthorOverridesEmpty") || null;
|
||||
this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null;
|
||||
this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null;
|
||||
this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null;
|
||||
@@ -102,9 +107,24 @@ export class ProfileModerationController {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.authorOverrideAddBtn instanceof HTMLElement) {
|
||||
this.authorOverrideAddBtn.addEventListener("click", () => {
|
||||
void this.handleAddAuthorOverride();
|
||||
});
|
||||
}
|
||||
if (this.authorOverrideInput instanceof HTMLInputElement) {
|
||||
this.authorOverrideInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void this.handleAddAuthorOverride();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
|
||||
this.boundModerationOverridesUpdate = () => {
|
||||
this.refreshModerationOverridesUi();
|
||||
this.refreshAuthorOverridesUi();
|
||||
};
|
||||
document.addEventListener(
|
||||
"video:moderation-override",
|
||||
@@ -501,7 +521,7 @@ export class ProfileModerationController {
|
||||
item.className = "card space-y-2 p-4";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "flex items-center justify-between gap-4";
|
||||
row.className = "profile-account-card__row";
|
||||
|
||||
const authorKey = entry.authorPubkey;
|
||||
let profileSummary = null;
|
||||
@@ -516,7 +536,7 @@ export class ProfileModerationController {
|
||||
profileSummary = this.mainController.dmController.createCompactProfileSummary(summaryData);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex flex-wrap items-center justify-end gap-2";
|
||||
actions.className = "profile-account-card__actions";
|
||||
|
||||
const resetButton = this.mainController.createRemoveButton({
|
||||
label: "Reset",
|
||||
@@ -569,6 +589,159 @@ export class ProfileModerationController {
|
||||
}
|
||||
}
|
||||
|
||||
setAuthorOverrideStatus(message) {
|
||||
if (this.authorOverrideStatus instanceof HTMLElement) {
|
||||
this.authorOverrideStatus.textContent = message || "";
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddAuthorOverride() {
|
||||
const input =
|
||||
this.authorOverrideInput instanceof HTMLInputElement
|
||||
? this.authorOverrideInput.value.trim()
|
||||
: "";
|
||||
if (!input) {
|
||||
this.setAuthorOverrideStatus("Enter a creator npub.");
|
||||
return false;
|
||||
}
|
||||
const hex = this.mainController.normalizeHexPubkey(input);
|
||||
if (!hex) {
|
||||
this.setAuthorOverrideStatus("That doesn't look like a valid npub.");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof this.mainController.services.setAuthorModerationOverride !==
|
||||
"function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.mainController.services.setAuthorModerationOverride(hex);
|
||||
if (this.authorOverrideInput instanceof HTMLInputElement) {
|
||||
this.authorOverrideInput.value = "";
|
||||
}
|
||||
this.setAuthorOverrideStatus("");
|
||||
this.refreshAuthorOverridesUi();
|
||||
this.mainController.showSuccess("Warnings turned off for this creator.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.mainController.showError(
|
||||
"Couldn't add that creator. Please try again.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthorOverrideReset(entry) {
|
||||
if (
|
||||
!entry ||
|
||||
!entry.authorPubkey ||
|
||||
typeof this.mainController.services.clearAuthorModerationOverride !==
|
||||
"function"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.mainController.services.clearAuthorModerationOverride(
|
||||
entry.authorPubkey,
|
||||
);
|
||||
this.refreshAuthorOverridesUi();
|
||||
this.mainController.showSuccess("Warnings restored for this creator.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.mainController.showError("Unable to reset this creator.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
refreshAuthorOverridesUi() {
|
||||
if (
|
||||
!(this.authorOverridesList instanceof HTMLElement) ||
|
||||
!(this.authorOverridesEmpty instanceof HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const services = this.mainController.services;
|
||||
const raw =
|
||||
typeof services.getAuthorModerationOverrides === "function"
|
||||
? services.getAuthorModerationOverrides()
|
||||
: [];
|
||||
const entries = (Array.isArray(raw) ? raw : [])
|
||||
.map((entry) => ({
|
||||
authorPubkey:
|
||||
typeof entry?.authorPubkey === "string"
|
||||
? this.mainController.normalizeHexPubkey(entry.authorPubkey) ||
|
||||
entry.authorPubkey
|
||||
: "",
|
||||
updatedAt: Number.isFinite(entry?.updatedAt)
|
||||
? Math.floor(entry.updatedAt)
|
||||
: 0,
|
||||
}))
|
||||
.filter((entry) => entry.authorPubkey)
|
||||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||
|
||||
this.authorOverridesList.textContent = "";
|
||||
|
||||
if (!entries.length) {
|
||||
this.authorOverridesEmpty.classList.remove("hidden");
|
||||
this.authorOverridesList.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
this.authorOverridesEmpty.classList.add("hidden");
|
||||
this.authorOverridesList.classList.remove("hidden");
|
||||
|
||||
const entriesNeedingFetch = new Set();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "card profile-account-card";
|
||||
|
||||
const authorKey = entry.authorPubkey;
|
||||
if (
|
||||
authorKey &&
|
||||
typeof services.getProfileCacheEntry === "function" &&
|
||||
!services.getProfileCacheEntry(authorKey)
|
||||
) {
|
||||
entriesNeedingFetch.add(authorKey);
|
||||
}
|
||||
|
||||
const summaryData =
|
||||
this.mainController.dmController.resolveProfileSummaryForPubkey(
|
||||
authorKey,
|
||||
);
|
||||
const summary =
|
||||
this.mainController.dmController.createCompactProfileSummary(
|
||||
summaryData,
|
||||
);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "profile-account-card__actions";
|
||||
const resetButton = this.mainController.createRemoveButton({
|
||||
label: "Reset",
|
||||
onRemove: () => this.handleAuthorOverrideReset(entry),
|
||||
});
|
||||
|
||||
if (summary) {
|
||||
item.appendChild(summary);
|
||||
}
|
||||
if (resetButton) {
|
||||
actions.appendChild(resetButton);
|
||||
item.appendChild(actions);
|
||||
}
|
||||
|
||||
this.authorOverridesList.appendChild(item);
|
||||
});
|
||||
|
||||
if (
|
||||
entriesNeedingFetch.size &&
|
||||
typeof services.batchFetchProfiles === "function"
|
||||
) {
|
||||
services.batchFetchProfiles(entriesNeedingFetch);
|
||||
}
|
||||
}
|
||||
|
||||
refreshModerationSettingsUi() {
|
||||
const service = this.getModerationSettingsService();
|
||||
if (!service) {
|
||||
@@ -620,6 +793,7 @@ export class ProfileModerationController {
|
||||
this.updateTrustedHideControlsVisibility();
|
||||
this.updateModerationTrustStats();
|
||||
this.refreshModerationOverridesUi();
|
||||
this.refreshAuthorOverridesUi();
|
||||
|
||||
this.applyModerationSettingsControlState({ resetStatus: true });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
derivePublicBaseUrl,
|
||||
} from "../../services/s3Service.js";
|
||||
import { getActiveSigner } from "../../nostr/client.js";
|
||||
import { FEATURE_BLOSSOM_STORAGE } from "../../constants.js";
|
||||
import { BLOSSOM_PROVIDER } from "../../services/blossomService.js";
|
||||
import {
|
||||
importBlossomServerList,
|
||||
publishBlossomServerList,
|
||||
} from "./blossomServerList.js";
|
||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
||||
import { storageSyncService } from "../../services/storageSyncService.js";
|
||||
import { StorageCorsHelp } from "./storageCorsHelp.js";
|
||||
@@ -97,6 +103,47 @@ export class ProfileStorageController {
|
||||
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
|
||||
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
|
||||
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
|
||||
|
||||
// Blossom (nostr-native) — keyless multi-server storage.
|
||||
this.storageS3Fields = document.getElementById("storageS3Fields") || null;
|
||||
this.storageBlossomSection =
|
||||
document.getElementById("storageBlossomSection") || null;
|
||||
this.storageBlossomServersInput =
|
||||
document.getElementById("storageBlossomServers") || null;
|
||||
this.storageBlossomImportBtn =
|
||||
document.getElementById("storageBlossomImportBtn") || null;
|
||||
this.storageBlossomPublishBtn =
|
||||
document.getElementById("storageBlossomPublishBtn") || null;
|
||||
this.storageBlossomServerListStatus =
|
||||
document.getElementById("storageBlossomServerListStatus") || null;
|
||||
this.storageProviderBlossomOption =
|
||||
document.getElementById("storageProviderBlossomOption") || null;
|
||||
// Reveal the Blossom provider option only when the deployment enables it.
|
||||
if (
|
||||
FEATURE_BLOSSOM_STORAGE === true &&
|
||||
this.storageProviderBlossomOption instanceof HTMLElement
|
||||
) {
|
||||
this.storageProviderBlossomOption.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
parseBlossomServers() {
|
||||
const raw = this.storageBlossomServersInput?.value || "";
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const line of raw.split(/[\n,]/)) {
|
||||
const url = line.trim().replace(/\/+$/, "");
|
||||
if (!url || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
// Only accept absolute http(s) server URLs.
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
@@ -124,6 +171,18 @@ export class ProfileStorageController {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.storageBlossomImportBtn instanceof HTMLElement) {
|
||||
this.storageBlossomImportBtn.addEventListener("click", () => {
|
||||
void this.handleImportBlossomServerList();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.storageBlossomPublishBtn instanceof HTMLElement) {
|
||||
this.storageBlossomPublishBtn.addEventListener("click", () => {
|
||||
void this.handlePublishBlossomServerList();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.storageProviderInput instanceof HTMLElement) {
|
||||
this.storageProviderInput.addEventListener("change", () => {
|
||||
void this.handleProviderChange();
|
||||
@@ -336,7 +395,35 @@ export class ProfileStorageController {
|
||||
this.mainController.getActivePubkey(),
|
||||
);
|
||||
const storageService = this.mainController.services?.storageService;
|
||||
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
|
||||
if (!pubkey || !storageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProvider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom servers live in plaintext connection meta — load them without an
|
||||
// unlock (there's no encrypted secret to decrypt).
|
||||
if (selectedProvider === BLOSSOM_PROVIDER) {
|
||||
try {
|
||||
const connections = await storageService.listConnections(pubkey);
|
||||
const match = findProviderConnection(connections, BLOSSOM_PROVIDER);
|
||||
const servers = Array.isArray(match?.meta?.servers)
|
||||
? match.meta.servers
|
||||
: [];
|
||||
if (this.storageBlossomServersInput instanceof HTMLElement) {
|
||||
this.storageBlossomServersInput.value = servers.join("\n");
|
||||
}
|
||||
if (this.storageDefaultInput instanceof HTMLElement) {
|
||||
this.storageDefaultInput.checked =
|
||||
match?.meta?.defaultForUploads === true;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[ProfileModal] Failed to load Blossom servers:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storageService.isUnlocked?.(pubkey)) {
|
||||
return;
|
||||
}
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
@@ -364,6 +451,21 @@ export class ProfileStorageController {
|
||||
|
||||
updateStorageFormVisibility() {
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom has no bucket/keys/CORS — swap the whole S3 field group for the
|
||||
// keyless multi-server section.
|
||||
const isBlossom = provider === BLOSSOM_PROVIDER;
|
||||
if (this.storageS3Fields instanceof HTMLElement) {
|
||||
this.storageS3Fields.classList.toggle("hidden", isBlossom);
|
||||
}
|
||||
if (this.storageBlossomSection instanceof HTMLElement) {
|
||||
this.storageBlossomSection.classList.toggle("hidden", !isBlossom);
|
||||
}
|
||||
if (isBlossom) {
|
||||
this.corsHelp.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isR2 = provider === "cloudflare_r2";
|
||||
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
|
||||
const isB2 = provider === PROVIDERS.B2;
|
||||
@@ -480,6 +582,50 @@ export class ProfileStorageController {
|
||||
this.storageUnlockFailure = null;
|
||||
}
|
||||
|
||||
setBlossomServerListStatus(message, kind = "info") {
|
||||
if (!(this.storageBlossomServerListStatus instanceof HTMLElement)) return;
|
||||
this.storageBlossomServerListStatus.textContent = message || "";
|
||||
const tone =
|
||||
kind === "error"
|
||||
? "text-status-danger"
|
||||
: kind === "success"
|
||||
? "text-status-success"
|
||||
: "text-muted";
|
||||
this.storageBlossomServerListStatus.className = `text-xs ${tone}`;
|
||||
}
|
||||
|
||||
blossomServerListArgs() {
|
||||
return {
|
||||
client: this.mainController.services?.nostrClient,
|
||||
pubkey: this.mainController.normalizeHexPubkey(
|
||||
this.mainController.getActivePubkey(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async handleImportBlossomServerList() {
|
||||
this.setBlossomServerListStatus("Fetching your server list…");
|
||||
const res = await importBlossomServerList(this.blossomServerListArgs());
|
||||
if (
|
||||
res.servers?.length &&
|
||||
this.storageBlossomServersInput instanceof HTMLElement
|
||||
) {
|
||||
this.storageBlossomServersInput.value = res.servers.join("\n");
|
||||
}
|
||||
if (res.error) devLogger.warn("[ProfileModal] Blossom import:", res.error);
|
||||
this.setBlossomServerListStatus(res.message, res.kind);
|
||||
}
|
||||
|
||||
async handlePublishBlossomServerList() {
|
||||
this.setBlossomServerListStatus("Publishing your server list…");
|
||||
const res = await publishBlossomServerList({
|
||||
...this.blossomServerListArgs(),
|
||||
servers: this.parseBlossomServers(),
|
||||
});
|
||||
if (res.error) devLogger.warn("[ProfileModal] Blossom publish:", res.error);
|
||||
this.setBlossomServerListStatus(res.message, res.kind);
|
||||
}
|
||||
|
||||
// After a page reload, a persisted nsec session restores the logged-in pubkey + UI
|
||||
// but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then
|
||||
// has no usable signer, and the generic "No active signer" error is misleading.
|
||||
@@ -710,6 +856,60 @@ export class ProfileStorageController {
|
||||
if (!storageService) return;
|
||||
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom: keyless. Persist the server list in the (plaintext) connection meta;
|
||||
// there is no secret to encrypt and no bucket/endpoint to validate.
|
||||
if (provider === BLOSSOM_PROVIDER) {
|
||||
const servers = this.parseBlossomServers();
|
||||
if (servers.length === 0) {
|
||||
this.setStorageFormStatus(
|
||||
"Add at least one Blossom server URL (https://…).",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isDefaultBlossom = this.storageDefaultInput?.checked || false;
|
||||
this.setStorageFormStatus("Saving...", "info");
|
||||
try {
|
||||
// Keyless: no secret to encrypt, so no storage unlock needed.
|
||||
await storageService.saveKeylessConnection(pubkey, BLOSSOM_PROVIDER, {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
servers,
|
||||
defaultForUploads: isDefaultBlossom,
|
||||
label: `Blossom (${servers.length} server${servers.length === 1 ? "" : "s"})`,
|
||||
});
|
||||
this.setStorageFormStatus("Blossom servers saved.", "success");
|
||||
this.mainController.showSuccess("Blossom storage saved.");
|
||||
|
||||
// Keep the encrypted synced copy current if the user opted in — the
|
||||
// account record (which carries the Blossom servers in plaintext meta)
|
||||
// is re-encrypted to self and published, so it roams like the S3 path.
|
||||
if (storageSyncService.isEnabled(pubkey)) {
|
||||
try {
|
||||
const syncResult = await storageSyncService.push(pubkey, {
|
||||
confirmOverwrite: () => confirmSyncOverwrite(STORAGE_SYNC_LABEL),
|
||||
});
|
||||
if (syncResult?.ok) {
|
||||
this.setSyncStatus("Synced copy updated.", "success");
|
||||
} else if (syncResult?.conflict) {
|
||||
this.setSyncStatus(
|
||||
"Synced copy on your account is newer — not overwritten.",
|
||||
);
|
||||
}
|
||||
} catch (syncError) {
|
||||
devLogger.warn(
|
||||
"[ProfileModal] Blossom re-sync after save failed:",
|
||||
syncError,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("Failed to save Blossom connection:", error);
|
||||
this.setStorageFormStatus("Failed to save Blossom servers.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
||||
const region = this.storageRegionInput?.value?.trim() || "auto";
|
||||
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
||||
@@ -860,6 +1060,40 @@ export class ProfileStorageController {
|
||||
}
|
||||
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom: no credentials to test. Best-effort reachability ping of each
|
||||
// server (CORS may block it — a failure here doesn't mean upload will fail,
|
||||
// so it's informational). The real verification is an upload.
|
||||
if (provider === BLOSSOM_PROVIDER) {
|
||||
const servers = this.parseBlossomServers();
|
||||
if (servers.length === 0) {
|
||||
this.setStorageFormStatus(
|
||||
"Add at least one Blossom server URL first.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.setStorageFormStatus("Checking servers…", "info");
|
||||
let reachable = 0;
|
||||
await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
try {
|
||||
await fetch(server, { method: "HEAD", mode: "cors" });
|
||||
reachable += 1;
|
||||
} catch {
|
||||
// CORS/offline — inconclusive, not necessarily a failure.
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.setStorageFormStatus(
|
||||
reachable > 0
|
||||
? `Reached ${reachable}/${servers.length} server(s). Upload to fully verify.`
|
||||
: `Couldn't reach the server(s) from the browser (CORS may block a HEAD). Save and try an upload to verify.`,
|
||||
reachable > 0 ? "success" : "info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
||||
const region = this.storageRegionInput?.value?.trim() || "auto";
|
||||
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { devLogger } from "../../utils/logger.js";
|
||||
import { FEATURE_BITCOIN_CONNECT } from "../../constants.js";
|
||||
import { showConfirm } from "../confirmDialog.js";
|
||||
import { NWC_URI_SCHEME } from "../profileModalContract.js";
|
||||
|
||||
// Vendored, pinned Bitcoin Connect bundle (scripts/build-bitcoin-connect.mjs).
|
||||
// Lazy-imported only when the user clicks "Connect wallet", so its ~700KB never
|
||||
// weighs on the main bundle. See docs/bitcoin-connect-plan.md.
|
||||
const BITCOIN_CONNECT_BUNDLE_URL =
|
||||
"../../../vendor/bitcoin-connect.bundle.min.js";
|
||||
import { createWalletSyncService } from "../../services/walletSyncService.js";
|
||||
import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js";
|
||||
import {
|
||||
@@ -32,6 +39,13 @@ export class ProfileWalletController {
|
||||
this.walletSyncRestoreBtn = null;
|
||||
this.walletSyncStatus = null;
|
||||
this._walletSyncService = null;
|
||||
|
||||
this.walletBitcoinConnectRow = null;
|
||||
this.walletBitcoinConnectButton = null;
|
||||
this.walletAdvanced = null;
|
||||
this.walletAdvancedSummary = null;
|
||||
this._bitcoinConnectModulePromise = null;
|
||||
this._bitcoinConnectInitialized = false;
|
||||
}
|
||||
|
||||
cacheDomReferences() {
|
||||
@@ -46,9 +60,40 @@ export class ProfileWalletController {
|
||||
this.walletSyncToggle = document.getElementById("walletSyncToggle") || null;
|
||||
this.walletSyncRestoreBtn = document.getElementById("walletSyncRestoreBtn") || null;
|
||||
this.walletSyncStatus = document.getElementById("walletSyncStatus") || null;
|
||||
this.walletBitcoinConnectRow =
|
||||
document.getElementById("profileWalletBitcoinConnectRow") || null;
|
||||
this.walletBitcoinConnectButton =
|
||||
document.getElementById("profileWalletBitcoinConnect") || null;
|
||||
this.walletAdvanced =
|
||||
document.getElementById("profileWalletAdvanced") || null;
|
||||
this.walletAdvancedSummary =
|
||||
document.getElementById("profileWalletAdvancedSummary") || null;
|
||||
|
||||
// Backwards compatibility alias if needed by tests/external code
|
||||
this.mainController.profileWalletStatusText = this.walletStatusText;
|
||||
|
||||
this.applyBitcoinConnectVisibility();
|
||||
}
|
||||
|
||||
// Bitcoin Connect on ⇒ show the "Connect wallet" button and tuck the manual URI
|
||||
// field under an "Advanced" disclosure (Connect is the primary path, manual the
|
||||
// backup). Off ⇒ hide the button, and force the disclosure open with its summary
|
||||
// hidden so the URI field just shows plainly (unchanged legacy behavior). Off
|
||||
// also means the vendored bundle is never imported.
|
||||
applyBitcoinConnectVisibility() {
|
||||
const enabled = FEATURE_BITCOIN_CONNECT === true;
|
||||
|
||||
if (this.walletBitcoinConnectRow instanceof HTMLElement) {
|
||||
this.walletBitcoinConnectRow.classList.toggle("hidden", !enabled);
|
||||
}
|
||||
|
||||
if (this.walletAdvanced instanceof HTMLElement) {
|
||||
// When disabled, keep the field visible (open) and drop the toggle chrome.
|
||||
this.walletAdvanced.open = !enabled;
|
||||
}
|
||||
if (this.walletAdvancedSummary instanceof HTMLElement) {
|
||||
this.walletAdvancedSummary.classList.toggle("hidden", !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
@@ -90,6 +135,15 @@ export class ProfileWalletController {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
FEATURE_BITCOIN_CONNECT === true &&
|
||||
this.walletBitcoinConnectButton instanceof HTMLElement
|
||||
) {
|
||||
this.walletBitcoinConnectButton.addEventListener("click", () => {
|
||||
void this.handleBitcoinConnect();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.walletSyncToggle instanceof HTMLElement) {
|
||||
this.walletSyncToggle.addEventListener("change", () => {
|
||||
void this.handleToggleSync();
|
||||
@@ -521,6 +575,104 @@ export class ProfileWalletController {
|
||||
return { valid: true, sanitized: value };
|
||||
}
|
||||
|
||||
// Lazy-load + one-time init of the vendored Bitcoin Connect bundle. Restricts
|
||||
// the modal to NWC and requests only the methods bitvid actually calls. Does
|
||||
// NOT let Bitcoin Connect persist the connection — bitvid owns the secret.
|
||||
async ensureBitcoinConnectModule() {
|
||||
if (this._bitcoinConnectModulePromise) {
|
||||
return this._bitcoinConnectModulePromise;
|
||||
}
|
||||
this._bitcoinConnectModulePromise = (async () => {
|
||||
const bc = await import(BITCOIN_CONNECT_BUNDLE_URL);
|
||||
if (!this._bitcoinConnectInitialized) {
|
||||
bc.init({
|
||||
appName: "bitvid",
|
||||
filters: ["nwc"],
|
||||
persistConnection: false,
|
||||
providerConfig: {
|
||||
nwc: {
|
||||
authorizationUrlOptions: {
|
||||
requestMethods: [
|
||||
"pay_invoice",
|
||||
"get_balance",
|
||||
"make_invoice",
|
||||
"lookup_invoice",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// One persistent handler: extract the NWC URI, hand it to the SAME
|
||||
// validate→save path as the manual field, then disconnect so bitvid is
|
||||
// the only holder of the spending secret. NEVER log the URI.
|
||||
bc.onConnected(async (provider) => {
|
||||
const uri =
|
||||
typeof provider?.client?.nostrWalletConnectUrl === "string"
|
||||
? provider.client.nostrWalletConnectUrl
|
||||
: "";
|
||||
try {
|
||||
if (uri && this.walletUriInput instanceof HTMLElement) {
|
||||
this.setSecretInputValue(this.walletUriInput, uri);
|
||||
await this.handleWalletSave();
|
||||
} else if (!uri) {
|
||||
this.updateWalletStatus(
|
||||
"That wallet didn't expose an NWC connection. Paste one manually below.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.error("[wallet] Bitcoin Connect save failed", error);
|
||||
} finally {
|
||||
try {
|
||||
bc.disconnect();
|
||||
} catch (disconnectError) {
|
||||
devLogger.warn(
|
||||
"[wallet] Bitcoin Connect disconnect failed",
|
||||
disconnectError,
|
||||
);
|
||||
}
|
||||
try {
|
||||
bc.closeModal?.();
|
||||
} catch (closeError) {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
});
|
||||
this._bitcoinConnectInitialized = true;
|
||||
}
|
||||
return bc;
|
||||
})().catch((error) => {
|
||||
// Reset so a later click can retry a failed load.
|
||||
this._bitcoinConnectModulePromise = null;
|
||||
throw error;
|
||||
});
|
||||
return this._bitcoinConnectModulePromise;
|
||||
}
|
||||
|
||||
async handleBitcoinConnect() {
|
||||
if (FEATURE_BITCOIN_CONNECT !== true || this.isWalletBusy()) {
|
||||
return;
|
||||
}
|
||||
let bc;
|
||||
try {
|
||||
bc = await this.ensureBitcoinConnectModule();
|
||||
} catch (error) {
|
||||
devLogger.error("[wallet] Failed to load Bitcoin Connect", error);
|
||||
this.updateWalletStatus(
|
||||
"Couldn't load the wallet connector. Try again, or paste a connection manually.",
|
||||
"error",
|
||||
);
|
||||
this.mainController.showError?.("Couldn't load the wallet connector.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bc.launchModal();
|
||||
} catch (error) {
|
||||
devLogger.error("[wallet] Bitcoin Connect launch failed", error);
|
||||
this.updateWalletStatus("Couldn't open the wallet connector.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async handleWalletSave() {
|
||||
const { uri, defaultZap, error } = this.getWalletFormValues();
|
||||
const context = {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// BUD-03 (kind 10063) Blossom server-list import/publish — the relay read/write
|
||||
// logic, extracted from ProfileStorageController so it stays thin and this is
|
||||
// unit-testable. Each function returns a { kind, message, ... } result the UI can
|
||||
// render directly (never throws). See docs/blossom-plan.md (Decision 3).
|
||||
import { serversFromServerListEvent } from "../../services/blossomService.js";
|
||||
import {
|
||||
buildBlossomServerListEvent,
|
||||
BLOSSOM_SERVER_LIST_KIND,
|
||||
} from "../../nostrEventSchemas.js";
|
||||
|
||||
function readRelaysOf(client) {
|
||||
if (Array.isArray(client?.readRelays) && client.readRelays.length) {
|
||||
return client.readRelays;
|
||||
}
|
||||
return Array.isArray(client?.relays) ? client.relays : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the newest kind-10063 server list published by `pubkey` and return its
|
||||
* servers. Does NOT persist — the caller drops them into the form for Save.
|
||||
*
|
||||
* @returns {Promise<{kind:"success"|"error", message:string, servers?:string[], error?:unknown}>}
|
||||
*/
|
||||
export async function importBlossomServerList({ client, pubkey } = {}) {
|
||||
if (!client || typeof client.getSubscriptionManager !== "function" || !pubkey) {
|
||||
return { kind: "error", message: "Log in to import your server list." };
|
||||
}
|
||||
try {
|
||||
const events = await client.getSubscriptionManager().list({
|
||||
filters: [{ kinds: [BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] }],
|
||||
relays: readRelaysOf(client),
|
||||
});
|
||||
const newest = (Array.isArray(events) ? events : [])
|
||||
.filter((e) => e && Array.isArray(e.tags))
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
||||
const servers = newest ? serversFromServerListEvent(newest) : [];
|
||||
if (!servers.length) {
|
||||
return {
|
||||
kind: "error",
|
||||
message: "No published server list found for your account.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "success",
|
||||
message: `Imported ${servers.length} server(s). Click Save to use them.`,
|
||||
servers,
|
||||
};
|
||||
} catch (error) {
|
||||
return { kind: "error", message: "Import failed. Please try again.", error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign + publish the given servers as the user's kind-10063 list.
|
||||
*
|
||||
* @returns {Promise<{kind:"success"|"error", message:string, error?:unknown}>}
|
||||
*/
|
||||
export async function publishBlossomServerList({ client, pubkey, servers } = {}) {
|
||||
if (!client || typeof client.signAndPublishEvent !== "function" || !pubkey) {
|
||||
return { kind: "error", message: "Log in to publish your server list." };
|
||||
}
|
||||
if (!Array.isArray(servers) || servers.length === 0) {
|
||||
return { kind: "error", message: "Add at least one server first." };
|
||||
}
|
||||
try {
|
||||
const event = buildBlossomServerListEvent({
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
servers,
|
||||
});
|
||||
await client.signAndPublishEvent(event);
|
||||
return {
|
||||
kind: "success",
|
||||
message: `Published ${servers.length} server(s) to your Nostr relays.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return { kind: "error", message: "Publish failed. Please try again.", error };
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,12 @@ export function fillStorageForm(c, conn) {
|
||||
} = conn.meta || {};
|
||||
|
||||
if (c.storageProviderInput) {
|
||||
c.storageProviderInput.value = provider || "cloudflare_r2";
|
||||
// The provider lives at the top level for legacy connections but only in
|
||||
// `meta` for the decrypted/keyless shape returned by getConnection — mirror
|
||||
// connectionProvider()'s fallback so the selector reflects the loaded (default)
|
||||
// connection instead of silently reverting to Cloudflare.
|
||||
c.storageProviderInput.value =
|
||||
provider || conn.meta?.provider || "cloudflare_r2";
|
||||
}
|
||||
const resolvedEndpoint =
|
||||
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
// 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);
|
||||
const whitelistNpubs = Array.from(accessControl.getWhitelist?.() || []);
|
||||
|
||||
setLoading(controller, true);
|
||||
let pending = [];
|
||||
try {
|
||||
pending = await fetchPendingSubmissions({ adminHex, editorHexes, whitelistNpubs });
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -510,6 +510,27 @@ const SERVICE_CONTRACT = [
|
||||
"Clears a specific moderation override when the viewer resets a decision.",
|
||||
fallback: () => () => false,
|
||||
},
|
||||
{
|
||||
key: "getAuthorModerationOverrides",
|
||||
type: "function",
|
||||
description:
|
||||
"Returns the account-level (per-creator) web-of-trust overrides for the active viewer.",
|
||||
fallback: () => () => [],
|
||||
},
|
||||
{
|
||||
key: "setAuthorModerationOverride",
|
||||
type: "function",
|
||||
description:
|
||||
"Adds an account-level override that suppresses WoT warnings for all of a creator's videos.",
|
||||
fallback: () => () => null,
|
||||
},
|
||||
{
|
||||
key: "clearAuthorModerationOverride",
|
||||
type: "function",
|
||||
description:
|
||||
"Clears an account-level (per-creator) override so the WoT warnings return.",
|
||||
fallback: () => () => false,
|
||||
},
|
||||
{
|
||||
key: "loadVideos",
|
||||
type: "function",
|
||||
|
||||
@@ -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) {
|
||||
@@ -3860,7 +3911,7 @@ export class ProfileModalController {
|
||||
|
||||
deduped.forEach(({ hex, label }) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "card flex items-center justify-between gap-4 p-4";
|
||||
item.className = "card profile-account-card";
|
||||
|
||||
let cachedProfile = null;
|
||||
if (hex && typeof this.services.getProfileCacheEntry === "function") {
|
||||
@@ -3892,7 +3943,7 @@ export class ProfileModalController {
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex flex-wrap items-center justify-end gap-2";
|
||||
actions.className = "profile-account-card__actions";
|
||||
|
||||
const viewButton = this.createViewChannelButton({
|
||||
targetNpub: encodedNpub,
|
||||
@@ -4179,7 +4230,7 @@ export class ProfileModalController {
|
||||
|
||||
deduped.forEach(({ hex, label }) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "card flex items-center justify-between gap-4 p-4";
|
||||
item.className = "card profile-account-card";
|
||||
|
||||
let cachedProfile = null;
|
||||
if (hex && typeof this.services.getProfileCacheEntry === "function") {
|
||||
@@ -4211,7 +4262,7 @@ export class ProfileModalController {
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "flex flex-wrap items-center justify-end gap-2";
|
||||
actions.className = "profile-account-card__actions";
|
||||
|
||||
const viewButton = this.createViewChannelButton({
|
||||
targetNpub: encodedNpub,
|
||||
|
||||
@@ -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;
|
||||
@@ -69,6 +69,10 @@ export default class VideoModalController {
|
||||
this.handleCopyCdn(event);
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-url", (event) => {
|
||||
this.handleCopyUrl(event);
|
||||
});
|
||||
|
||||
this.videoModal.addEventListener("video:copy-magnet", () => {
|
||||
if (this.handleCopyMagnetCallback) {
|
||||
this.handleCopyMagnetCallback();
|
||||
@@ -115,6 +119,28 @@ export default class VideoModalController {
|
||||
});
|
||||
}
|
||||
|
||||
handleCopyUrl(event) {
|
||||
const url = event?.detail?.url || "";
|
||||
if (!url) {
|
||||
if (this.showError) {
|
||||
this.showError("Could not generate a share link.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
if (this.showSuccess) {
|
||||
this.showSuccess("Video link copied to clipboard!");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.showError) {
|
||||
this.showError("Failed to copy the link.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSourceSwitch(event) {
|
||||
const detail = event?.detail || {};
|
||||
const { source } = detail;
|
||||
|
||||
+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");
|
||||
|
||||
+23
-3
@@ -941,7 +941,17 @@ export class TorrentClient {
|
||||
// Cap peer connections for the streaming torrent too. 30 is plenty to
|
||||
// saturate playback bandwidth while avoiding the WebRTC-handshake storm
|
||||
// that can peg the main thread (WebTorrent's default is 55).
|
||||
const chromeOptions = { strategy: "sequential", maxConns: 30 };
|
||||
// Announce to the WSS trackers explicitly. The magnet path already carries
|
||||
// trackers (playbackUtils augments it), but a .torrent-buffer add
|
||||
// (torrentFileBytes, from a Blossom video's companion event) has an EMPTY
|
||||
// embedded announce list — without this it would reach no tracker, discover
|
||||
// no WebRTC peers, and stream from the webseed only (looks like "1 peer").
|
||||
// WebTorrent merges this with any trackers already on the source, deduped.
|
||||
const chromeOptions = {
|
||||
strategy: "sequential",
|
||||
maxConns: 30,
|
||||
announce: [...WSS_TRACKERS],
|
||||
};
|
||||
/**
|
||||
* CRITICAL: Passing `urlList` ensures the client can stream from the webseed.
|
||||
* Without this, videos with 0 P2P peers will fail to play even if a valid
|
||||
@@ -951,12 +961,22 @@ export class TorrentClient {
|
||||
chromeOptions.urlList = candidateUrls;
|
||||
}
|
||||
|
||||
// When the caller supplies verified .torrent bytes (the piece-map from a
|
||||
// Blossom video's companion event — already infohash-checked upstream), add
|
||||
// that buffer directly so WebTorrent has metadata without a peer or xs=.
|
||||
// The trusted webseed still rides along via chromeOptions.urlList.
|
||||
const torrentSource =
|
||||
opts?.torrentFileBytes instanceof Uint8Array &&
|
||||
opts.torrentFileBytes.length
|
||||
? opts.torrentFileBytes
|
||||
: magnetURI;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 3) Add the torrent to the client and handle accordingly.
|
||||
if (isFirefoxBrowser) {
|
||||
this.log("Starting torrent download (Firefox path)");
|
||||
this.client.add(
|
||||
magnetURI,
|
||||
torrentSource,
|
||||
{ ...chromeOptions, maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
@@ -972,7 +992,7 @@ export class TorrentClient {
|
||||
);
|
||||
} else {
|
||||
this.log("Starting torrent download (Chrome path)");
|
||||
this.client.add(magnetURI, chromeOptions, (torrent) => {
|
||||
this.client.add(torrentSource, chromeOptions, (torrent) => {
|
||||
this.log("Torrent added (Chrome path):", torrent.name);
|
||||
this.handleTorrentStream(
|
||||
torrent,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+151
-3
@@ -20,7 +20,9 @@
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getalby/bitcoin-connect": "3.12.3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^28.1.0",
|
||||
@@ -832,6 +834,52 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@getalby/bitcoin-connect": {
|
||||
"version": "3.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/bitcoin-connect/-/bitcoin-connect-3.12.3.tgz",
|
||||
"integrity": "sha512-2d+tNHBCZBoImKJgZwTUOyK9ypXaTg/hwvtQ8EHbgSKKkuycOrks7SzlUc0iJHthD3JGFVMWZEgOgw4AQVVE4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@getalby/lightning-tools": "^8.1.1",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@lightninglabs/lnc-web": "^0.3.6-alpha",
|
||||
"qrcode-generator": "1.4.4",
|
||||
"zustand": "^5.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@getalby/lightning-tools": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-8.2.0.tgz",
|
||||
"integrity": "sha512-eL+cnHyzeUARKVzNRuFBRBppAU5RBJOLjhFVzSWt8hDibRzL5+e4hq8I79+RGHfrWWMUDv382Jx8ewOazrBj7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:[email protected]"
|
||||
}
|
||||
},
|
||||
"node_modules/@getalby/sdk": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-8.0.3.tgz",
|
||||
"integrity": "sha512-vPEogAWwLHbL55COeXrRN7yRBXMDGDxX1M2A+o3Lqw60FFng6fa9FK9sw8ptdILMD1dQZq8FzPXuabQ7seoDBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@getalby/lightning-tools": "^8.1.1",
|
||||
"nostr-tools": "^2.23.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "lightning",
|
||||
"url": "lightning:[email protected]"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -891,6 +939,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lightninglabs/lnc-core": {
|
||||
"version": "0.3.6-alpha",
|
||||
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.6-alpha.tgz",
|
||||
"integrity": "sha512-ObzY5wi+PS0GQduFHYDjGJpYZxKD27vtMf3fUVne2gGNMej1mvvJAOwATgef8YV/IZodAzy73Z/F2zfaQ+iE4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lightninglabs/lnc-web": {
|
||||
"version": "0.3.6-alpha",
|
||||
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-web/-/lnc-web-0.3.6-alpha.tgz",
|
||||
"integrity": "sha512-oiL1mKlbEjb1lcmNAHA0Gn+mlx5oQOJ8YVONJR/vgo4QLmL/48EyA12QbZ71mP9li45hGOoGqpYMRwBYtIeFog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lightninglabs/lnc-core": "^0.3.6-alpha",
|
||||
"crypto-js": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
@@ -1797,6 +1863,44 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/blossom-client-sdk": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/blossom-client-sdk/-/blossom-client-sdk-5.0.0.tgz",
|
||||
"integrity": "sha512-xz7w6eBQv6uiNK9ogh5AymnzCAohiU029XySpKxJyG7RsDHkoru+xoZhY55tiIOyWFX/5s8pip4hVh5hO1PN/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cashu/cashu-ts": "^2.4.3",
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cashu/cashu-ts": {
|
||||
"optional": true
|
||||
},
|
||||
"hls.js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/blossom-client-sdk/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -2321,6 +2425,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-declaration-sorter": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
|
||||
@@ -3789,9 +3900,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.1.tgz",
|
||||
"integrity": "sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==",
|
||||
"version": "2.23.9",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
|
||||
"integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.1.1",
|
||||
@@ -5046,6 +5157,13 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||
"integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6738,6 +6856,36 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
"build:css": "postcss css/tailwind.source.css -o css/tailwind.generated.css",
|
||||
"build:beacon:bundle": "node scripts/build-beacon.mjs",
|
||||
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
|
||||
"build:bitcoin-connect": "node scripts/build-bitcoin-connect.mjs",
|
||||
"build:blossom-sdk": "node scripts/build-blossom-sdk.mjs",
|
||||
"ci:beacon": "npm run build:beacon",
|
||||
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
|
||||
"audit": "node scripts/daily-design-system-audit.mjs",
|
||||
@@ -57,7 +59,9 @@
|
||||
"lint:pool-access": "node scripts/check-direct-pool-access.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getalby/bitcoin-connect": "3.12.3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"blossom-client-sdk": "5.0.0",
|
||||
"cssnano": "^7.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^28.1.0",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Bundles Alby's Bitcoin Connect into a single self-contained ESM file that
|
||||
// bitvid lazy-imports behind FEATURE_BITCOIN_CONNECT (docs/bitcoin-connect-plan.md).
|
||||
// Mirrors the floating-ui vendor step in build-beacon.mjs. Pinned via the exact
|
||||
// devDependency version in package.json.
|
||||
import { build } from "esbuild";
|
||||
import { mkdir, copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const entryPoint = path.join(repoRoot, "scripts/vendor/bitcoin-connect-entry.mjs");
|
||||
const vendorDir = path.join(repoRoot, "vendor");
|
||||
const outFile = path.join(vendorDir, "bitcoin-connect.bundle.min.js");
|
||||
const licenseSrc = path.join(
|
||||
repoRoot,
|
||||
"node_modules/@getalby/bitcoin-connect/LICENSE",
|
||||
);
|
||||
const licenseOut = path.join(vendorDir, "bitcoin-connect.LICENSE");
|
||||
|
||||
await mkdir(vendorDir, { recursive: true });
|
||||
|
||||
await build({
|
||||
bundle: true,
|
||||
entryPoints: [entryPoint],
|
||||
outfile: outFile,
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
platform: "browser",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
global: "globalThis",
|
||||
},
|
||||
// @getalby/lightning-tools lazily `import('crypto')` for L402 macaroons — a
|
||||
// path bitvid never calls. Alias it to a browser shim so the bundle resolves.
|
||||
alias: {
|
||||
crypto: path.join(repoRoot, "scripts/vendor/crypto-shim.mjs"),
|
||||
"node:crypto": path.join(repoRoot, "scripts/vendor/crypto-shim.mjs"),
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
try {
|
||||
await copyFile(licenseSrc, licenseOut);
|
||||
} catch (error) {
|
||||
console.warn("[build-bitcoin-connect] could not copy LICENSE:", error.message);
|
||||
}
|
||||
|
||||
console.log("[build-bitcoin-connect] wrote", path.relative(repoRoot, outFile));
|
||||
@@ -0,0 +1,51 @@
|
||||
// Bundles blossom-client-sdk into a single self-contained ESM file that bitvid
|
||||
// lazy-imports behind FEATURE_BLOSSOM_STORAGE (docs/blossom-plan.md, TODO #30).
|
||||
// Mirrors the bitcoin-connect / floating-ui vendor steps. Pinned via the exact
|
||||
// devDependency version in package.json.
|
||||
import { build } from "esbuild";
|
||||
import { mkdir, copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const entryPoint = path.join(repoRoot, "scripts/vendor/blossom-sdk-entry.mjs");
|
||||
const vendorDir = path.join(repoRoot, "vendor");
|
||||
const outFile = path.join(vendorDir, "blossom-client-sdk.bundle.min.js");
|
||||
const licenseSrc = path.join(
|
||||
repoRoot,
|
||||
"node_modules/blossom-client-sdk/LICENSE.txt",
|
||||
);
|
||||
const licenseOut = path.join(vendorDir, "blossom-client-sdk.LICENSE");
|
||||
|
||||
await mkdir(vendorDir, { recursive: true });
|
||||
|
||||
await build({
|
||||
bundle: true,
|
||||
entryPoints: [entryPoint],
|
||||
outfile: outFile,
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
platform: "browser",
|
||||
target: ["es2020"],
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
global: "globalThis",
|
||||
},
|
||||
// @cashu/cashu-ts is an OPTIONAL peer dep the SDK only lazy-imports on the
|
||||
// BUD-07 payment branch (HTTP 402). bitvid uses free Blossom servers, so alias
|
||||
// it to a throwing stub instead of pulling the dep.
|
||||
alias: {
|
||||
"@cashu/cashu-ts": path.join(repoRoot, "scripts/vendor/cashu-stub.mjs"),
|
||||
},
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
try {
|
||||
await copyFile(licenseSrc, licenseOut);
|
||||
} catch (error) {
|
||||
console.warn("[build-blossom-sdk] could not copy LICENSE:", error.message);
|
||||
}
|
||||
|
||||
console.log("[build-blossom-sdk] wrote", path.relative(repoRoot, outFile));
|
||||
@@ -29,10 +29,10 @@ const GRANDFATHERED = {
|
||||
"js/ui/profileModalController.js": 8130,
|
||||
"js/ui/components/VideoModal.js": 4976,
|
||||
"js/app.js": 5602,
|
||||
"js/channelProfile.js": 5529,
|
||||
"js/nostr/client.js": 4356,
|
||||
"js/channelProfile.js": 5806,
|
||||
"js/nostr/client.js": 4362,
|
||||
"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,
|
||||
@@ -57,13 +57,13 @@ const GRANDFATHERED = {
|
||||
"js/app/playbackCoordinator.js": 1376,
|
||||
"js/feedEngine/stages.js": 1370,
|
||||
"js/ui/views/VideoListView.js": 1359,
|
||||
"js/ui/components/UploadModal.js": 1319,
|
||||
"js/ui/components/UploadModal.js": 1400,
|
||||
"js/ui/components/SimilarContentCard.js": 1303,
|
||||
"js/services/playbackService.js": 1274,
|
||||
"js/services/authService.js": 1335,
|
||||
"js/ui/zapController.js": 1206,
|
||||
"js/services/r2Service.js": 1192,
|
||||
"js/ui/applicationBootstrap.js": 1217,
|
||||
"js/ui/applicationBootstrap.js": 1451,
|
||||
"js/ui/components/EditModal.js": 1130,
|
||||
"js/services/commentThreadService.js": 1100,
|
||||
"js/nostr/managers/SignerManager.js": 1160,
|
||||
|
||||
@@ -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");
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Vendor entry for Alby's Bitcoin Connect (getalby/bitcoin-connect), bundled by
|
||||
// scripts/build-bitcoin-connect.mjs into vendor/bitcoin-connect.bundle.min.js so
|
||||
// bitvid can lazy-import it behind FEATURE_BITCOIN_CONNECT without a runtime CDN.
|
||||
//
|
||||
// Importing the package self-registers its web components (bc-modal, etc.), so a
|
||||
// single import both loads the API and boots the UI. We re-export only the small
|
||||
// surface ProfileWalletController needs. See docs/bitcoin-connect-plan.md.
|
||||
export {
|
||||
init,
|
||||
launchModal,
|
||||
closeModal,
|
||||
onConnected,
|
||||
onDisconnected,
|
||||
disconnect,
|
||||
getConnectorConfig,
|
||||
isConnected,
|
||||
} from "@getalby/bitcoin-connect";
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
// Vendor entry for blossom-client-sdk (hzrd149), bundled by
|
||||
// scripts/build-blossom-sdk.mjs into vendor/blossom-client-sdk.bundle.min.js so
|
||||
// bitvid can lazy-import it behind FEATURE_BLOSSOM_STORAGE without a runtime CDN.
|
||||
// Re-exports only the surface bitvid needs. See docs/blossom-plan.md.
|
||||
//
|
||||
// The action helpers live under the `./actions/*` subpaths (the root entry
|
||||
// namespaces them as `Actions`); auth + discovery are top-level modules.
|
||||
export {
|
||||
createUploadAuth,
|
||||
createMirrorAuth,
|
||||
createListAuth,
|
||||
createDeleteAuth,
|
||||
encodeAuthorizationHeader,
|
||||
} from "blossom-client-sdk/auth";
|
||||
export {
|
||||
getServersFromServerListEvent,
|
||||
USER_BLOSSOM_SERVER_LIST_KIND,
|
||||
} from "blossom-client-sdk/nostr";
|
||||
export {
|
||||
multiServerUpload,
|
||||
multiServerMediaUpload,
|
||||
} from "blossom-client-sdk/actions/multi-server";
|
||||
export { uploadBlob } from "blossom-client-sdk/actions/upload";
|
||||
export { mirrorBlob } from "blossom-client-sdk/actions/mirror";
|
||||
export { listBlobs, iterateBlobs } from "blossom-client-sdk/actions/list";
|
||||
export { deleteBlob } from "blossom-client-sdk/actions/delete";
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// Stub for the optional @cashu/cashu-ts peer dep, aliased in when bundling
|
||||
// blossom-client-sdk (scripts/build-blossom-sdk.mjs). The SDK only reaches for it
|
||||
// on the BUD-07 cashu-payment branch (a lazy `await import()` triggered by an HTTP
|
||||
// 402). bitvid targets free Blossom servers, so this never runs; the stubs throw
|
||||
// loudly if a payment-gated server is ever hit rather than silently misbehaving.
|
||||
function unsupported() {
|
||||
throw new Error(
|
||||
"Cashu payments (BUD-07) are not supported in the bitvid Blossom bundle",
|
||||
);
|
||||
}
|
||||
|
||||
export const getEncodedToken = unsupported;
|
||||
export const decodePaymentRequest = unsupported;
|
||||
export const getDecodedToken = unsupported;
|
||||
|
||||
export default {};
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
// Minimal browser shim for node's `crypto`, aliased in when bundling Bitcoin
|
||||
// Connect (scripts/build-bitcoin-connect.mjs). The only place its deps reach for
|
||||
// `crypto` is @getalby/lightning-tools' L402 macaroon helpers via a lazy
|
||||
// `await import('crypto')` — a path bitvid's NWC connect/pay flow never calls.
|
||||
// timingSafeEqual is implemented properly; the L402-only createHmac is stubbed so
|
||||
// it fails loudly rather than silently misbehaving if ever reached.
|
||||
export function timingSafeEqual(a, b) {
|
||||
const ua = a instanceof Uint8Array ? a : new Uint8Array(a);
|
||||
const ub = b instanceof Uint8Array ? b : new Uint8Array(b);
|
||||
if (ua.length !== ub.length) {
|
||||
return false;
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < ua.length; i += 1) {
|
||||
diff |= ua[i] ^ ub[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function createHmac() {
|
||||
throw new Error(
|
||||
"createHmac (L402 macaroons) is not available in the bitvid browser bundle",
|
||||
);
|
||||
}
|
||||
|
||||
export default { timingSafeEqual, createHmac };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test, { beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
setAuthorModerationOverride,
|
||||
getAuthorModerationOverride,
|
||||
hasAuthorModerationOverride,
|
||||
clearAuthorModerationOverride,
|
||||
getAuthorModerationOverridesList,
|
||||
loadAuthorModerationOverridesFromStorage,
|
||||
} from "../js/state/cache.js";
|
||||
|
||||
const HEX = "a".repeat(64);
|
||||
const HEX2 = "b".repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearAuthorModerationOverride(HEX);
|
||||
clearAuthorModerationOverride(HEX2);
|
||||
});
|
||||
|
||||
test("set + get + has recognizes an account-level override", () => {
|
||||
const entry = setAuthorModerationOverride(HEX, { showAnyway: true });
|
||||
assert.equal(entry.authorPubkey, HEX);
|
||||
assert.equal(entry.showAnyway, true);
|
||||
assert.equal(hasAuthorModerationOverride(HEX), true);
|
||||
assert.equal(getAuthorModerationOverride(HEX)?.showAnyway, true);
|
||||
assert.equal(hasAuthorModerationOverride(HEX2), false);
|
||||
});
|
||||
|
||||
test("hex input is normalized (uppercase accepted, stored lowercase)", () => {
|
||||
setAuthorModerationOverride(HEX.toUpperCase(), { showAnyway: true });
|
||||
assert.equal(hasAuthorModerationOverride(HEX), true, "uppercase resolves to the same entry");
|
||||
});
|
||||
|
||||
test("clearing removes it; showAnyway:false is treated as a removal", () => {
|
||||
setAuthorModerationOverride(HEX, { showAnyway: true });
|
||||
assert.equal(clearAuthorModerationOverride(HEX), true);
|
||||
assert.equal(hasAuthorModerationOverride(HEX), false);
|
||||
|
||||
setAuthorModerationOverride(HEX, { showAnyway: true });
|
||||
assert.equal(setAuthorModerationOverride(HEX, { showAnyway: false }), null);
|
||||
assert.equal(hasAuthorModerationOverride(HEX), false);
|
||||
});
|
||||
|
||||
test("survives a reload via persist → load roundtrip", () => {
|
||||
setAuthorModerationOverride(HEX, { showAnyway: true });
|
||||
setAuthorModerationOverride(HEX2, { showAnyway: true });
|
||||
// A reload repopulates the in-memory map from localStorage.
|
||||
loadAuthorModerationOverridesFromStorage();
|
||||
const list = getAuthorModerationOverridesList()
|
||||
.map((entry) => entry.authorPubkey)
|
||||
.sort();
|
||||
assert.deepEqual(list, [HEX, HEX2].sort());
|
||||
});
|
||||
|
||||
test("blank/invalid pubkey is ignored", () => {
|
||||
assert.equal(setAuthorModerationOverride("", { showAnyway: true }), null);
|
||||
assert.equal(setAuthorModerationOverride("not-a-key", { showAnyway: true }), null);
|
||||
assert.equal(getAuthorModerationOverridesList().length, 0);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// BUD-03 kind-10063 server-list import/publish helpers (extracted from the storage
|
||||
// pane). Every path returns a { kind, message, ... } result — never throws — so the
|
||||
// UI can render it directly. See docs/blossom-plan.md (Decision 3).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
importBlossomServerList,
|
||||
publishBlossomServerList,
|
||||
} from "../js/ui/profileModal/blossomServerList.js";
|
||||
|
||||
const PK = "a".repeat(64);
|
||||
const listEvent = (servers, created_at = 1000) => ({
|
||||
kind: 10063,
|
||||
created_at,
|
||||
tags: servers.map((s) => ["server", s]),
|
||||
});
|
||||
|
||||
test("importBlossomServerList returns the NEWEST list's servers, author-scoped", async () => {
|
||||
const client = {
|
||||
readRelays: ["wss://r"],
|
||||
getSubscriptionManager: () => ({
|
||||
list: async ({ filters, relays }) => {
|
||||
assert.deepEqual(filters[0].kinds, [10063]);
|
||||
assert.deepEqual(filters[0].authors, [PK]);
|
||||
assert.deepEqual(relays, ["wss://r"]);
|
||||
return [listEvent(["https://old"], 100), listEvent(["https://a", "https://b"], 999)];
|
||||
},
|
||||
}),
|
||||
};
|
||||
const res = await importBlossomServerList({ client, pubkey: PK });
|
||||
assert.equal(res.kind, "success");
|
||||
assert.deepEqual(res.servers, ["https://a", "https://b"]);
|
||||
});
|
||||
|
||||
test("importBlossomServerList: nothing published → error result (no throw)", async () => {
|
||||
const client = { getSubscriptionManager: () => ({ list: async () => [] }) };
|
||||
const res = await importBlossomServerList({ client, pubkey: PK });
|
||||
assert.equal(res.kind, "error");
|
||||
assert.match(res.message, /No published server list/);
|
||||
});
|
||||
|
||||
test("importBlossomServerList: not logged in → error", async () => {
|
||||
const res = await importBlossomServerList({ client: null, pubkey: "" });
|
||||
assert.equal(res.kind, "error");
|
||||
assert.match(res.message, /Log in/);
|
||||
});
|
||||
|
||||
test("importBlossomServerList: relay error is caught, returned as an error result", async () => {
|
||||
const client = {
|
||||
getSubscriptionManager: () => ({
|
||||
list: async () => {
|
||||
throw new Error("relay down");
|
||||
},
|
||||
}),
|
||||
};
|
||||
const res = await importBlossomServerList({ client, pubkey: PK });
|
||||
assert.equal(res.kind, "error");
|
||||
assert.match(res.message, /Import failed/);
|
||||
assert.equal(res.error.message, "relay down");
|
||||
});
|
||||
|
||||
test("publishBlossomServerList signs + publishes a kind-10063 event with server tags", async () => {
|
||||
let published = null;
|
||||
const client = {
|
||||
signAndPublishEvent: async (ev) => {
|
||||
published = ev;
|
||||
return { id: "x" };
|
||||
},
|
||||
};
|
||||
const res = await publishBlossomServerList({
|
||||
client,
|
||||
pubkey: PK,
|
||||
servers: ["https://a", "https://b"],
|
||||
});
|
||||
assert.equal(res.kind, "success");
|
||||
assert.equal(published.kind, 10063);
|
||||
assert.equal(published.pubkey, PK);
|
||||
assert.deepEqual(
|
||||
published.tags.filter((t) => t[0] === "server"),
|
||||
[["server", "https://a"], ["server", "https://b"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("publishBlossomServerList: no servers → error (nothing published)", async () => {
|
||||
let called = false;
|
||||
const client = {
|
||||
signAndPublishEvent: async () => {
|
||||
called = true;
|
||||
},
|
||||
};
|
||||
const res = await publishBlossomServerList({ client, pubkey: PK, servers: [] });
|
||||
assert.equal(res.kind, "error");
|
||||
assert.match(res.message, /at least one server/);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("publishBlossomServerList: publish failure → error result (no throw)", async () => {
|
||||
const client = {
|
||||
signAndPublishEvent: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
};
|
||||
const res = await publishBlossomServerList({
|
||||
client,
|
||||
pubkey: PK,
|
||||
servers: ["https://a"],
|
||||
});
|
||||
assert.equal(res.kind, "error");
|
||||
assert.match(res.message, /Publish failed/);
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
// blossomService tests (Node-safe — no WebTorrent). Covers the availability gate,
|
||||
// server-list resolution, input guards, and the uploadVideo orchestration (blob +
|
||||
// injected torrent → magnet + storagePointer). The vendored SDK is mocked via
|
||||
// loadSdk; real-server round-trips are exercised in-browser. See docs/blossom-plan.md.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import blossomService, {
|
||||
BlossomService,
|
||||
BLOSSOM_PROVIDER,
|
||||
isBlossomProvider,
|
||||
resolveBlossomServers,
|
||||
blobSha256FromUrl,
|
||||
serversFromServerListEvent,
|
||||
} from "../js/services/blossomService.js";
|
||||
import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js";
|
||||
|
||||
const SHA = "a".repeat(64);
|
||||
const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" });
|
||||
|
||||
// A BlossomService whose SDK is mocked: uploadBlob uploads directly to each
|
||||
// server (BUD-02 /upload) and returns a descriptor, echoing the blob so we can
|
||||
// assert which servers were hit. `failServers` simulates per-server failures.
|
||||
function mockService({ failServers = [] } = {}) {
|
||||
const uploads = [];
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }),
|
||||
uploadBlob: async (server, file, opts) => {
|
||||
// Give each blob a distinct sha256 so video vs .torrent URLs differ.
|
||||
const sha = file?.name?.includes(".torrent") ? "b".repeat(64) : SHA;
|
||||
if (typeof opts?.onAuth === "function") {
|
||||
await opts.onAuth(server, sha, "upload", file);
|
||||
}
|
||||
uploads.push({ server, file });
|
||||
if (failServers.includes(server)) {
|
||||
throw new Error("CORS blocked");
|
||||
}
|
||||
return { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" };
|
||||
},
|
||||
});
|
||||
return { svc, uploads };
|
||||
}
|
||||
|
||||
test("isBlossomProvider / BLOSSOM_PROVIDER only match 'blossom'", () => {
|
||||
assert.equal(BLOSSOM_PROVIDER, "blossom");
|
||||
assert.equal(isBlossomProvider("blossom"), true);
|
||||
assert.equal(isBlossomProvider("cloudflare_r2"), false);
|
||||
});
|
||||
|
||||
test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag", () => {
|
||||
assert.equal(blossomService.isAvailable(), FEATURE_BLOSSOM_STORAGE === true);
|
||||
});
|
||||
|
||||
test("resolveBlossomServers de-dupes, trims, and reads meta.servers", () => {
|
||||
assert.deepEqual(
|
||||
resolveBlossomServers({ servers: [" https://a ", "https://a", "https://b", "", " "] }),
|
||||
["https://a", "https://b"],
|
||||
);
|
||||
assert.deepEqual(resolveBlossomServers({ meta: { servers: ["https://c"] } }), ["https://c"]);
|
||||
assert.deepEqual(resolveBlossomServers({}), []);
|
||||
});
|
||||
|
||||
test("uploadFile guards: file, servers, signer required", async () => {
|
||||
const { svc } = mockService();
|
||||
await assert.rejects(() => svc.uploadFile({ servers: ["https://s"], signer }), /requires a file/);
|
||||
await assert.rejects(
|
||||
() => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer }),
|
||||
/at least one server/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => svc.uploadFile({ file: new Blob(["x"]), servers: ["https://s"], signer: null }),
|
||||
/requires a signer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadFile uploads directly to every server and returns the primary url", async () => {
|
||||
const { svc, uploads } = mockService();
|
||||
const out = await svc.uploadFile({
|
||||
file: new File([new Uint8Array([1])], "v.mp4"),
|
||||
servers: ["https://a", "https://b"],
|
||||
signer,
|
||||
});
|
||||
assert.equal(out.url, `https://a/${SHA}`);
|
||||
assert.equal(out.key, SHA);
|
||||
assert.deepEqual(
|
||||
uploads.map((u) => u.server).sort(),
|
||||
["https://a", "https://b"],
|
||||
"uploaded to both servers directly (no /mirror)",
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadFile survives partial failure — one server down, another succeeds", async () => {
|
||||
const { svc } = mockService({ failServers: ["https://a"] });
|
||||
const out = await svc.uploadFile({
|
||||
file: new File([new Uint8Array([1])], "v.mp4"),
|
||||
servers: ["https://a", "https://b"],
|
||||
signer,
|
||||
});
|
||||
assert.equal(out.url, `https://b/${SHA}`, "falls back to the working server");
|
||||
});
|
||||
|
||||
test("uploadFile throws a descriptive error when ALL servers fail", async () => {
|
||||
const { svc } = mockService({ failServers: ["https://a", "https://b"] });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
svc.uploadFile({
|
||||
file: new File([new Uint8Array([1])], "v.mp4"),
|
||||
servers: ["https://a", "https://b"],
|
||||
signer,
|
||||
}),
|
||||
/failed on all server\(s\).*CORS blocked/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadFile declares a Content-Type when the File has none (avoids 415)", async () => {
|
||||
// Browsers leave File.type empty for containers like .m4v; the SDK then PUTs a
|
||||
// typeless body and strict servers answer 415. uploadFile must re-type the blob.
|
||||
const { svc, uploads } = mockService();
|
||||
const typeless = new File([new Uint8Array([1])], "clip.m4v");
|
||||
assert.equal(typeless.type, "", "precondition: the File has no MIME type");
|
||||
await svc.uploadFile({ file: typeless, servers: ["https://a"], signer });
|
||||
assert.equal(
|
||||
uploads[0].file.type,
|
||||
"video/mp4",
|
||||
"the uploaded blob was re-typed from the .m4v extension",
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadFile overrides a generic application/octet-stream type from the extension", async () => {
|
||||
// Files produced by other apps or fetched via WebTorrent often carry a generic
|
||||
// "application/octet-stream" type; strict servers reject that with 415/400 even
|
||||
// though they accept the real type. A recognized extension must win.
|
||||
const { svc, uploads } = mockService();
|
||||
const generic = new File([new Uint8Array([1])], "Fire Pit Render.mp4", {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
await svc.uploadFile({ file: generic, servers: ["https://a"], signer });
|
||||
assert.equal(
|
||||
uploads[0].file.type,
|
||||
"video/mp4",
|
||||
"generic octet-stream is replaced by video/mp4 from the .mp4 extension",
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadFile keeps a specific, correct browser type untouched", async () => {
|
||||
const { svc, uploads } = mockService();
|
||||
const good = new File([new Uint8Array([1])], "clip.mkv", { type: "video/webm" });
|
||||
await svc.uploadFile({ file: good, servers: ["https://a"], signer });
|
||||
// A specific (non-generic) browser type is trusted even if it differs from the
|
||||
// extension's inferred type — we don't second-guess a real declared type.
|
||||
assert.equal(uploads[0].file.type, "video/webm");
|
||||
});
|
||||
|
||||
test("uploadFile surfaces the server HTTP status in the all-fail error", async () => {
|
||||
// Simulate a Blossom server rejecting the media type (415). The SDK's HTTPError
|
||||
// exposes `.status`; the thrown message must include it, not just a vague reason.
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async () => ({ kind: 24242 }),
|
||||
uploadBlob: async () => {
|
||||
const err = new Error("Something went wrong");
|
||||
err.status = 415;
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
svc.uploadFile({
|
||||
file: new File([new Uint8Array([1])], "v.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
}),
|
||||
/HTTP 415: Something went wrong/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => {
|
||||
const { svc } = mockService();
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async () => ({ hasValidInfoHash: false, torrentFile: null }),
|
||||
});
|
||||
assert.equal(result.url, `https://a/${SHA}`);
|
||||
assert.equal(result.key, SHA);
|
||||
assert.match(result.storagePointer, /blossom/);
|
||||
assert.equal(result.magnet, "");
|
||||
assert.equal(result.hasValidInfoHash, false);
|
||||
});
|
||||
|
||||
test("uploadVideo with a torrent uploads the .torrent as a blob and builds a magnet (ws + xs)", async () => {
|
||||
const { svc, uploads } = mockService();
|
||||
const infoHash = "0".repeat(40);
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async ({ videoPublicUrl }) => {
|
||||
assert.equal(videoPublicUrl, `https://a/${SHA}`, "ws is the blossom video url");
|
||||
return {
|
||||
hasValidInfoHash: true,
|
||||
infoHash,
|
||||
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(uploads.length, 2, "video blob + .torrent blob");
|
||||
assert.equal(result.hasValidInfoHash, true);
|
||||
assert.equal(result.infoHash, infoHash);
|
||||
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
|
||||
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "ws = blossom video url");
|
||||
assert.match(result.magnet, /xs=https%3A%2F%2Fa%2F/, "xs = blossom .torrent url");
|
||||
assert.equal(result.torrentUrl, `https://a/${"b".repeat(64)}`);
|
||||
});
|
||||
|
||||
test("uploadVideo publishes URL-only when the .torrent sidecar is rejected (415)", async () => {
|
||||
// Spec correction: a webseed-only magnet (ws= but no xs=) can't bootstrap
|
||||
// WebTorrent — there's no metadata source — so shipping one produces a dead
|
||||
// share link. When a Blossom server rejects the .torrent (415), uploadVideo must
|
||||
// succeed with the video URL but publish NO magnet (URL-only), while retaining
|
||||
// the generated .torrent locally for other storage targets.
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async () => ({ kind: 24242 }),
|
||||
uploadBlob: async (server, file) => {
|
||||
if (file?.name?.includes(".torrent")) {
|
||||
const err = new Error("File type not allowed, unsupported");
|
||||
err.status = 415;
|
||||
throw err;
|
||||
}
|
||||
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
|
||||
},
|
||||
});
|
||||
const infoHash = "0".repeat(40);
|
||||
const torrentFile = new File([new Uint8Array([9])], "clip.torrent");
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async () => ({ hasValidInfoHash: true, infoHash, torrentFile }),
|
||||
});
|
||||
assert.equal(result.url, `https://a/${SHA}`, "video url is returned");
|
||||
assert.equal(result.magnet, "", "no magnet is published when the .torrent isn't hosted");
|
||||
assert.equal(result.torrentUrl, "", "no hosted .torrent url after rejection");
|
||||
assert.equal(result.hasValidInfoHash, false, "no usable magnet is advertised");
|
||||
assert.equal(result.torrentFile, torrentFile, "the generated .torrent is retained locally");
|
||||
});
|
||||
|
||||
test("uploadVideo Tier 2: publishes a companion metadata event → ws=-only magnet", async () => {
|
||||
// .torrent rejected by the server (415), but a publisher is wired (flag on at the
|
||||
// mediaUploader seam): bitvid publishes the piece-map companion event and ships a
|
||||
// ws=-only magnet it can bootstrap from Nostr. No xs= (nothing hosted).
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async () => ({ kind: 24242 }),
|
||||
uploadBlob: async (server, file) => {
|
||||
if (file?.name?.includes(".torrent")) {
|
||||
const err = new Error("File type not allowed");
|
||||
err.status = 415;
|
||||
throw err;
|
||||
}
|
||||
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
|
||||
},
|
||||
});
|
||||
const infoHash = "0".repeat(40);
|
||||
const published = [];
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async () => ({
|
||||
hasValidInfoHash: true,
|
||||
infoHash,
|
||||
torrentFile: new File([new Uint8Array([9, 9, 9])], "clip.torrent"),
|
||||
}),
|
||||
publishTorrentMetadata: async (args) => published.push(args),
|
||||
});
|
||||
assert.equal(published.length, 1, "the companion was published exactly once");
|
||||
assert.equal(published[0].infoHash, infoHash);
|
||||
assert.equal(
|
||||
Buffer.from(published[0].torrentBase64, "base64").length,
|
||||
3,
|
||||
"the exact .torrent bytes were base64-encoded",
|
||||
);
|
||||
assert.equal(result.hasValidInfoHash, true);
|
||||
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
|
||||
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "webseed points at the video");
|
||||
assert.ok(!/xs=/.test(result.magnet), "no xs= — the .torrent isn't hosted");
|
||||
});
|
||||
|
||||
test("uploadVideo Tier 2: over-cap piece-map falls back to URL-only", async () => {
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async () => ({ kind: 24242 }),
|
||||
uploadBlob: async (server, file) => {
|
||||
if (file?.name?.includes(".torrent")) {
|
||||
const err = new Error("File type not allowed");
|
||||
err.status = 415;
|
||||
throw err;
|
||||
}
|
||||
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
|
||||
},
|
||||
});
|
||||
const published = [];
|
||||
// A .torrent whose base64 exceeds the 64 KiB cap.
|
||||
const big = new File([new Uint8Array(60 * 1024)], "big.torrent");
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async () => ({
|
||||
hasValidInfoHash: true,
|
||||
infoHash: "0".repeat(40),
|
||||
torrentFile: big,
|
||||
}),
|
||||
publishTorrentMetadata: async (args) => published.push(args),
|
||||
});
|
||||
assert.equal(published.length, 0, "over-cap ⇒ nothing published");
|
||||
assert.equal(result.magnet, "", "URL-only");
|
||||
assert.equal(result.hasValidInfoHash, false);
|
||||
});
|
||||
|
||||
test("uploadVideo Tier 2: a failed companion publish falls back to URL-only", async () => {
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createUploadAuth: async () => ({ kind: 24242 }),
|
||||
uploadBlob: async (server, file) => {
|
||||
if (file?.name?.includes(".torrent")) {
|
||||
const err = new Error("File type not allowed");
|
||||
err.status = 415;
|
||||
throw err;
|
||||
}
|
||||
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
|
||||
},
|
||||
});
|
||||
const result = await svc.uploadVideo({
|
||||
file: new File([new Uint8Array([1])], "clip.mp4"),
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
generateTorrent: async () => ({
|
||||
hasValidInfoHash: true,
|
||||
infoHash: "0".repeat(40),
|
||||
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
|
||||
}),
|
||||
publishTorrentMetadata: async () => {
|
||||
throw new Error("relay unreachable");
|
||||
},
|
||||
});
|
||||
assert.equal(result.magnet, "", "publish failed ⇒ URL-only, video still succeeds");
|
||||
assert.equal(result.hasValidInfoHash, false);
|
||||
assert.equal(result.url, `https://a/${SHA}`);
|
||||
});
|
||||
|
||||
// --- BUD-12 management primitives (Phase 2) ---
|
||||
|
||||
test("deleteFile guards + calls deleteBlob with a delete auth", async () => {
|
||||
const svc = new BlossomService();
|
||||
const calls = [];
|
||||
svc.loadSdk = async () => ({
|
||||
createDeleteAuth: async (s, hash) => ({ kind: 24242, t: "delete", hash }),
|
||||
deleteBlob: async (server, hash, opts) => {
|
||||
if (typeof opts?.onAuth === "function") await opts.onAuth();
|
||||
calls.push({ server, hash });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
await assert.rejects(() => svc.deleteFile({ sha256: SHA, signer }), /server and a sha256/);
|
||||
await assert.rejects(() => svc.deleteFile({ server: "https://a", signer }), /server and a sha256/);
|
||||
await assert.rejects(
|
||||
() => svc.deleteFile({ server: "https://a", sha256: SHA, signer: null }),
|
||||
/requires a signer/,
|
||||
);
|
||||
const ok = await svc.deleteFile({ server: "https://a", sha256: SHA, signer });
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls[0], { server: "https://a", hash: SHA });
|
||||
});
|
||||
|
||||
test("blobSha256FromUrl extracts the 64-hex blob id (or '' for non-blobs)", () => {
|
||||
const sha = "a".repeat(64);
|
||||
assert.equal(blobSha256FromUrl(`https://npub1x.blossom.band/${sha}.mp4`), sha);
|
||||
assert.equal(blobSha256FromUrl(`https://blossom.band/${sha}`), sha);
|
||||
assert.equal(blobSha256FromUrl(`https://cdn.example/u/2026/clip.mp4`), "", "not a blob path");
|
||||
assert.equal(blobSha256FromUrl("not a url"), "");
|
||||
assert.equal(blobSha256FromUrl(""), "");
|
||||
});
|
||||
|
||||
test("deleteByUrl maps a Blossom URL to its blob and deletes it on every server", async () => {
|
||||
const svc = new BlossomService();
|
||||
const calls = [];
|
||||
svc.loadSdk = async () => ({
|
||||
createDeleteAuth: async () => ({ kind: 24242, t: "delete" }),
|
||||
deleteBlob: async (server, hash) => {
|
||||
calls.push({ server, hash });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const sha = "b".repeat(64);
|
||||
const out = await svc.deleteByUrl({
|
||||
url: `https://blossom.band/${sha}.mp4`,
|
||||
servers: ["https://a", "https://b"],
|
||||
signer,
|
||||
});
|
||||
assert.equal(out.sha256, sha);
|
||||
assert.deepEqual(out.deleted.sort(), ["https://a", "https://b"]);
|
||||
assert.deepEqual(calls.map((c) => c.hash), [sha, sha]);
|
||||
});
|
||||
|
||||
test("deleteByUrl falls back to the URL's own origin when no servers are given", async () => {
|
||||
const svc = new BlossomService();
|
||||
const calls = [];
|
||||
svc.loadSdk = async () => ({
|
||||
createDeleteAuth: async () => ({}),
|
||||
deleteBlob: async (server, hash) => {
|
||||
calls.push({ server, hash });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const sha = "d".repeat(64);
|
||||
const out = await svc.deleteByUrl({
|
||||
url: `https://npub1x.blossom.band/${sha}.mp4`,
|
||||
signer,
|
||||
});
|
||||
assert.equal(out.sha256, sha);
|
||||
assert.deepEqual(out.deleted, ["https://npub1x.blossom.band"], "deleted from the URL origin");
|
||||
assert.equal(calls[0].server, "https://npub1x.blossom.band");
|
||||
});
|
||||
|
||||
test("deleteByUrl is a no-op for a non-Blossom URL", async () => {
|
||||
const svc = new BlossomService();
|
||||
let called = false;
|
||||
svc.loadSdk = async () => ({
|
||||
createDeleteAuth: async () => ({}),
|
||||
deleteBlob: async () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
const out = await svc.deleteByUrl({
|
||||
url: "https://cdn.example/video.mp4",
|
||||
servers: ["https://a"],
|
||||
signer,
|
||||
});
|
||||
assert.equal(out.sha256, "");
|
||||
assert.equal(called, false, "no delete attempted for a non-blob URL");
|
||||
});
|
||||
|
||||
test("deleteByUrl reports per-server failures without throwing", async () => {
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createDeleteAuth: async () => ({}),
|
||||
deleteBlob: async (server) => {
|
||||
if (server === "https://a") throw new Error("403 forbidden");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const sha = "c".repeat(64);
|
||||
const out = await svc.deleteByUrl({
|
||||
url: `https://x/${sha}`,
|
||||
servers: ["https://a", "https://b"],
|
||||
signer,
|
||||
});
|
||||
assert.deepEqual(out.deleted, ["https://b"]);
|
||||
assert.equal(out.failed.length, 1);
|
||||
assert.match(out.failed[0].error, /403/);
|
||||
});
|
||||
|
||||
test("serversFromServerListEvent reads + de-dupes the BUD-03 server tags", () => {
|
||||
const event = {
|
||||
kind: 10063,
|
||||
tags: [
|
||||
["server", " https://a "],
|
||||
["server", "https://a"],
|
||||
["server", "https://b"],
|
||||
["client", "x"],
|
||||
["server", ""],
|
||||
],
|
||||
};
|
||||
assert.deepEqual(serversFromServerListEvent(event), ["https://a", "https://b"]);
|
||||
assert.deepEqual(serversFromServerListEvent(null), []);
|
||||
});
|
||||
|
||||
test("listFiles returns the server's blob descriptors for a pubkey", async () => {
|
||||
const svc = new BlossomService();
|
||||
svc.loadSdk = async () => ({
|
||||
createListAuth: async () => ({ kind: 24242, t: "list" }),
|
||||
listBlobs: async (server, pubkey) => [
|
||||
{ url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" },
|
||||
],
|
||||
});
|
||||
await assert.rejects(() => svc.listFiles({ pubkey: "pk" }), /server and a pubkey/);
|
||||
const blobs = await svc.listFiles({ server: "https://a", pubkey: "f".repeat(64), signer });
|
||||
assert.equal(blobs.length, 1);
|
||||
assert.equal(blobs[0].sha256, SHA);
|
||||
});
|
||||
@@ -194,6 +194,8 @@ describe("DM block/mute filtering", () => {
|
||||
: nostrClient.relays;
|
||||
const originalFetchListIncrementally = nostrClient.fetchListIncrementally;
|
||||
const originalRelayEntries = relayManager.getEntries();
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalGetSubscriptionManager = nostrClient.getSubscriptionManager;
|
||||
|
||||
const relayUrls = ["wss://dm-block-regression.example"];
|
||||
relayManager.setEntries(
|
||||
@@ -202,6 +204,22 @@ describe("DM block/mute filtering", () => {
|
||||
);
|
||||
nostrClient.relays = relayUrls;
|
||||
|
||||
// Keep this test hermetic. Otherwise loadBlocks() opens a LIVE block-list
|
||||
// subscription through nostrClient's real SimplePool aimed at the fake relay
|
||||
// above, leaking TCP sockets + reconnect timers that outlive the test and
|
||||
// hang the node:test runner AFTER every assertion has already passed.
|
||||
// (1) A truthy stub pool stops loadBlocks from calling ensurePool(), which
|
||||
// otherwise re-creates and connects a real pool when pool is falsy
|
||||
// (userBlocks.js) — so nulling the pool alone is not enough.
|
||||
// (2) A no-op subscription manager makes startBlockListSubscription() a
|
||||
// no-op instead of a real relay REQ.
|
||||
// The block MERGE under test still runs entirely through the stubbed
|
||||
// fetchListIncrementally below. Both are restored in finally.
|
||||
nostrClient.pool = { __mockForDmBlockFilterTest: true };
|
||||
nostrClient.getSubscriptionManager = () => ({
|
||||
subscribe: () => ({ close() {} }),
|
||||
});
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
@@ -317,6 +335,8 @@ describe("DM block/mute filtering", () => {
|
||||
});
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.fetchListIncrementally = originalFetchListIncrementally;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.getSubscriptionManager = originalGetSubscriptionManager;
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// - refreshMirrorStates() looks up relay truth (batched), corrects the button
|
||||
// label, AND reconciles the local flag so it becomes a correct cache.
|
||||
|
||||
// Self-contained localStorage shim: the mirror flag helpers read the global
|
||||
// `localStorage`, so without this the suite only passed by relying on another
|
||||
// test file leaking the shim into the shared global (brittle load-order
|
||||
// dependency). Importing it here makes this file honest in isolation.
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test, { beforeEach, afterEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user