diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index b74a935e..e77abb33 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -99,3 +99,316 @@ test_integrity_note: backstop) rather than weakening a real correctness check; coverage of the actual security/decrypt behavior is preserved." ``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + file: tests/nip71-mirror-flags.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-mirror-delete-unconditional + given: "A video that was shared to NIP-71 on one device/session, then deleted from another device (or after the per-video opt-in flag — browser-local localStorage — was cleared)" + when: "resolveDeleteSync runs during delete with FEATURE_NIP71_MIRROR on but the local enabled flag false/absent" + then: "The mirror teardown (NIP-09 + empty tombstone, idempotent) is still attempted, so the NIP-71 mirror is not orphaned on other apps; the feature flag still fully gates it" + observable_outcomes: + - "resolveDeleteSync({featureOn:true, enabled:false|undefined}) -> {action:'unshare'}" + - "resolveDeleteSync({featureOn:false, ...}) -> {action:'none'} (feature flag still gates)" + determinism_controls: + - "pure function; explicit input matrix" + anti_cheat_rationale: + prevents: + - "hard-coded return value" + note: "The previous expectation (enabled:false -> none) encoded the bug: it + gated teardown on a browser-local flag, orphaning the mirror across devices. + The corrected spec is STRICTER — teardown fires whenever the feature is on — + and adds the enabled:undefined case. The feature-flag gate assertion is + retained, so no real gate was weakened." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + file: tests/nostr-delete-flow.test.mjs + change_type: ["spec_correction", "refactor_tests"] + scenarios: + - id: SCN-delete-surfaces-relay-failures + given: "A logged-in delete where one write relay rejects the kind-5 deletion" + when: "deleteAllVersions publishes to the WRITE set via getDeletePublishRelays() (the afb6200b relay-scope fix)" + then: "The delete summary surfaces the failing relay" + - id: SCN-buffered-feed-tombstone-guard + given: "A delete event seeds a tombstone, then an OLDER non-deleted event for the same root arrives on the live stream" + when: "events flow through the real VideoEventBuffer (the path subscribeVideos builds via the SubscriptionManager), not the removed direct pool.sub() injection" + then: "The older event is tombstone-guarded (marked deleted, kept out of activeMap) and never surfaces as a LIVE video; a deletion event surfaces marked deleted:true so the UI removes the card" + observable_outcomes: + - "deleteSummary.summary.failed identifies wss://relay.fail (writeRelays now configured so the injected failure is exercised)" + - "no callback video with the deleted/older event id and deleted:false" + - "client.activeMap cleared for the root; tombstone advanced; stored event marked deleted" + determinism_controls: + - "videoEventVerifier virtualizes the off-thread signature worker (all events valid)" + - "buffer.scheduleFlush(true) awaited for synchronous commit" + - "real NostrClient + real VideoEventBuffer (no mocked tombstone guard)" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "snapshot rubber-stamping" + note: "Two corrections. (1) The file used bare assert + top-level await, so the + unit runner (which requires a node:test import) SILENTLY EXCLUDED it — it had + rotted and was failing on main unnoticed. Converted to node:test so it runs + in CI. (2) The relay-failure test set only client.relays, but deletes moved to + the write set (getDeletePublishRelays); it now sets writeRelays so the + injected failure is actually exercised — the assertion was previously passing + vacuously. (3) The subscription assertions drove the REMOVED pool.sub() path + and expected 'no callback at all' for deleted events; the current + VideoEventBuffer intentionally surfaces deleted events marked deleted:true so + the UI can remove the card. Rewritten to drive the real buffer + real client + and assert the stricter, accurate invariant (never surfaces as LIVE; guarded + and removed from activeMap). Coverage is stronger: the buffer's own unit test + mocks the client's tombstone guard, whereas this exercises the real guard." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + file: tests/watch-history.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-watch-history-partial-acceptance-durable + given: "A logged-in user snapshots watch history to 3 write relays where 2 accept and 1 rejects (single attempt, no escalation)" + when: "watchHistoryService.snapshot runs after the 565a9618 fix (a snapshot is durable as soon as ANY relay accepts; reads take the newest event per d-tag)" + then: "The snapshot SUCCEEDS — it does not throw, the result is ok and not retryable, no republish is scheduled, the month result is flagged partial with the accepting/rejecting relays captured, and the pointer queue is emptied (the pointer was durably published)" + - id: SCN-watch-history-logged-in-not-session + given: "A logged-in user (nostrClient.pubkey set) records a watch and the entry is published and re-loaded" + when: "publishView resolves the owning actor as the logged-in pubkey and sets the session flag only when nobody is logged in" + then: "The round-tripped entry is NOT flagged session=true (it is the logged-in user's history, not a local-only session entry); the queue files under the logged-in pubkey so getQueuedPointers(actor) sees it" + observable_outcomes: + - "snapshot() resolves (does not throw); result.ok === true; result.retryable === false" + - "scheduleWatchHistoryRepublish NOT called" + - "result.results[0].partial === true; relayStatus.pointer shows relay.one/two accepted, relay.three rejected" + - "getQueuedPointers(actor).length === 0 after a successful snapshot" + - "resolvedItems[0].session !== true for a logged-in user" + determinism_controls: + - "poolHarness resolver with a fixed accept/reject plan per relay url" + - "session crypto + recordVideoView virtualized; stable relay set" + - "logged-in pubkey aligned with the queried actor (matches the convention used by the other tests in the file)" + anti_cheat_rationale: + prevents: + - "snapshot rubber-stamping" + - "hard-coded return value" + note: "Three corrections in this file, all matching shipped behavior. (1) The + partial-acceptance test asserted the INVERSE of intended behavior — that + snapshot() THROWS on partial acceptance and retries until EVERY relay accepts — + encoding exactly the all-relays false-failure bug 565a9618 fixed. Rewritten to + the success path (no throw, partial flag, per-relay status, queue drained, no + spurious republish). (2) testWatchHistoryServiceIntegration set the logged-in + pubkey to a value DIFFERENT from the actor it queried; since watch history now + files under the logged-in pubkey, the queue assertions looked at the wrong key. + Aligned pubkey to the queried actor (the convention every other test uses) — a + setup fix, no assertion weakened. (3) The same test asserted session===true for + a logged-in watch, which is the pre-fix bug (logged-in watches were wrongly + flagged session by comparing against the view actor); corrected to session!==true + per the current rule that session marks only logged-out, local-only entries. + With these the full file is green and was REMOVED from the run-unit-tests + QUARANTINE." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + files: + - tests/zap-shared-state.test.mjs + - tests/video-modal-zap.test.mjs + change_type: ["spec_correction", "refactor_tests"] + scenarios: + - id: SCN-platform-fee-unparseable-override + given: "An unparseable platform-fee override value (e.g. 'not-a-number') with PLATFORM_FEE_PERCENT configured > 0" + when: "resolvePlatformFeePercent runs" + then: "It falls back to the configured platform default (getDefaultPlatformFeePercent), NOT to 0 — a junk override cannot silently disable/bypass the platform fee" + - id: SCN-zap-completion-state-resets-then-completes + given: "A zap whose receipts validate to a warning (awaiting validated receipt)" + when: "sendZap runs" + then: "The modal completion state is reset to false at the start of the send and set true at the terminal path, so two completion-state changes are recorded and the end state is true" + - id: SCN-zap-receipts-render-into-controller-element + given: "VideoModal.renderZapReceipts called with one validated (or one failed) receipt" + when: "rendering delegates to the inner zapController" + then: "Exactly one entry is rendered into the controller's modalZapReceipts element" + observable_outcomes: + - "resolvePlatformFeePercent('not-a-number') === getDefaultPlatformFeePercent() (> 0)" + - "completedStates grows by 2 (false then true) on a completing send; end state true" + - "controller.modalZapReceipts.children.length === 1 per rendered receipt" + determinism_controls: + - "pure fee function with explicit inputs; configured PLATFORM_FEE_PERCENT read via getDefaultPlatformFeePercent (not hard-coded)" + - "modal stub records setZapCompleted/resetZapForm calls; DummyElement DOM" + anti_cheat_rationale: + prevents: + - "hard-coded return value" + - "over-mocking internal logic" + note: "Three corrections, all to match shipped behavior. (1) The fee-fallback test + asserted 0; the function correctly falls back to the configured platform default + so a garbage override cannot zero out (bypass) the fee — corrected to assert + against getDefaultPlatformFeePercent and that the result is > 0 (STRICTER: pins + the no-bypass guarantee). (2) The completion-count test asserted +1; sendZap + legitimately resets completed=false at the start of every send and sets true at + the terminal path (+2), with the end-state==true assertion retained as the real + invariant. (3) The receipt-render tests set modalZapReceipts on the outer + VideoModal, but rendering was refactored to delegate to the inner zapController + which owns that element — set/assert on the controller's element (a harness fix, + no assertion weakened). NOTE: these accompany three PRODUCTION bug fixes found + via the same tests — NWC getPublicKey hex->bytes (connection was broken), the + in-flight 'Sending…' status mis-toned as warning, and the success path not using + the holistic resetZapForm." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + file: tests/video-modal-zap.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-zap-paid-but-receipt-unconfirmed-is-success + given: "A zap share whose PAYMENT succeeded (NWC returned a preimage) but whose kind-9735 zap receipt could not be validated on the advertised relays" + when: "sendZap finishes" + then: "It is reported as a SUCCESS (Sent N sats) with a soft note that the on-relay receipt couldn't be confirmed — NOT as a warning/error. The form resets and the attempt is marked completed." + observable_outcomes: + - "last zap status tone === 'success' (was 'warning')" + - "status message matches /sent 750 sats/i and /couldn't confirm the zap receipt/i (was /awaiting validated zap receipt/i)" + - "receipts rendered in partial mode; form reset; completedStates +2 ending true" + determinism_controls: + - "injected splitAndZap returns a paid receipt (status:success, payment.preimage) with validation.status:'failed'" + anti_cheat_rationale: + prevents: + - "snapshot rubber-stamping" + - "hard-coded return value" + note: "The previous expectation asserted the BUG: a paid share whose 9735 receipt + wasn't found was surfaced as a 'warning' error ('Awaiting validated zap receipt') + and routed through notifyError — telling users a SUCCESSFUL zap had failed + (confirmed live: the platform fee landed but the UI showed a red error). The NWC + payment preimage is proof the invoice was paid; the 9735 receipt is supplementary + and is commonly not seen by a static client (relay coverage/timing). Corrected to + assert success-with-a-soft-note. Genuine PAYMENT failures (status !== 'success') + still go through the failure/retry path, unchanged." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-23 + file: tests/zap-split.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-zap-receipt-found-by-indexed-tags + given: "A published kind-9735 zap receipt for a video zap (the zap request carries #p recipient + #e event tags)" + when: "validateZapReceipt queries relays for the receipt" + then: "It filters by the reliably-indexed #e + #p tags (not #bolt11, which most relays don't index), then confirms via author pubkey + bolt11 + description hash" + observable_outcomes: + - "the relay query filter has filters[0]['#e'] === [eventId] and ['#p'] === [recipient]" + - "filters[0]['#bolt11'] is absent" + - "a matching receipt still validates as status:'passed'" + determinism_controls: + - "mock SimplePool.list captures the filter; fixed zap request tags + test bolt11 with matching description hash" + anti_cheat_rationale: + prevents: + - "snapshot rubber-stamping" + - "over-mocking internal logic" + note: "The previous test asserted filters[0]['#bolt11'][0] === invoice — encoding the + bug. Relays generally do not index arbitrary tags like bolt11, so that query found + nothing and a successfully-paid zap was reported as having no receipt. Corrected to + assert the reliably-indexed #e/#p anchors (added an #e tag to the request to model a + real video zap). The receipt-matching assertions (passed status, correct event, + checked relays) are unchanged/retained — coverage is stronger, not weaker." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-24 + file: tests/view-counter.test.mjs + change_type: ["spec_correction", "flake_fix"] + scenarios: + - id: SCN-view-counter-legacy-path + given: "The view-counter unit harness mocks the legacy per-pointer view APIs (list/count/subscribe) on the singleton client" + when: "A pointer is subscribed and hydrated" + then: "viewCounter exercises the legacy per-pointer hydrate/subscribe path (the one viewCounter.js explicitly designates 'for tests / mocks'), so the mocked APIs actually fire" + - id: SCN-view-counter-dedupe-window-aligned + given: "Two views by the same viewer within one fixed dedupe window" + when: "Counted" + then: "They dedupe to one (floor(created_at/window) bucket, matching the replaceable view-event d-tag)" + observable_outcomes: + - "hydration triggers a list()/count() call (legacy path) instead of the batched SubscriptionManager path silently taking over" + - "two same-pubkey views in one aligned bucket yield total === 1" + determinism_controls: + - "nostrClient.getSubscriptionManager pinned to () => null so the path is deterministic" + - "the dedupe test's base timestamp is aligned to a window-bucket boundary (was Date.now(), which made the +window/2 offset straddle a boundary depending on time of day)" + anti_cheat_rationale: + prevents: + - "over-mocking internal logic" + - "retry/sleep-based flake masking" + note: "The batched SubscriptionManager path was added later (relay-storm work); the + test mocks the legacy APIs but never pinned getSubscriptionManager, so the real + singleton's manager took over and the mocks never fired. Pinning it to null restores + the intended (and code-designated 'tests/mocks') legacy-path coverage. The dedupe + assertion (count === 1) is unchanged — only its setup was made deterministic." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +--- + +```yaml +test_integrity_note: + date: 2026-06-24 + file: tests/view-counter.test.mjs + change_type: ["spec_correction"] + scenarios: + - id: SCN-hydrate-root-timestamp + given: "A latest video revision whose root event is fetched by hydrateVideoHistory" + when: "History is hydrated, then the timestamp-sync step runs" + then: "rootCreatedAtByRoot caches the earliest (root) created_at, AND the active video's rootCreatedAt FIELD is set by syncActiveVideoRootTimestamp" + observable_outcomes: + - "nostrClient.rootCreatedAtByRoot.get(rootId) === root.created_at (hydrateVideoHistory's direct output)" + - "after syncActiveVideoRootTimestamp(...), latestVideo.rootCreatedAt === root.created_at" + determinism_controls: + - "pool.get returns a fixed root event; rootCreatedAtByRoot seeded empty" + anti_cheat_rationale: + prevents: + - "snapshot rubber-stamping" + note: "hydrateVideoHistory's contract is to populate the per-root created_at MAP; the + video OBJECT's rootCreatedAt field is applied by a separate util + (syncActiveVideoRootTimestamp) after a refactor moved that unit boundary. The test + previously expected hydrateVideoHistory to mutate the passed object directly, which it + no longer does. Corrected to assert the map (its real output) and to drive the sync + util for the field — stronger, not weaker (it now exercises the full real pipeline)." + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/components/sidebar.html b/components/sidebar.html index d4788085..76be8484 100644 --- a/components/sidebar.html +++ b/components/sidebar.html @@ -54,6 +54,28 @@ /> For You + + + Trending + **Status**: Planning +> **Status**: ON HOLD (deferred 2026-06-23 — revisit after launch backlog clears) +> **Status (original)**: Planning > **Platforms**: Android, iOS, Windows, macOS, Linux > **Framework**: [Capacitor](https://capacitorjs.com/) by Ionic > **Goal**: Ship bitvid as a native app on all major platforms while keeping a single codebase with the existing web/PWA deployment. diff --git a/docs/nip46-roadmap.md b/docs/nip46-roadmap.md index aaca171c..1bc959a6 100644 --- a/docs/nip46-roadmap.md +++ b/docs/nip46-roadmap.md @@ -1,5 +1,7 @@ # NIP-46 Roadmap & Features +> **Status**: ON HOLD (deferred 2026-06-23 — current NIP-46 login is working well; revisit these enhancements later). + This document outlines the current state of our NIP-46 (Nostr Connect) implementation and tracks future enhancements. The goal is to provide a seamless, secure, and user-friendly remote signing experience. ## Current State diff --git a/docs/zap-audit-plan.md b/docs/zap-audit-plan.md index b8c51cfa..53e2a625 100644 --- a/docs/zap-audit-plan.md +++ b/docs/zap-audit-plan.md @@ -18,6 +18,88 @@ Pre-launch priority #3. The zap flow feels clunky and errors on send. platform address via `platformAddress.js` / `PLATFORM_LUD16_OVERRIDE`. ## Findings so far + +### 2026-06-23 session 3 (live-test: successful zap reported as error) +- **A successful zap was shown as a red error (FIXED, item #4).** Live test: zapping + `NosToons@coinos.io` (CORS-friendly) actually PAID — the platform fee landed — but + bitvid showed *"Awaiting validated zap receipt… No zap receipt was published on the + advertised relays."* Root cause: a share whose PAYMENT succeeded (NWC returned a + preimage) but whose kind-9735 receipt couldn't be validated was routed through + `notifyError` as a warning. The preimage is proof the invoice was paid; the 9735 is + supplementary and a static client commonly can't see it (relay coverage/timing/late + publish). Fixed (`js/ui/zapController.js`): paid-but-unvalidated is now a SUCCESS with + a soft note ("Payment went through; couldn't confirm the zap receipt on relays"). + Genuine payment failures (status !== success) still go through the failure/retry path. + Spec-corrected test in `tests/video-modal-zap.test.mjs` (see TEST_INTEGRITY.md). + RECEIPT DISCOVERY FIXED (item #4, 2026-06-23): the validator queried receipts with + `{ kinds:[9735], "#bolt11":[invoice] }` — but most relays DON'T index arbitrary tags + like `bolt11`, so the query returned nothing even when the receipt was published. + New `buildReceiptFilters` (`js/payments/zapReceiptValidator.js`) queries by the + reliably-indexed `#e` (event) / `#a` (coordinate) + `#p` (recipient) tags from the + zap request, falling back to `#bolt11` only if no anchor exists; the precise match + (author pubkey + bolt11 + description hash) still confirms the right receipt. Tests + in `tests/zap-receipt-pool.test.mjs` + spec-corrected `tests/zap-split.test.mjs`. + TIMING GAP CLOSED (2026-06-23): the validator now POLLS for the receipt — up to 3 + lookups (default), ~1.2s apart — so a receipt the recipient's service publishes a + few seconds after EOSE is still caught. Stops as soon as it's found (no delay in the + common case); attempts/delay/sleep are injectable (`receiptLookupAttempts`, + `receiptLookupDelayMs`, `sleep`) and tests stub `sleep` so they don't wait. Reason + now distinguishes "no events at all" vs "events but none matched". Tests: + `testValidateZapReceiptRetriesForLateReceipt` (found on 3rd poll) + + `testValidateZapReceiptGivesUpAfterRetries` in `tests/zap-split.test.mjs`. NOTE: in + the no-receipt case this adds ~2.4s before the (success-with-note) status shows; + acceptable for a payment confirmation. A fully background validate-and-upgrade flow + remains a possible future refinement. +- LOG NOISE seen during the live test (pre-existing, separate items): cold-start + RELAY STORM (~28 REQ/s, item #9); `bad req: uneven size input to from_hex` (odd-length + hex reaching a relay — the sanitizer #9 should catch this; re-confirm); watch-history + bucketing items with `watchedAt:0` into a `1970-01` month (minor watch-history bug). + +### 2026-06-23 session 2 (live-test fixes) +- **Couldn't type in the zap comment box (FIXED, real bug).** The shared popover + engine treats every panel as an ARIA menu and `preventDefault`'d single-character + + arrow keydowns → every keystroke in the comment/amount fields was swallowed. + `handleMenuKeydown` now ignores events from editable targets + (input/textarea/select/contenteditable/role=textbox). Regression test in + `tests/ui/popoverEngine.test.mjs`. +- **NWC wallet didn't restore "like storage" (FIXED, real bug).** The "already + offered" flag in `settingsRestorePrompt.js` was per-PUBKEY, so the first item + offered (storage) flagged the whole account and the wallet note was never offered + afterward. Now tracked PER-ITEM. Decision (user, 2026-06-23): keep the one-time + CONFIRM for the wallet (it's a spending secret) — not silent auto-restore. + Regression test in `tests/settings-restore-prompt.test.mjs`. +- **"Click the Zap button many times before it opens" (FIX — needs live confirm).** + A click during an in-flight open canceled it (`pendingToggle="close"`), so rapid + double-clicks toggled the popover back closed. Now an in-flight-open click is + ignored unless the dialog is already visibly open. (`video-modal/zapController.js`) +- **CORS confirmed for goblinbox** (`unitypay.cash/.well-known/lnurlp/goblinbox` → + net::ERR_FAILED). With the NWC fix in place, `wallet.hasUri:true` — the send fails + purely at the LNURL fetch (CORS), not NWC. This is the data point for the proxy + decision (item 1). + +### 2026-06-23 session (fixes landed) +- **NWC wallet connection was BROKEN (launch-blocker, FIXED).** `parseNwcUri` + (`js/payments/nwcClient.js`) passed the hex `secret` to nostr-tools `getPublicKey`, + which in v2.x requires a `Uint8Array` → it threw `expected Uint8Array` and the whole + NWC connect failed (NWC is the only zap payment path). Now converts hex→bytes (try + hex, fall back to `hexToBytesCompat`). Real-tools regression test: + `tests/nwc-parse-uri.test.mjs`. (The mock-based `tests/nwc-client.test.mjs` is still + quarantined — its nostr-tools mock is shadowed by the frozen canonical toolkit; needs + a harness rework, but the bug itself is fixed + guarded.) +- **In-flight "Sending… sats" status was mis-toned as `warning` (FIXED).** A pending + state was colored `text-warning-strong` (alarming) and counted as a warning. Now + `neutral`. (`js/ui/zapController.js`) +- **Success path didn't fully reset the form (FIXED).** The terminal success path + cleared only the comment (`setZapComment`) while the validation-warning path used the + holistic `resetZapForm`. Unified to `resetZapForm({amount:"",comment:""})` so amount + + comment reset consistently after a send. (`js/ui/zapController.js`) +- **Fee-fallback is no-bypass (verified).** `resolvePlatformFeePercent` falls back to + the configured `PLATFORM_FEE_PERCENT` (30) on an unparseable override — a junk override + can't silently disable the fee. (Stale test that expected 0 corrected.) +- **3a popover** — FIXED separately (see TODO 3a); the in-modal zap dialog now portals + to `#uiOverlay` so it positions `bottom-end` under the button. + +### Earlier findings - **Send error = recipient LNURL unreachable (CORS/offline).** [PARTLY FIXED] Sending to a creator whose LNURL host is down or lacks CORS headers (observed: `unitypay.cash/.well-known/lnurlp/goblinbox`) makes the browser `fetch` reject @@ -31,13 +113,15 @@ Pre-launch priority #3. The zap flow feels clunky and errors on send. - **Popover mis-positioning (zap + embed)** — see TODO 3a; shared `popoverEngine`. ## To do (next session, in priority order) -1. **CORS / LNURL proxy decision (the real reliability fix).** A static client - can't fetch LNURL endpoints that omit CORS headers, so zaps to many creators - fail. Options: (a) a small server-side/edge proxy (Vercel function) that fetches - the LNURL pay-service data + invoice and returns it with CORS — what most web - wallets do; (b) document the limitation and only support CORS-enabled hosts; - (c) route via the connected NWC wallet if it can resolve LN addresses. Decide, - then implement. Confirm whether `unitypay.cash` is also just offline. +1. **CORS / LNURL proxy — DECISION: re-test first (2026-06-23).** Because the NWC + connection was fully broken (hex secret → `getPublicKey`; fixed this session), an + unknown share of the observed "send errors" were the NWC bug, NOT CORS. Plan: + deploy the NWC fix, run real zaps, and measure the actual CORS failure rate before + committing to a proxy. THEN choose: (a) small Vercel edge proxy that fetches LNURL + pay-data + invoice and returns with CORS [recommended if the rate is high — what web + wallets do]; (b) document the limit / CORS-only hosts; (c) route LN-address + resolution via the connected NWC wallet. Also confirm whether `unitypay.cash` is + simply offline vs. CORS-less. 2. **Popover positioning (3a).** Fix the shared engine safely (portal in-modal panels out to `#uiOverlay`, or remove the modal-subtree containing block). Needs the live computed `position/left/top` + DOM parent of `#modalZapDialog`. diff --git a/js/app.js b/js/app.js index 59db1b8a..96f4d2f5 100644 --- a/js/app.js +++ b/js/app.js @@ -2974,6 +2974,37 @@ class Application { return false; } + // Direct messages require a signing identity. If nobody is logged in, prompt + // for login instead of silently opening an unusable messages tab. + if (!this.isUserLoggedIn()) { + this.showError( + "Please log in with your Nostr account to send a direct message.", + ); + try { + if ( + this.loginModalController && + typeof this.loginModalController.openModal === "function" && + this.loginModalController.openModal({}) + ) { + return false; + } + } catch (error) { + devLogger.warn( + "[Application] Failed to open login modal for direct message:", + error, + ); + } + Promise.resolve( + this.authService?.requestLogin?.({ allowAccountSelection: true }), + ).catch((error) => { + devLogger.warn( + "[Application] Direct message login request failed:", + error, + ); + }); + return false; + } + this.setDmRecipientPubkey(normalized); if (this.profileController) { @@ -3905,6 +3936,11 @@ class Application { return this._feed.registerExploreFeed(...args); } + registerTrendingFeed(...args) { + this._initCoordinators(); + return this._feed.registerTrendingFeed(...args); + } + registerSubscriptionsFeed(...args) { this._initCoordinators(); return this._feed.registerSubscriptionsFeed(...args); @@ -3950,6 +3986,11 @@ class Application { return this._feed.refreshFeed(FEED_TYPES.EXPLORE, ...args); } + refreshTrendingFeed(...args) { + this._initCoordinators(); + return this._feed.refreshFeed(FEED_TYPES.TRENDING, ...args); + } + refreshRecentFeed(...args) { this._initCoordinators(); return this._feed.refreshFeed("recent", ...args); @@ -3993,6 +4034,11 @@ class Application { return this._feed.loadExploreVideos(...args); } + async loadTrendingVideos(...args) { + this._initCoordinators(); + return this._feed.loadTrendingVideos(...args); + } + async loadOlderVideos(...args) { this._initCoordinators(); return this._feed.loadOlderVideos(...args); diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index dc79c790..22fc2f9a 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -12,7 +12,7 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; -import { FEED_TYPES } from "../constants.js"; +import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js"; /** * Kick a background watch-history refresh after login, once the feed-driving @@ -988,7 +988,11 @@ export function createAuthSessionCoordinator(deps) { typeof logoutView === "string" && logoutView.trim().toLowerCase() === FEED_TYPES.FOR_YOU ) { - setHashView(FEED_TYPES.RECENT); + // For You is logged-in only; send logged-out users to the same landing + // as the default homepage — Trending (or Recent if Trending is off). + setHashView( + FEATURE_TRENDING_FEED ? FEED_TYPES.TRENDING : FEED_TYPES.RECENT + ); } const activeModalVideo = diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index b461d2d7..2ac93bbc 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -10,6 +10,10 @@ */ import { FEED_TYPES, STANDARD_TIMEOUT_MS, MEDIUM_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js"; +import { + registerTrendingFeed as registerTrendingFeedPipeline, + buildTrendingFeedRuntime, +} from "../feedEngine/trendingFeed.js"; /** * @param {object} deps - Injected dependencies. @@ -398,7 +402,7 @@ export function createFeedCoordinator(deps) { createResolvePostedAtStage(), createExploreScorerStage(), ], - sorter: createExploreDiversitySorter(), + sorter: createExploreDiversitySorter({ lambda: 0.5 }), hooks: { timestamps: { getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), @@ -531,20 +535,11 @@ export function createFeedCoordinator(deps) { const preferencesAvailable = preferenceSource?.dataReady === true; const moderationThresholds = this.getActiveModerationThresholds(); - // Follows drive the For You ranking (follows-boost in the scorer). - let subscriptionAuthors = []; - try { - if (subscriptions && typeof subscriptions.getSubscribedAuthors === "function") { - const authors = subscriptions.getSubscribedAuthors(); - if (Array.isArray(authors)) { - subscriptionAuthors = authors.filter( - (a) => typeof a === "string" && a, - ); - } - } - } catch (error) { - devLogger.warn("[feedCoordinator] Failed to read subscribed authors:", error); - } + // Follows drive the For You ranking (follows-boost in the scorer). The + // refreshFeed caller wraps this in a try, so optional chaining is enough. + const subscriptionAuthors = ( + subscriptions?.getSubscribedAuthors?.() || [] + ).filter((a) => typeof a === "string" && a); // Watch-topic affinity signal (reuse the Explore source if present). const whTagSource = @@ -671,6 +666,11 @@ export function createFeedCoordinator(deps) { return { blacklistedEventIds: blacklist, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + // Explore leans new-to-you: the scorer applies a soft penalty to authors + // you already follow so non-followed creators surface. + subscriptionAuthors: ( + subscriptions?.getSubscribedAuthors?.() || [] + ).filter((a) => typeof a === "string" && a), tagPreferences: { interests: Array.isArray(interests) ? [...interests] : [], disinterests: Array.isArray(disinterests) ? [...disinterests] : [], @@ -848,14 +848,18 @@ export function createFeedCoordinator(deps) { ); } runtime = this.buildForYouFeedRuntime({ watchHistoryItems }); + // Keep the scorer/sorter's "your people first" order — without this + // VideoListView re-sorts chronologically and For You looks like Recent. + metadataModifier = (m) => ({ ...m, preserveOrder: true }); break; } case FEED_TYPES.KIDS: runtime = this.buildKidsFeedRuntime(); metadataModifier = (m) => { - if (runtime?.ageGroup && !m.ageGroup) - m.ageGroup = runtime.ageGroup; - return m; + const next = { ...m, preserveOrder: true }; + if (runtime?.ageGroup && !next.ageGroup) + next.ageGroup = runtime.ageGroup; + return next; }; break; case FEED_TYPES.EXPLORE: @@ -867,6 +871,11 @@ export function createFeedCoordinator(deps) { return next; }; break; + case FEED_TYPES.TRENDING: + runtime = buildTrendingFeedRuntime(this); + // Preserve the view-count ranking (otherwise re-sorted by date). + metadataModifier = (m) => ({ ...m, preserveOrder: true }); + break; case "recent": runtime = this.buildRecentFeedRuntime(); break; @@ -1026,6 +1035,12 @@ export function createFeedCoordinator(deps) { refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts); shouldCheckRelayHealth = false; break; + case FEED_TYPES.TRENDING: + includeTags = false; + loadingMessage = "Fetching trending videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.TRENDING, opts); + shouldCheckRelayHealth = false; + break; default: devLogger.warn( `[Application] Unknown feed type for loadFeedVideos: ${feedType}`, @@ -1120,6 +1135,14 @@ export function createFeedCoordinator(deps) { return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch); }, + registerTrendingFeed() { + return registerTrendingFeedPipeline(this); + }, + + async loadTrendingVideos(forceFetch = false) { + return this.loadFeedVideos(FEED_TYPES.TRENDING, forceFetch); + }, + async loadOlderVideos(lastTimestamp) { const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, { blacklistedEventIds: this.blacklistedEventIds, diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index e9f00649..37bae812 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -867,6 +867,10 @@ export function createPlaybackCoordinator(deps) { const playbackOptions = { url: trimmedUrl, magnet: magnetInput, + // Hosted mirrors (NIP-71 imeta variants) to fail over to mid-load. + sources: Array.isArray(this.currentVideo?.sources) + ? this.currentVideo.sources + : [], }; if (hasTrigger) { playbackOptions.trigger = this.lastModalTrigger; diff --git a/js/channelProfile.js b/js/channelProfile.js index e8e4935d..b6e59fe4 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -1,7 +1,7 @@ // js/channelProfile.js import { nostrClient } from "./nostrClientFacade.js"; -import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js"; +import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5440,7 +5440,7 @@ async function loadUserVideos(pubkey) { return; } - const videos = buildRenderableChannelVideos({ events, app }); + const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app }); saveCachedChannelVideos(pubkey, videos); const rendered = await renderChannelVideosFromList({ videos, diff --git a/js/channelProfileVideos.js b/js/channelProfileVideos.js index 766d8f3b..669d9d02 100644 --- a/js/channelProfileVideos.js +++ b/js/channelProfileVideos.js @@ -28,6 +28,33 @@ export function convertChannelEvent(entry) { return sharedConvertEventToVideo(entry); } +// Reconcile a fresh live fetch with already-known events (the in-memory video +// store + persisted cache) so a transiently-failing relay can't SHRINK the grid. +// The channel render replaces the container with the live set, so rendering only +// what this load fetched drops any video whose sole relay timed out — the "some +// of a creator's videos are missing" bug. Union the two by event id (live wins on +// collision since it's the freshest copy); the caller's dedupe-by-root + access +// checks still handle versioning, deletes, and precedence downstream. +export function mergeChannelVideoSources(liveEvents, knownEvents) { + const byId = new Map(); + const extras = []; + const add = (list) => { + if (!Array.isArray(list)) return; + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const id = typeof entry.id === "string" ? entry.id : ""; + if (!id) { + extras.push(entry); + continue; + } + if (!byId.has(id)) byId.set(id, entry); + } + }; + add(liveEvents); // live first → wins on id collision + add(knownEvents); + return [...byId.values(), ...extras]; +} + // ---- Per-channel persisted cache (instant cold-load paint) ------------------- // Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile, // render the last-seen videos from localStorage BEFORE relays connect, then the diff --git a/js/constants.js b/js/constants.js index 5cb050cc..2dfc4d5f 100644 --- a/js/constants.js +++ b/js/constants.js @@ -197,6 +197,7 @@ const DEFAULT_FLAGS = Object.freeze({ FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes. FEATURE_TRUSTED_HIDE_CONTROLS: true, FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes + FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab. TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, WSS_TRACKERS: DEFAULT_WSS_TRACKERS, @@ -220,6 +221,7 @@ const runtimeFlags = (() => { FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS, FEATURE_IMPROVED_COMMENT_FETCHING: DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING, + FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED, 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], @@ -265,6 +267,11 @@ export let FEATURE_SEARCH_FILTERS = coerceBoolean( DEFAULT_FLAGS.FEATURE_SEARCH_FILTERS ); +export let FEATURE_TRENDING_FEED = coerceBoolean( + runtimeFlags.FEATURE_TRENDING_FEED, + DEFAULT_FLAGS.FEATURE_TRENDING_FEED +); + export let FEATURE_TRUST_SEEDS = coerceBoolean( runtimeFlags.FEATURE_TRUST_SEEDS, DEFAULT_FLAGS.FEATURE_TRUST_SEEDS @@ -538,6 +545,7 @@ export const FEED_TYPES = Object.freeze({ FOR_YOU: "for-you", KIDS: "kids", EXPLORE: "explore", + TRENDING: "trending", SUBSCRIPTIONS: "subscriptions", HISTORY: "history", CHANNEL: "channel-profile", diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index e4f83cde..28f2c086 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -261,6 +261,21 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLega } } + // Legacy (kind 4) default: with no explicit hint, kind-4 DMs are NIP-04, so + // try nip04 first and don't burn a (serialized, slow) nip-07 round-trip on + // nip44. This MUST outrank `priority` below — the signer registers its nip44 + // candidate at a lower (earlier) priority than nip04, so leaving priority to + // decide made every kind-4 message attempt nip44 first (guaranteed "invalid + // base64" on the ?iv= ciphertext) before falling back to nip04. Only flips + // the nip04-vs-nip44 default; an explicit hint above already took over. + if (preferLegacy && !hasHints) { + const aL = a.scheme === "nip04" ? 0 : 1; + const bL = b.scheme === "nip04" ? 0 : 1; + if (aL !== bL) { + return aL - bL; + } + } + if (a.priority !== b.priority) { return a.priority - b.priority; } @@ -273,17 +288,6 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLega } } - // Legacy (kind 4) default: with no explicit hint, kind-4 DMs are NIP-04, so - // try nip04 first and don't burn a (serialized, slow) nip-07 round-trip on - // nip44. Only flips the default — an explicit hint above already took over. - if (preferLegacy && !hasHints) { - const aL = a.scheme === "nip04" ? 0 : 1; - const bL = b.scheme === "nip04" ? 0 : 1; - if (aL !== bL) { - return aL - bL; - } - } - const aRank = desiredSchemeRank(a.scheme); const bRank = desiredSchemeRank(b.scheme); if (aRank !== bRank) { diff --git a/js/feedEngine/exploreScoring.js b/js/feedEngine/exploreScoring.js index 01a47250..2ae562f7 100644 --- a/js/feedEngine/exploreScoring.js +++ b/js/feedEngine/exploreScoring.js @@ -4,12 +4,13 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { isPlainObject, toSet } from "./utils.js"; const DEFAULT_WEIGHTS = Object.freeze({ - novelty: 0.3, - freshness: 0.25, + novelty: 0.35, // reward topics outside your watch history (discovery) + freshness: 0.12, // Explore is discovery, not "what's new" — recency matters less historySimilarity: 0.2, newTagFraction: 0.1, popularityNorm: 0.1, disinterestOverlap: 0.25, + followedPenalty: 0.3, // deprioritize creators you already follow → new-to-you }); const DEFAULT_FRESHNESS_HALF_LIFE_DAYS = 14; @@ -319,6 +320,10 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti const popularityMax = resolvePopularityMax(options, context); const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context); const now = resolveNow(context); + // Soft penalty for creators you already follow so Explore leans toward + // creators that are NEW to you. A penalty (not a filter) keeps the feed full + // when you follow lots of people. + const follows = toSet(context?.runtime?.subscriptionAuthors); for (const item of items) { if (!item || typeof item !== "object") { @@ -379,13 +384,17 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti freshness = clamp01(decay); } + const followedMatch = + typeof video.pubkey === "string" && follows.has(video.pubkey) ? 1 : 0; + const score = clamp01( weights.novelty * novelty + weights.freshness * freshness + weights.historySimilarity * historySimilarityPositive + weights.newTagFraction * newTagFraction + weights.popularityNorm * popularityNorm - - weights.disinterestOverlap * disinterestOverlap, + weights.disinterestOverlap * disinterestOverlap - + (weights.followedPenalty || 0) * followedMatch, ); if (!isPlainObject(item.metadata)) { @@ -400,6 +409,7 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti historySimilarity, popularityNorm, freshness, + followedMatch, tags: Array.from(videoTags), }; @@ -459,6 +469,11 @@ const FOR_YOU_WEIGHTS = Object.freeze({ disinterestOverlap: 0.3, // penalty for disinterested tags }); +// Affinity at/above this counts as a real interest match (tier 1), so +// interest/watch-topic videos lead the feed alongside follows. Tuned low because +// cosine affinity against a sparse user vector rarely gets near 1. +const FOR_YOU_AFFINITY_TIER = 0.15; + /** * For You scorer (docs/feed-algo-audit.md). Inclusive + ranked: it scores (never * filters) so the feed is never empty — when you have no follows/interests/ @@ -540,6 +555,16 @@ export function createForYouScorerStage({ item.metadata = {}; } item.metadata.forYouScore = score; + // Tier drives "your people first": followed authors (2) and interest/ + // watch-topic matches (1) lead the feed regardless of recency; everything + // else (0) ranks below by score. The sorter reads this; when NO item is + // tiered (logged-out / no follows / no interests) it falls back to a + // discovery ordering so For You never mirrors Recently-added. + item.metadata.forYouTier = followsMatch + ? 2 + : affinity >= FOR_YOU_AFFINITY_TIER + ? 1 + : 0; item.metadata.forYouComponents = { followsMatch, affinity, diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index e3a97f63..66b52878 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -3,21 +3,122 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { markAsNormalized } from "./utils.js"; -// Ranks by the For You score (set by createForYouScorerStage): score desc, -// trusted-muted last, recency as the tiebreak. Reuses the same muted-last rule -// as the chronological sorter so behavior is consistent across feeds. +// Ranks the active feed by VIEW COUNT (the "Trending" tab). Counts come from the +// injected runtime.getViewCount(video) — the shared viewCounter cache populated +// by the grid cards' per-card subscriptions. Unknown/not-yet-loaded counts are +// treated as 0, so on a cold cache the feed reads as recency and settles into +// true trending as counts stream in (the Trending view re-runs the feed on +// viewCounter's debounced change signal). Trusted-muted always sinks last. +export function createTrendingSorter() { + return function trendingSorter(items = [], context = {}) { + if (!Array.isArray(items)) { + return []; + } + const getViewCount = + typeof context?.runtime?.getViewCount === "function" + ? context.runtime.getViewCount + : () => 0; + + const isMuted = (entry) => + entry?.metadata?.moderation?.trustedMuted === true || + entry?.video?.moderation?.trustedMuted === true; + + const timestampOf = (entry) => { + const video = entry?.video; + if (!video || typeof video !== "object") { + return Number.NEGATIVE_INFINITY; + } + const root = Number(video.rootCreatedAt); + if (Number.isFinite(root)) return Math.floor(root); + const created = Number(video.created_at); + return Number.isFinite(created) ? Math.floor(created) : Number.NEGATIVE_INFINITY; + }; + + // Resolve each item's view count once (avoid O(n log n) cache lookups). + const viewsByItem = new Map(); + for (const item of items) { + let total = 0; + try { + const value = getViewCount(item?.video); + total = Number.isFinite(value) ? Number(value) : 0; + } catch (error) { + total = 0; + } + viewsByItem.set(item, total); + } + + const compare = (a, b) => { + const viewDiff = (viewsByItem.get(b) || 0) - (viewsByItem.get(a) || 0); + if (viewDiff !== 0) return viewDiff; + return timestampOf(b) - timestampOf(a); + }; + + const live = items.filter((entry) => !isMuted(entry)); + const muted = items.filter((entry) => isMuted(entry)); + return [...live.sort(compare), ...muted.sort(compare)]; + }; +} + +// Round-robin interleave by author: one video per creator per pass, each pass in +// the caller's pre-sorted order. Consecutive cards are from different creators, +// so a feed reads as a varied "discovery" mix instead of a chronological wall — +// used for For You's no-signal fallback and Explore's diversity. +export function interleaveByAuthor(items, { authorOf } = {}) { + if (!Array.isArray(items) || items.length < 3) { + return Array.isArray(items) ? [...items] : []; + } + const keyOf = + typeof authorOf === "function" + ? authorOf + : (entry) => + typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : ""; + + const buckets = new Map(); + const order = []; + for (const item of items) { + const key = keyOf(item) || `__anon__${order.length}`; + if (!buckets.has(key)) { + buckets.set(key, []); + order.push(key); + } + buckets.get(key).push(item); + } + + const result = []; + let remaining = items.length; + while (remaining > 0) { + for (const key of order) { + const bucket = buckets.get(key); + if (bucket && bucket.length) { + result.push(bucket.shift()); + remaining -= 1; + } + } + } + return result; +} + +// Ranks the For You feed "your people first": followed authors and interest/ +// watch-topic matches (forYouTier, set by the scorer) lead regardless of +// recency, then everyone else by score. When NOTHING is tiered (logged-out / no +// follows / no interests) it falls back to an author-interleaved discovery order +// so the feed never mirrors Recently-added. Trusted-muted always sinks last. export function createForYouScoreSorter() { return function forYouScoreSorter(items = [], context = {}) { if (!Array.isArray(items)) { return []; } void context; - const copy = [...items]; const isMuted = (entry) => entry?.metadata?.moderation?.trustedMuted === true || entry?.video?.moderation?.trustedMuted === true; + const tierOf = (entry) => { + const tier = Number(entry?.metadata?.forYouTier); + return Number.isFinite(tier) ? tier : 0; + }; + const scoreOf = (entry) => { const score = Number(entry?.metadata?.forYouScore); return Number.isFinite(score) ? score : 0; @@ -34,20 +135,32 @@ export function createForYouScoreSorter() { return Number.isFinite(created) ? Math.floor(created) : Number.NEGATIVE_INFINITY; }; - copy.sort((a, b) => { - const aMuted = isMuted(a); - const bMuted = isMuted(b); - if (aMuted !== bMuted) { - return aMuted ? 1 : -1; - } - const scoreDiff = scoreOf(b) - scoreOf(a); - if (scoreDiff !== 0) { - return scoreDiff; - } - return timestampOf(b) - timestampOf(a); - }); + const live = items.filter((entry) => !isMuted(entry)); + const muted = items.filter((entry) => isMuted(entry)); - return copy; + const byScore = (a, b) => { + const scoreDiff = scoreOf(b) - scoreOf(a); + if (scoreDiff !== 0) return scoreDiff; + return timestampOf(b) - timestampOf(a); + }; + + const personalized = live.some((entry) => tierOf(entry) > 0); + + let ordered; + if (personalized) { + // Tier desc, then score within tier. "Your people" rise to the top. + ordered = [...live].sort((a, b) => { + const tierDiff = tierOf(b) - tierOf(a); + if (tierDiff !== 0) return tierDiff; + return byScore(a, b); + }); + } else { + // No signals → discovery fallback: score order, then interleave authors so + // it doesn't read as a chronological clone of Recently-added. + ordered = interleaveByAuthor([...live].sort(byScore)); + } + + return [...ordered, ...muted.sort(byScore)]; }; } diff --git a/js/feedEngine/trendingFeed.js b/js/feedEngine/trendingFeed.js new file mode 100644 index 00000000..c85e3ae8 --- /dev/null +++ b/js/feedEngine/trendingFeed.js @@ -0,0 +1,124 @@ +// js/feedEngine/trendingFeed.js +// +// The "Trending" tab: the recently-added (active) source ranked by VIEW COUNT. +// Lives outside feedCoordinator.js (which is at its size cap) and is wired in via +// thin delegations. The sorter ranks by the shared viewCounter cache; the +// Trending view re-runs the feed on viewCounter's debounced change signal so the +// order settles into true trending as counts stream in. + +import { FEED_TYPES } from "../constants.js"; +import { getVideoViewCountSnapshot } from "../viewCounter.js"; +import { + createActiveNostrSource, + createBlacklistFilterStage, + createDedupeByRootStage, + createModerationStage, + createResolvePostedAtStage, +} from "./index.js"; +import { createTrendingSorter } from "./sorters.js"; + +// Resolve a video's deduped view total from the shared viewCounter cache using +// the app's canonical pointer derivation (the same key the grid cards subscribe +// with, so Trending and the per-card count agree). +function makeGetViewCount(app) { + return (video) => { + if (!video || typeof video !== "object") { + return 0; + } + try { + const info = + typeof app.deriveVideoPointerInfo === "function" + ? app.deriveVideoPointerInfo(video) + : null; + if (!info?.pointer) { + return 0; + } + const total = getVideoViewCountSnapshot(info.pointer); + return Number.isFinite(total) ? Number(total) : 0; + } catch (error) { + return 0; + } + }; +} + +export function buildTrendingFeedRuntime(app) { + const blacklist = + app.blacklistedEventIds instanceof Set + ? new Set(app.blacklistedEventIds) + : new Set(); + const moderationThresholds = + typeof app.getActiveModerationThresholds === "function" + ? app.getActiveModerationThresholds() + : null; + return { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => app.isAuthorBlocked(pubkey), + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, + getViewCount: makeGetViewCount(app), + }; +} + +export function registerTrendingFeed(app) { + if (!app?.feedEngine || typeof app.feedEngine.registerFeed !== "function") { + return null; + } + const existing = + typeof app.feedEngine.getFeedDefinition === "function" + ? app.feedEngine.getFeedDefinition(FEED_TYPES.TRENDING) + : null; + if (existing) { + return existing; + } + + const resolveThreshold = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + if (typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = + active && typeof active === "object" ? active[key] : undefined; + if (Number.isFinite(candidate) || candidate === Number.POSITIVE_INFINITY) { + return candidate; + } + } + return defaultValue; + }; + + try { + return app.feedEngine.registerFeed(FEED_TYPES.TRENDING, { + source: createActiveNostrSource({ service: app.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + app.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => app.dedupeVideosByRoot(videos), + }), + createModerationStage({ + getService: () => app.nostrService.getModerationService(), + autoplayThreshold: resolveThreshold("autoplayBlockThreshold"), + blurThreshold: resolveThreshold("blurThreshold"), + trustedMuteHideThreshold: resolveThreshold("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThreshold("trustedSpamHideThreshold"), + }), + createResolvePostedAtStage(), + ], + sorter: createTrendingSorter(), + hooks: { + timestamps: { + getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => app.resolveVideoPostedAt(video), + }, + }, + }); + } catch (error) { + return null; + } +} diff --git a/js/hashChangeHandler.js b/js/hashChangeHandler.js index 705d0a57..7ccb36ec 100644 --- a/js/hashChangeHandler.js +++ b/js/hashChangeHandler.js @@ -1,5 +1,5 @@ import { devLogger as defaultDevLogger, userLogger as defaultUserLogger } from "./utils/logger.js"; -import { FEED_TYPES } from "./constants.js"; +import { FEED_TYPES, FEATURE_TRENDING_FEED } from "./constants.js"; const LEGACY_DOCS_VIEWS = { about: "about", @@ -90,8 +90,12 @@ export function createHashChangeHandler({ try { if (!match || !match[1]) { + // Logged in → personalized For You; logged out → Trending (the best + // not-personalized landing), falling back to Recent if Trending is off. const defaultViewName = resolveIsLoggedIn() ? FEED_TYPES.FOR_YOU + : FEATURE_TRENDING_FEED + ? FEED_TYPES.TRENDING : FEED_TYPES.RECENT; if (defaultViewName === currentViewName) { diff --git a/js/nostr/client.js b/js/nostr/client.js index 4437e117..4faf16e0 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -42,6 +42,10 @@ import { resolveStoragePointerValue, } from "../utils/storagePointer.js"; import { RelayBatchFetcher } from "./relayBatchFetcher.js"; +// Single source of truth for active-key derivation. The tombstone/zombie-guard +// logic below relies on it, and a stale local copy (missing the LEGACY: guard) +// caused deleted legacy videos to resurrect — keep this imported, not duplicated. +import { getActiveKey } from "./utils.js"; import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js"; import { createWatchHistoryManager, @@ -407,17 +411,6 @@ function buildDmFilters(actorPubkey, { since, until, limit } = {}) { return filters; } -function getActiveKey(video) { - if (video.videoRootId) { - return `ROOT:${video.videoRootId}`; - } - const dTag = video.tags?.find((t) => t[0] === "d"); - if (dTag) { - return `${video.pubkey}:${dTag[1]}`; - } - return `LEGACY:${video.id}`; -} - export { convertEventToVideo, Nip46RpcClient, diff --git a/js/nostr/toolkit.js b/js/nostr/toolkit.js index 94f46e5a..05d47e0b 100644 --- a/js/nostr/toolkit.js +++ b/js/nostr/toolkit.js @@ -151,7 +151,11 @@ function normalizeRelayList(relays) { // 64-char lowercase hex. A single malformed/odd-length entry makes strict relays // (e.g. primal) reject the whole REQ with "uneven size input to from_hex", // silently dropping that subscription's results. -const HEX64_FILTER_FIELDS = ["ids", "authors", "#e", "#p", "#q"]; +// Includes the NIP-22 UPPERCASE root-scope tags #E (root event id) and #P (root +// author pubkey) — relays hex-decode these too, and the kind-1111 comment +// subscriptions query by them, so an odd-length value there was bypassing the +// sanitizer and getting the whole REQ rejected with "uneven size input to from_hex". +const HEX64_FILTER_FIELDS = ["ids", "authors", "#e", "#p", "#q", "#E", "#P"]; const HEX64_PATTERN = /^[0-9a-f]{64}$/; // Strip invalid hex from the hex-only filter fields so one bad value can't get a diff --git a/js/nostr/viewEvents.js b/js/nostr/viewEvents.js index d2740263..b38988ba 100644 --- a/js/nostr/viewEvents.js +++ b/js/nostr/viewEvents.js @@ -145,11 +145,14 @@ function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) { typeof actorPubkey === "string" && actorPubkey.trim() ? actorPubkey.trim().toLowerCase() : "anon"; - const timestamp = Number.isFinite(createdAtSeconds) - ? Math.max(0, Math.floor(createdAtSeconds)) - : Math.floor(Date.now() / 1000); - const entropy = generateViewEventEntropy(); - return `${scope}:${normalizedActor}:${timestamp}:${entropy}`; + // DETERMINISTIC + window-bucketed. Kind-30079 view events are parameterized- + // replaceable (deduped by kind+pubkey+d-tag), so a stable d-tag per + // (viewer, video, dedupe-window) makes the relay collapse a viewer's repeat + // views in the same window into ONE event. That keeps both the NIP-45 COUNT and + // a list() accurate AND scalable. The old entropy+exact-timestamp tag made every + // view a unique event, defeating dedupe and inflating counts (item #4 audit). + const bucketIndex = deriveViewEventBucketIndex(createdAtSeconds); + return `${scope}:${normalizedActor}:${bucketIndex}`; } export function hasRecentViewPublish(scope, bucketIndex, actorPubkey) { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 9c1c07a3..e3056d2b 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -41,6 +41,7 @@ import { import { pMap } from "../utils/asyncUtils.js"; import { selectNewestListEvent } from "./listEventOrdering.js"; import { dedupeNewestPerReplaceableAddress } from "./watchHistoryDedup.js"; +import { bucketWatchHistoryItemsByMonth } from "./watchHistoryBucketing.js"; import { getCachedChunkPlaintext, setCachedChunkPlaintext, @@ -306,7 +307,7 @@ export function normalizeActorKey(actor) { return trimmed.toLowerCase(); } -function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_ITEMS) { +export function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_ITEMS) { const seen = new Map(); if (Array.isArray(rawItems)) { for (const candidate of rawItems) { @@ -360,20 +361,7 @@ function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_IT ? deduped : deduped.slice(0, Math.max(0, Math.floor(maxItems))); - const buckets = {}; - for (const item of limited) { - const watchedAt = Number.isFinite(item.watchedAt) ? item.watchedAt : 0; - const date = new Date(watchedAt * 1000); - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, '0'); - const key = (watchedAt > 0) ? `${year}-${month}` : "1970-01"; - - if (!buckets[key]) { - buckets[key] = []; - } - buckets[key].push(item); - } - return buckets; + return bucketWatchHistoryItemsByMonth(limited); } diff --git a/js/nostr/watchHistoryBucketing.js b/js/nostr/watchHistoryBucketing.js new file mode 100644 index 00000000..167a7a1b --- /dev/null +++ b/js/nostr/watchHistoryBucketing.js @@ -0,0 +1,60 @@ +// Watch-history month bucketing. Items are grouped into "YYYY-MM" buckets by their +// watch time. Legacy/migrated items can arrive with no real watch time +// (watchedAt 0); those used to silo into a literal "1970-01" month that +// self-perpetuated (read back as 0 -> re-bucketed -> re-published with 0). bitvid +// pointer values look like "::" and the dTag usually embeds the +// video's creation time as a 13-digit millisecond timestamp, so we derive a STABLE +// fallback from it instead. + +// Returns the embedded creation time (seconds) from a pointer's d-tag, or 0 if no +// plausible 13-digit ms timestamp is present (e.g. a hash-only d-tag). +export function deriveWatchedAtFromPointer(pointer) { + const value = typeof pointer?.value === "string" ? pointer.value : ""; + if (!value) { + return 0; + } + const dTag = value.includes(":") ? value.slice(value.lastIndexOf(":") + 1) : value; + const matches = dTag.match(/\d{13}/g); + if (!matches) { + return 0; + } + // Constrain to a sane ms range (~2014..~2033) so we don't latch onto 13 random + // digits inside a hex d-tag. + const MIN_MS = 1_400_000_000_000; + const MAX_MS = 2_000_000_000_000; + for (const match of matches) { + const ms = Number(match); + if (Number.isFinite(ms) && ms >= MIN_MS && ms <= MAX_MS) { + return Math.floor(ms / 1000); + } + } + return 0; +} + +// Group already-deduped/sorted/limited pointer items into { "YYYY-MM": [items] }. +// For items with no watch time, derive a stable one from the d-tag and BACKFILL it +// (this is what breaks the 0 -> "1970-01" -> 0 loop). Only truly undecodable items +// still land in the stable epoch bucket. +export function bucketWatchHistoryItemsByMonth(items) { + const buckets = {}; + const list = Array.isArray(items) ? items : []; + for (const item of list) { + let watchedAt = Number.isFinite(item.watchedAt) ? item.watchedAt : 0; + if (watchedAt <= 0) { + const derived = deriveWatchedAtFromPointer(item); + if (derived > 0) { + watchedAt = derived; + item.watchedAt = derived; + } + } + const date = new Date(watchedAt * 1000); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const key = watchedAt > 0 ? `${year}-${month}` : "1970-01"; + if (!buckets[key]) { + buckets[key] = []; + } + buckets[key].push(item); + } + return buckets; +} diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 55ed1695..9ae48f1c 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -2199,11 +2199,11 @@ export function buildViewEvent(params) { tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); } - const resolvedDedupeTag = - dedupeTag || - (schema?.identifierTag?.name - ? Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) - : ""); + // No random fallback: view events must be parameterized-replaceable on the + // exact d-tag the caller supplies (publishViewEvent computes a deterministic, + // window-bucketed one). A random d-tag here would make every view a unique + // event and defeat relay-side dedupe, inflating view counts (item #4 audit). + const resolvedDedupeTag = typeof dedupeTag === "string" ? dedupeTag : ""; if (resolvedDedupeTag && schema?.identifierTag?.name) { const identifierName = schema.identifierTag.name; diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 1329be1f..00a9cc5f 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -306,6 +306,48 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { }; } +// Browser-side reachability probe for a Lightning address. Does exactly what a +// zapper's browser would do — resolve the address and fetch its LNURL pay-service +// data. If that fails (host offline, TLS-broken, or — most common — missing CORS +// headers), nobody can zap this address from a static client like bitvid either. +// Use it to proactively warn a creator at wallet-connect time. +// Returns: { ok, reason, address, allowsNostr }. +// reason: "ok" | "invalid-address" | "lnurl-unreachable" | "no-nostr" | +export async function checkLightningAddressZappable(address, { fetcher } = {}) { + let resolved; + try { + resolved = resolveLightningAddress(address); + } catch (error) { + return { + ok: false, + reason: "invalid-address", + address: typeof address === "string" ? address.trim() : "", + }; + } + + try { + const metadata = await fetchPayServiceData(resolved.url, { fetcher }); + // Reachable from a browser. A pay endpoint that doesn't advertise Nostr can + // still take a normal payment but won't produce a zap receipt — surface it. + if (metadata && metadata.allowsNostr !== true) { + return { + ok: true, + reason: "no-nostr", + address: resolved.address, + allowsNostr: false, + }; + } + return { ok: true, reason: "ok", address: resolved.address, allowsNostr: true }; + } catch (error) { + return { + ok: false, + reason: error?.code || (typeof error?.message === "string" ? error.message : "lnurl-unreachable"), + address: resolved.address, + error, + }; + } +} + export function validateInvoiceAmount(metadata, amountSats) { if (!metadata || typeof metadata !== "object") { throw new Error("LNURL metadata is required to validate invoice amounts."); diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js index 5a616cbd..8933b7b4 100644 --- a/js/payments/nwcClient.js +++ b/js/payments/nwcClient.js @@ -329,7 +329,16 @@ function parseNwcUri(uri) { } const tools = assertNostrTools(["getPublicKey"]); - const clientPubkey = tools.getPublicKey(secretKey); + // nostr-tools >= 2.x requires the secret key as bytes (Uint8Array), not a hex + // string — passing hex throws "expected Uint8Array". Try hex first for forward/ + // backward compat with toolkits that still accept it, then fall back to bytes. + // Without this, parseNwcUri throws and NWC wallet connection fails entirely. + let clientPubkey; + try { + clientPubkey = tools.getPublicKey(secretKey); + } catch (error) { + clientPubkey = tools.getPublicKey(hexToBytesCompat(secretKey)); + } const queryParams = {}; for (const [key, values] of additionalParams.entries()) { diff --git a/js/payments/zapReceiptValidator.js b/js/payments/zapReceiptValidator.js index f35d46b2..fb1d8c30 100644 --- a/js/payments/zapReceiptValidator.js +++ b/js/payments/zapReceiptValidator.js @@ -100,6 +100,51 @@ function resolveRelayUrls(zapEvent) { return normalizeRelayList(relaysTag.slice(1)); } +// Build the relay query to FIND the zap receipt (kind 9735). The receipt copies +// the zap request's #e (event) / #a (coordinate) / #p (recipient) tags, and those +// are the tags relays actually index. A previous implementation filtered ONLY on +// "#bolt11": [invoice] — but most relays do NOT index arbitrary tags like bolt11, +// so the query returned nothing even when the receipt was published, and a +// successful zap looked unconfirmed. Prefer the indexed anchors; the precise +// match (author pubkey + bolt11 + description) still confirms the right receipt. +export function buildReceiptFilters(parsedZapRequest, invoiceCandidate) { + const tags = Array.isArray(parsedZapRequest?.tags) ? parsedZapRequest.tags : []; + const findTag = (name) => { + const tag = tags.find( + (entry) => + Array.isArray(entry) && + entry[0] === name && + typeof entry[1] === "string" && + entry[1].trim() + ); + return tag ? tag[1].trim() : ""; + }; + + const recipient = findTag("p"); + const eventId = findTag("e"); + const coordinate = findTag("a"); + + const filter = { kinds: [ZAP_RECEIPT_KIND], limit: 20 }; + if (eventId) { + filter["#e"] = [eventId]; + } else if (coordinate) { + filter["#a"] = [coordinate]; + } + if (recipient) { + filter["#p"] = [recipient]; + } + + // Last resort: no reliably-indexed anchor available — fall back to the bolt11 + // tag (works only on relays that happen to index it). + if (!filter["#e"] && !filter["#a"] && !filter["#p"]) { + const bolt = + typeof invoiceCandidate === "string" ? invoiceCandidate.toLowerCase() : ""; + return [{ kinds: [ZAP_RECEIPT_KIND], "#bolt11": [bolt], limit: 10 }]; + } + + return [filter]; +} + function normalizeInvoiceValue(invoice) { if (typeof invoice !== "string") { return ""; @@ -377,44 +422,10 @@ export async function validateZapReceipt(context = {}, overrides = {}) { }); } - const filters = [ - { - kinds: [ZAP_RECEIPT_KIND], - "#bolt11": [invoiceCandidate.toLowerCase()], - limit: 10, - }, - ]; - - let events = []; - try { - if (typeof overrides.listEvents === "function") { - events = await overrides.listEvents(pool, relayUrls, filters); - } else { - events = await pool.list(relayUrls, filters); - } - } catch (error) { - userLogger.warn("[zapReceiptValidator] Failed to fetch zap receipts from relays.", error); - events = []; - } finally { - try { - if (typeof pool.close === "function") { - pool.close(relayUrls); - } - } catch (error) { - // Ignore close errors. - } - } - - if (!Array.isArray(events) || !events.length) { - return buildValidationResult({ - status: "failed", - reason: "No zap receipt was published on the advertised relays.", - relays: relayUrls, - }); - } + const filters = buildReceiptFilters(parsedZapRequest, invoiceCandidate); const normalizedBolt = invoiceCandidate.toLowerCase(); - const successfulEvent = events.find((event) => { + const matchesReceipt = (event) => { if (!event || typeof event !== "object") { return false; } @@ -447,14 +458,75 @@ export async function validateZapReceipt(context = {}, overrides = {}) { if (!descriptionTag) { return false; } - const descriptionValue = descriptionTag[1]; - return descriptionValue === zapRequestString; - }); + return descriptionTag[1] === zapRequestString; + }; + + // The recipient's Lightning service publishes the 9735 receipt asynchronously + // AFTER the invoice is paid — it can land a few seconds after our first query + // (which resolves on EOSE). One immediate lookup therefore misses slow receipts. + // Poll a few times with a short delay before giving up. Injectable for tests. + const attempts = Number.isFinite(overrides.receiptLookupAttempts) + ? Math.max(1, Math.floor(overrides.receiptLookupAttempts)) + : 3; + const retryDelayMs = Number.isFinite(overrides.receiptLookupDelayMs) + ? Math.max(0, Math.floor(overrides.receiptLookupDelayMs)) + : 1200; + const sleep = + typeof overrides.sleep === "function" + ? overrides.sleep + : (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + let successfulEvent = null; + let sawAnyEvents = false; + try { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (attempt > 0 && retryDelayMs > 0) { + await sleep(retryDelayMs); + } + + let events = []; + try { + if (typeof overrides.listEvents === "function") { + events = await overrides.listEvents(pool, relayUrls, filters); + } else { + events = await pool.list(relayUrls, filters); + } + } catch (error) { + userLogger.warn( + "[zapReceiptValidator] Failed to fetch zap receipts from relays.", + error, + ); + events = []; + } + + if (Array.isArray(events) && events.length) { + sawAnyEvents = true; + const match = events.find(matchesReceipt); + if (match) { + successfulEvent = match; + break; + } + } + } + } finally { + try { + if (typeof pool.close === "function") { + pool.close(relayUrls); + } + } catch (error) { + // Ignore close errors. + } + } if (!successfulEvent) { return buildValidationResult({ status: "failed", - reason: "No compliant zap receipt matched the zap request.", + // Distinguish "nothing was there at all" from "events existed but none + // matched" — the latter is more suspicious; the former is usually just + // relay coverage/timing. + reason: sawAnyEvents + ? "No compliant zap receipt matched the zap request." + : "No zap receipt was published on the advertised relays.", relays: relayUrls, }); } diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index ef85233f..68817795 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -22,7 +22,7 @@ import { import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; -import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "../nostr/toolkit.js"; import { relayManager } from "../relayManager.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, @@ -686,7 +686,8 @@ class HashtagPreferencesService { } const readRelays = relayManager.getReadRelayUrls(); - const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); + // capReadRelays bounds to the §17 read cap (≤8) so this doesn't fan out across the full NIP-65 set at login. + const relays = capReadRelays(readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS)); if (!relays.length) { if (wasLoadedForUser) { userLogger.warn( diff --git a/js/services/nip71MirrorFlags.js b/js/services/nip71MirrorFlags.js index 899e2c84..6331381f 100644 --- a/js/services/nip71MirrorFlags.js +++ b/js/services/nip71MirrorFlags.js @@ -137,9 +137,18 @@ export function resolveEditSync({ featureOn, enabled, eligible } = {}) { return eligible === true ? { action: "publish" } : { action: "unshare" }; } -// On DELETE of a mirrored video: remove the mirror and clear the flag. +// On DELETE of a video: always attempt to tear the mirror down whenever the +// feature is on — do NOT gate on the local `enabled` flag. That flag lives only +// in this browser's localStorage (see top of file), so trusting it here orphans +// the NIP-71 mirror whenever the video was shared on one device/session and +// deleted from another (or after the cache was cleared): the mirror keeps living +// on other apps even though the bitvid video is gone. `remove()` is idempotent +// and cheap (NIP-09 + empty tombstone are no-ops if no mirror exists), so an +// unconditional teardown is safe. `enabled` is accepted for signature symmetry +// with resolveEditSync but is intentionally ignored. export function resolveDeleteSync({ featureOn, enabled } = {}) { - if (featureOn !== true || enabled !== true) { + void enabled; + if (featureOn !== true) { return { action: "none" }; } return { action: "unshare" }; diff --git a/js/services/playbackService.js b/js/services/playbackService.js index d94449ab..6abb2d5d 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -522,6 +522,34 @@ class PlaybackSession extends SimpleEventEmitter { this.trimmedMagnet = typeof options.magnet === "string" ? options.magnet.trim() : ""; + // Ordered, de-duped hosted sources to try in turn (primary URL first, then + // any mirrors from the video's imeta `sources`). Lets playback fail over to + // the next mirror when one is dead/stalls mid-load, before dropping to P2P. + this.hostedSourceCandidates = (() => { + const seen = new Set(); + const list = []; + const add = (candidate) => { + // Accept plain URL strings or imeta `{ url, ... }` source objects. + const raw = + typeof candidate === "string" + ? candidate + : candidate && typeof candidate.url === "string" + ? candidate.url + : ""; + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (!trimmed) return; + const key = trimmed.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + list.push(trimmed); + }; + add(this.sanitizedUrl); + if (Array.isArray(options.sources)) { + options.sources.forEach(add); + } + return list; + })(); + // The signature prevents duplicate work if the same video is requested again // while already loading/playing. this.requestSignature = @@ -897,12 +925,14 @@ class PlaybackSession extends SimpleEventEmitter { }; // --- Attempt URL Logic --- - const attemptHostedPlayback = async () => { - if (!httpsUrl) return null; + // candidateUrl defaults to the primary; the execution flow calls this once + // per hosted source so a dead/stalled mirror fails over to the next one. + const attemptHostedPlayback = async (candidateUrl = httpsUrl) => { + if (!candidateUrl) return null; this.emit("status", { message: "Checking hosted URL..." }); this.service.log( - `[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` + `[playVideoWithFallback] Probing hosted URL ${candidateUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` ); let hostedStatusResolved = false; @@ -959,7 +989,7 @@ class PlaybackSession extends SimpleEventEmitter { ); const probeResult = await this.service.probeHostedUrl({ - url: httpsUrl, + url: candidateUrl, magnet: this.trimmedMagnet, probeUrl, }); @@ -1036,7 +1066,7 @@ class PlaybackSession extends SimpleEventEmitter { // ignore } activeVideoEl.crossOrigin = null; - activeVideoEl.src = httpsUrl; + activeVideoEl.src = candidateUrl; const playPromise = activeVideoEl.play(); if (playPromise && typeof playPromise.catch === "function") { playPromise.catch((err) => { @@ -1145,31 +1175,45 @@ class PlaybackSession extends SimpleEventEmitter { let hostedErrorMessage = ""; if (tryUrlFirst) { - if (httpsUrl) { - // Wrap URL attempt in timeout - const urlResult = await withTimeout( - attemptHostedPlayback(), - effectiveTimeout, - "URL Playback" - ); + if (this.hostedSourceCandidates.length) { + // Try each hosted source in turn; a dead/stalled one fails over to the + // next mirror before we drop to P2P. + let lastUrlResult = null; + for (let i = 0; i < this.hostedSourceCandidates.length; i += 1) { + const candidateUrl = this.hostedSourceCandidates[i]; + const urlResult = await withTimeout( + attemptHostedPlayback(candidateUrl), + effectiveTimeout, + "URL Playback" + ); - if (urlResult && urlResult.source === "url") { - return urlResult; + if (urlResult && urlResult.source === "url") { + return urlResult; + } + + lastUrlResult = urlResult; + if (typeof urlResult?.errorMessage === "string") { + hostedErrorMessage = urlResult.errorMessage; + } + + // Clean up watchdogs/listeners and reset the element before the next + // candidate (the single-attempt path relied on attemptTorrentPlayback + // to do this; the loop must do it between mirrors). + const moreCandidatesRemain = + i < this.hostedSourceCandidates.length - 1; + if (urlResult?.reason === "timeout" || moreCandidatesRemain) { + this.cleanupWatchdog(); + cleanupHostedUrlStatusListeners(); + resetVideoElement(); + } + if (moreCandidatesRemain) { + this.service.log( + `[playVideoWithFallback] Hosted source ${candidateUrl} failed (${urlResult?.reason || "unknown"}); trying next mirror.` + ); + } } - if (typeof urlResult?.errorMessage === "string") { - hostedErrorMessage = urlResult.errorMessage; - } - - // If timeout or failure, proceed to fallback - if (urlResult?.reason === "timeout") { - // Need to ensure URL probing/playback is effectively cancelled - this.cleanupWatchdog(); - cleanupHostedUrlStatusListeners(); - resetVideoElement(); - } - - const fallbackReason = urlResult?.reason || "url-unavailable"; + const fallbackReason = lastUrlResult?.reason || "url-unavailable"; if (this.magnetForPlayback && forcedSource !== "url") { return await attemptTorrentPlayback(fallbackReason); } diff --git a/js/services/playbackStrategyService.js b/js/services/playbackStrategyService.js index cff8074a..748b204d 100644 --- a/js/services/playbackStrategyService.js +++ b/js/services/playbackStrategyService.js @@ -197,6 +197,8 @@ export class PlaybackStrategyService { const session = this.playbackService.createSession({ url: sanitizedUrl, magnet: trimmedMagnet, + // Hosted mirrors to fail over to when the primary URL dies mid-load. + sources: Array.isArray(options.sources) ? options.sources : [], requestSignature, videoElement: modalVideoEl, waitForCleanup: context.waitForCleanup, diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index d24eaa09..f1072211 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -22,21 +22,37 @@ function readOffered() { } } -function markOffered(pubkey) { +// 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". +// Returns { storage?: true, wallet?: true }. Legacy boolean `true` (pre per-item) +// is read as storage-only so existing users still get the wallet offer. +function readOfferedKinds(pubkey) { + const entry = readOffered()[pubkey]; + if (entry === true) { + return { storage: true }; + } + return entry && typeof entry === "object" ? entry : {}; +} + +function markOffered(pubkey, kind) { try { if (typeof localStorage === "undefined") { return; } const map = readOffered(); - map[pubkey] = true; + const current = map[pubkey] === true ? { storage: true } : map[pubkey]; + const kinds = current && typeof current === "object" ? { ...current } : {}; + kinds[kind] = true; + map[pubkey] = kinds; localStorage.setItem(OFFERED_KEY, JSON.stringify(map)); } catch (error) { // Best-effort. } } -function hasOffered(pubkey) { - return readOffered()[pubkey] === true; +function hasOffered(pubkey, kind) { + return readOfferedKinds(pubkey)[kind] === true; } const ITEM_LABELS = { @@ -53,7 +69,9 @@ export function createSettingsRestorePrompt({ : false, logger = null, } = {}) { - // Items not already enabled on THIS device that have a remote copy to restore. + // Items not already enabled / not already offered on THIS device that have a + // remote copy to restore. Each kind is considered INDEPENDENTLY so a wallet note + // published after storage was already offered is still surfaced. async function collectCandidates(pubkey) { const candidates = []; const services = [ @@ -61,7 +79,11 @@ export function createSettingsRestorePrompt({ ["wallet", walletSync], ]; for (const [kind, service] of services) { - if (!service?.isAvailable?.() || service.isEnabled(pubkey)) { + if ( + !service?.isAvailable?.() || + service.isEnabled(pubkey) || + hasOffered(pubkey, kind) + ) { continue; } try { @@ -80,9 +102,6 @@ export function createSettingsRestorePrompt({ if (!key) { return { offered: false, reason: "no-pubkey" }; } - if (hasOffered(key)) { - return { offered: false, reason: "already-offered" }; - } const candidates = await collectCandidates(key); if (!candidates.length) { @@ -91,8 +110,11 @@ export function createSettingsRestorePrompt({ return { offered: false, reason: "no-remote" }; } - // We are about to prompt — mark so we never nag again on this device. - markOffered(key); + // We are about to prompt — mark EACH offered kind so we never nag again for it + // on this device (but other kinds stay eligible for a future login). + for (const kind of candidates) { + markOffered(key, kind); + } const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and "); const accepted = confirm( diff --git a/js/subscriptions.js b/js/subscriptions.js index 15081616..37653fa8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -10,7 +10,7 @@ import { listVideoViewEvents, subscribeVideoViewEvents, } from "./nostrViewEventsFacade.js"; -import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; import { buildSubscriptionListEvent, @@ -730,6 +730,11 @@ class SubscriptionsManager { return null; } + // Bound the live follows-list subscription to the §17 read cap (≤8, user + // relays first + reserved defaults) so it doesn't fan out across the user's + // full NIP-65 set during the cold-login REQ storm. + const cappedRelays = capReadRelays(relays); + const key = `subscriptions:${normalized}`; this.subscriptionKey = key; @@ -751,7 +756,7 @@ class SubscriptionsManager { if (manager) { this.subscriptionHandle = manager.subscribe({ key, - relays, + relays: cappedRelays, filters, label: "subscription-list", onEvent: (event) => this.handleSubscriptionListEvent(event), @@ -762,7 +767,7 @@ class SubscriptionsManager { return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, - relays, + relays: cappedRelays, filters, label: "subscription-list", onEvent: (event) => this.handleSubscriptionListEvent(event), diff --git a/js/trendingView.js b/js/trendingView.js new file mode 100644 index 00000000..4a1014fd --- /dev/null +++ b/js/trendingView.js @@ -0,0 +1,65 @@ +// js/trendingView.js +// +// The "Trending" tab presentation hook. The ranking lives in the feed engine +// (createTrendingSorter), but view counts arrive asynchronously as the grid +// cards subscribe to them. So while this view is active we listen for +// viewCounter's coalesced "counts changed" signal and re-run the Trending feed +// (a cheap re-rank over the in-memory active set, no relay re-fetch) so the order +// visibly settles into true trending as counts stream in. + +import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; +import { onViewCountsChanged } from "./viewCounter.js"; + +const TRENDING_RERANK_DEBOUNCE_MS = 700; + +let unsubscribeCounts = null; +let rerankTimer = null; + +export function teardownTrendingView() { + if (typeof unsubscribeCounts === "function") { + try { + unsubscribeCounts(); + } catch (error) { + // best effort + } + unsubscribeCounts = null; + } + if (rerankTimer) { + clearTimeout(rerankTimer); + rerankTimer = null; + } +} + +export function initTrendingView({ getApp, isActive } = {}) { + const infoTrigger = document.getElementById("trendingInfoTrigger"); + if (infoTrigger) { + attachFeedInfoPopover( + infoTrigger, + "Trending ranks recently-added videos by their view count. The order settles as counts load in." + ); + } + + // Reset any prior subscription (re-entering the tab). + teardownTrendingView(); + + const stillActive = typeof isActive === "function" ? isActive : () => true; + + unsubscribeCounts = onViewCountsChanged(() => { + if (rerankTimer) { + return; + } + rerankTimer = setTimeout(() => { + rerankTimer = null; + if (!stillActive()) { + teardownTrendingView(); + return; + } + const app = typeof getApp === "function" ? getApp() : null; + if (app && typeof app.refreshTrendingFeed === "function") { + Promise.resolve( + app.refreshTrendingFeed({ reason: "view-count-update" }) + ).catch(() => {}); + } + }, TRENDING_RERANK_DEBOUNCE_MS); + }); +} diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 2f04c9f1..3e69eebf 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -24,7 +24,7 @@ import r2Service from "../services/r2Service.js"; import s3UploadService from "../services/s3UploadService.js"; import RelayHealthService from "../services/relayHealthService.js"; import { createFeedEngine } from "../feedEngine/index.js"; -import { URL_FIRST_ENABLED, SHORT_TIMEOUT_MS } from "../constants.js"; +import { URL_FIRST_ENABLED, SHORT_TIMEOUT_MS, FEATURE_TRENDING_FEED } from "../constants.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; import { relayManager } from "../relayManager.js"; import { userBlocks } from "../userBlocks.js"; @@ -275,6 +275,15 @@ export default class ApplicationBootstrap { app.registerForYouFeed(); app.registerKidsFeed(); app.registerExploreFeed(); + if (FEATURE_TRENDING_FEED && typeof app.registerTrendingFeed === "function") { + app.registerTrendingFeed(); + } else if (typeof document !== "undefined") { + // Flag off: hide the always-visible Trending sidebar link. + const trendingLink = document.getElementById("trendingLink"); + if (trendingLink) { + trendingLink.classList.add("hidden"); + } + } app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); // Re-run the For You feed when background watch-history resolution completes. @@ -666,6 +675,10 @@ export default class ApplicationBootstrap { designSystem: app.designSystemContext, callbacks: { getCurrentVideo: () => app.currentVideo, + getVideoByEventId: (eventId) => + eventId && app.videosMap instanceof Map + ? app.videosMap.get(eventId) || null + : null, getCurrentUserNpub: () => app.getCurrentUserNpub(), getCurrentUserPubkey: () => app.pubkey, canCurrentUserManageBlacklist: () => @@ -685,10 +698,26 @@ export default class ApplicationBootstrap { handleEnsurePresenceAction: (payload) => app.handleEnsurePresenceAction(payload), handleEventDetailsAction: (payload) => { - if (app.modalManager && app.modalManager.eventDetailsModal && payload?.video) { - app.modalManager.eventDetailsModal.open(payload.video); + const modal = + app.modalManager?.eventDetailsModal || app.eventDetailsModal || null; + if (!modal || typeof modal.open !== "function") { + app.showError?.("Event details are unavailable."); + return Promise.resolve(); } - return Promise.resolve(); + // Card popovers sometimes don't carry the video object on context, so + // resolve it from the menu item's eventId as a fallback (the object the + // grid already holds). This is what made the button silently no-op. + let video = payload?.video || null; + if (!video && payload?.eventId && app.videosMap instanceof Map) { + video = app.videosMap.get(payload.eventId) || null; + } + if (!video) { + app.showError?.("No video selected for event details."); + return Promise.resolve(); + } + return Promise.resolve(modal.open(video)).catch((error) => { + devLogger.warn("[eventDetails] Failed to open event details:", error); + }); }, loadVideos: () => app.loadVideos(), refreshAllVideoGrids: (options) => app.refreshAllVideoGrids(options), diff --git a/js/ui/components/EventDetailsModal.js b/js/ui/components/EventDetailsModal.js index 56e10dc4..b778b1c3 100644 --- a/js/ui/components/EventDetailsModal.js +++ b/js/ui/components/EventDetailsModal.js @@ -409,10 +409,15 @@ export class EventDetailsModal { this.currentIndex = 0; this.isLoadingHistory = true; - this.renderVersion(video); - this.updateNavigationState(); - + // Show the modal FIRST so a render hiccup can't leave the click looking dead; + // then populate it (guarded). openStaticModal(modal, { document: this.document }); + try { + this.renderVersion(video); + this.updateNavigationState(); + } catch (error) { + devLogger.error("[EventDetailsModal] Failed to render version:", error); + } // Fetch history try { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 825f9c9e..a612b40e 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -2994,7 +2994,10 @@ export class VideoModal { } handleCopyRequest() { - this.dispatch("action:copy", { video: this.activeVideo }); + // ModalManager listens for "video:copy-magnet" (it runs app.handleCopyMagnet). + // The previous "action:copy" name had no listener, so the copy-magnet button + // did nothing — same dead-event bug as the embed button. + this.dispatch("video:copy-magnet", { video: this.activeVideo }); } handleShareRequest(event) { @@ -3006,7 +3009,10 @@ export class VideoModal { } handleEmbedRequest() { - this.dispatch("action:embed", { video: this.activeVideo }); + // ModalManager listens for "video:embed" (it opens EmbedVideoModal). The + // previous "action:embed" name had no listener, so the embed button did + // nothing — the event was dispatched into the void. + this.dispatch("video:embed", { video: this.activeVideo }); } handleCreatorNavigation() { diff --git a/js/ui/components/video-modal/zapController.js b/js/ui/components/video-modal/zapController.js index ea46ec70..abd5a712 100644 --- a/js/ui/components/video-modal/zapController.js +++ b/js/ui/components/video-modal/zapController.js @@ -116,8 +116,15 @@ export class ZapController { const popoverIsOpen = this.isZapDialogOpen(); if (this.modalZapOpenPromise) { - this.modalZapPendingToggle = "close"; - this.closeZapDialog({ silent: true, restoreFocus: false }); + // An open is in flight. Only honor a click as "close" if the dialog is + // already visibly open; otherwise IGNORE the extra click. Canceling an + // in-flight open made rapid double-clicks net to "closed", so the popover + // needed many clicks to actually appear (the engine sets isOpen only + // after positioning, so a mid-open click reads as not-yet-open here). + if (popoverIsOpen) { + this.modalZapPendingToggle = "close"; + this.closeZapDialog({ silent: true, restoreFocus: false }); + } return; } diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index e9f26aeb..e71651bf 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -187,6 +187,12 @@ export function createVideoMoreMenuPanel({ dataset: { eventId: metadata.id, context }, }); + appendMenuAction(doc, list, { + text: "Popularity", + action: "view-stats", + dataset: { eventId: metadata.id, context }, + }); + const baseBoostDataset = { eventId: metadata.id, author: metadata.pubkey, diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 823afc77..da6bd78d 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -45,6 +45,7 @@ export default class MoreMenuController { this.callbacks = { getCurrentVideo: callbacks.getCurrentVideo || (() => null), + getVideoByEventId: callbacks.getVideoByEventId || (() => null), getCurrentUserNpub: callbacks.getCurrentUserNpub || (() => null), getCurrentUserPubkey: callbacks.getCurrentUserPubkey || (() => null), canCurrentUserManageBlacklist: @@ -157,7 +158,7 @@ export default class MoreMenuController { ...(detail.dataset || {}), eventId: detail.dataset?.eventId || detail.video?.id || "", }; - this.handleMoreMenuAction(detail.action, dataset); + this.handleMoreMenuAction(detail.action, dataset, detail.video || null); }; if (typeof view.addEventListener === "function") { @@ -1018,7 +1019,13 @@ export default class MoreMenuController { event.preventDefault(); event.stopPropagation(); - const context = button.getAttribute("data-context") || "card"; + // The modal ⋯ trigger declares its context via `data-more-dropdown` + // ("modal"), not `data-context` — reading only the latter made it + // default to "card" and skip the modal video resolution below. + const context = + button.getAttribute("data-context") || + button.getAttribute("data-more-dropdown") || + "card"; let video = null; let pointerInfo = null; @@ -1027,6 +1034,11 @@ export default class MoreMenuController { if (context === "modal") { video = this.callbacks.getCurrentVideo(); + // currentVideo can be null when the modal opened without playback + // starting (e.g. a deep link); fall back to the modal's activeVideo. + if (!video && this.videoModal?.activeVideo) { + video = this.videoModal.activeVideo; + } if (Array.isArray(video?.pointer) && video.pointer.length >= 2) { pointerInfo = { pointer: video.pointer }; } @@ -1162,6 +1174,37 @@ export default class MoreMenuController { .catch(() => this.callbacks.showError("Failed to copy the link.")); break; } + case "view-stats": { + // Prefer the card's own video; fall back to resolving it from the menu + // item's eventId (card popovers don't always carry the object), and only + // use the modal's current video for the modal context. + 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; + } + // Dismiss the menu, then open the public popularity chart (lazy-loaded). + try { + this.activePopover?.close?.(); + } catch (error) { + // ignore + } + try { + const { openPopularityModal } = await import("../viewCountChart.js"); + openPopularityModal({ video: targetVideo }); + } catch (error) { + userLogger.warn("[MoreMenu] Failed to open popularity modal:", error); + this.callbacks.showError("Couldn’t open popularity."); + } + break; + } case "remove-history": { await this.callbacks.handleRemoveHistoryAction(dataset); break; @@ -1582,11 +1625,18 @@ export default class MoreMenuController { break; } case "event-details": { + // Card context passes the card's video; modal context falls back to the + // active video (the dispatch path doesn't carry the video object). let targetVideo = video; if (!targetVideo && context === "modal") { targetVideo = currentVideo; } - + // Dismiss the menu before opening the details modal. + try { + this.activePopover?.close?.(); + } catch (error) { + // ignore + } await this.callbacks.handleEventDetailsAction({ eventId: dataset.eventId, video: targetVideo, diff --git a/js/ui/overlay/popoverEngine.js b/js/ui/overlay/popoverEngine.js index 00285ebd..431e58c0 100644 --- a/js/ui/overlay/popoverEngine.js +++ b/js/ui/overlay/popoverEngine.js @@ -5,6 +5,11 @@ import { readDesignToken, } from "../../designSystem/metrics.js"; import { ensureOverlayRoot } from "./overlayRoot.js"; +import { + isEditableTarget, + movePanelIntoPortal, + restorePanelToOrigin, +} from "./popoverPanelDom.js"; import { arrow as arrowMiddleware, autoUpdate, @@ -245,6 +250,9 @@ export function createPopover(trigger, render, options = {}) { let autoUpdateCleanup = null; let isOpen = false; let previousActiveElement = null; + // Origin of a pre-existing app-owned panel relocated into the portal on open + // (see movePanelIntoPortal); used to put it back on close/destroy. + let panelOrigin = null; let menuItemIdCounter = 0; const menuHandlers = { keydown: null, @@ -582,6 +590,13 @@ export function createPopover(trigger, render, options = {}) { return; } + // Never hijack keys while the user types in a form field inside the panel + // (zap comment/amount, search boxes, etc.) — let characters and caret keys + // through instead of routing them to menu typeahead/navigation. + if (isEditableTarget(event.target)) { + return; + } + menuState.items = findMenuItems(menuState.panel); switch (event.key) { @@ -915,6 +930,13 @@ export function createPopover(trigger, render, options = {}) { overlayRoot.appendChild(portal); } + // Pull a pre-existing in-host panel out to the portal so fixed positioning is + // viewport-relative (fixes the zap/embed dialog anchoring to the modal edge). + const movedOrigin = movePanelIntoPortal(panel, portal); + if (movedOrigin) { + panelOrigin = movedOrigin; + } + if (activePopoverInstance && activePopoverInstance !== api) { activePopoverInstance.close({ restoreFocus: false }); } @@ -992,6 +1014,11 @@ export function createPopover(trigger, render, options = {}) { setExpandedAttribute(anchor, false); resetTypeaheadBuffer(); + // Return a relocated app-owned panel to its original DOM home (no-op for + // fresh portal-owned panels) so the host DOM stays intact and re-open works. + restorePanelToOrigin(panel, panelOrigin); + panelOrigin = null; + if (restoreFocus && restoreFocusOnClose) { restoreTriggerFocus(); } diff --git a/js/ui/overlay/popoverPanelDom.js b/js/ui/overlay/popoverPanelDom.js new file mode 100644 index 00000000..f73119e0 --- /dev/null +++ b/js/ui/overlay/popoverPanelDom.js @@ -0,0 +1,64 @@ +// Pure DOM helpers for the popover engine, extracted to keep popoverEngine.js +// under its size budget. + +function isElement(node) { + return Boolean(node) && typeof node === "object" && node.nodeType === 1; +} + +// True when the event target is a form control / editable element. The engine +// treats panels as ARIA menus (arrow-key nav + single-char typeahead, both +// preventDefault'd); that must NOT hijack keystrokes when the user is typing in a +// panel that contains inputs (e.g. the zap comment/amount fields) — otherwise +// letters and caret keys never reach the field and "you can't type" in the popup. +export function isEditableTarget(target) { + if (!isElement(target)) { + return false; + } + const tag = typeof target.tagName === "string" ? target.tagName.toLowerCase() : ""; + if (tag === "input" || tag === "textarea" || tag === "select") { + return true; + } + if (target.isContentEditable === true) { + return true; + } + const role = + typeof target.getAttribute === "function" ? target.getAttribute("role") : ""; + return role === "textbox" || role === "searchbox" || role === "combobox"; +} + +// Move a panel into the portal (body-level overlay root) for the duration of the +// open state, so `position: fixed` is viewport-relative rather than resolving +// against a transformed/contained in-modal ancestor. Fresh render-created panels +// are already in the portal (no-op); this only relocates pre-existing, app-owned +// panels. Returns the origin { parent, nextSibling } to restore later, or null. +export function movePanelIntoPortal(panel, portal) { + if (!panel || !portal || panel.parentNode === portal) { + return null; + } + const origin = panel.parentNode + ? { parent: panel.parentNode, nextSibling: panel.nextSibling } + : null; + portal.appendChild(panel); + return origin; +} + +// Put a relocated app-owned panel back exactly where it came from so the host +// (e.g. the video modal) keeps an intact DOM and the popover can be re-opened. +export function restorePanelToOrigin(panel, origin) { + if (!panel || !origin) { + return; + } + const { parent, nextSibling } = origin; + if (!parent || typeof parent.insertBefore !== "function") { + return; + } + try { + if (nextSibling && nextSibling.parentNode === parent) { + parent.insertBefore(panel, nextSibling); + } else { + parent.appendChild(panel); + } + } catch (error) { + // Best-effort: a failed restore must not break close(). + } +} diff --git a/js/ui/profileModal/ProfileDirectMessageController.js b/js/ui/profileModal/ProfileDirectMessageController.js index 41180786..592a0028 100644 --- a/js/ui/profileModal/ProfileDirectMessageController.js +++ b/js/ui/profileModal/ProfileDirectMessageController.js @@ -242,6 +242,26 @@ export class ProfileDirectMessageController { this.updateMessageThreadSelection(nextRecipient); if (nextRecipient) { + // Make this recipient's conversation the active one so a freshly-selected + // recipient (e.g. the "Message" button for someone with no existing + // thread) isn't overridden by a stale activeDmConversationId pointing at + // the top of the inbox. setFocusedDmConversation only tracks read-state — + // activeDmConversationId is what buildDmConversationData reads to decide + // which thread the right-hand pane renders, so set it directly (mirroring + // handleDmConversationSelect). + const actor = this.helper.resolveActiveDmActor(); + const conversationId = actor + ? this.helper.buildDmConversationId(actor, nextRecipient) + : ""; + if (conversationId) { + this.activeDmConversationId = conversationId; + // Selecting a recipient explicitly (not the auto thread-default) means + // the user wants to see that thread — switch the mobile pane to it too. + if (reason !== "thread-default") { + this.dmMobileView = "thread"; + } + this.setFocusedDmConversation(conversationId); + } void this.helper.ensureDmRecipientData(nextRecipient); this.renderer.setMessagesAnnouncement("Ready to message this recipient."); } else if (reason === "clear") { @@ -623,6 +643,33 @@ export class ProfileDirectMessageController { return this.actions.handleSendProfileMessage(...args); } + // DM app-shell callbacks. The renderer wires these on `this.controller`, but the + // delegations were missing — so Send / mark-read / settings / toggles all threw + // "is not a function" and the app-shell DM UI couldn't send a message. + handleDmAppShellSendMessage(...args) { + return this.actions.handleDmAppShellSendMessage(...args); + } + + handleDmConversationMarkRead(...args) { + return this.actions.handleDmConversationMarkRead(...args); + } + + handleDmMarkAllConversationsRead(...args) { + return this.actions.handleDmMarkAllConversationsRead(...args); + } + + handleReadReceiptsToggle(...args) { + return this.actions.handleReadReceiptsToggle(...args); + } + + handleTypingIndicatorsToggle(...args) { + return this.actions.handleTypingIndicatorsToggle(...args); + } + + openDmSettingsModal(...args) { + return this.actions.openDmSettingsModal(...args); + } + handleAttachmentSelection(...args) { return this.actions.handleAttachmentSelection(...args); } @@ -653,6 +700,14 @@ export class ProfileDirectMessageController { return this.helper.resolveActiveDmRecipient(...args); } + // ProfileModerationController calls dmController.resolveProfileSummaryForPubkey + // (and createCompactProfileSummary, already delegated below). The method lives on + // the helper, so without this delegation the moderation-settings UI threw + // "is not a function" on open. + resolveProfileSummaryForPubkey(...args) { + return this.helper.resolveProfileSummaryForPubkey(...args); + } + createCompactProfileSummary(...args) { return this.renderer.createCompactProfileSummary(...args); } diff --git a/js/ui/profileModal/ProfileDirectMessageHelper.js b/js/ui/profileModal/ProfileDirectMessageHelper.js index bbd71102..874b3f54 100644 --- a/js/ui/profileModal/ProfileDirectMessageHelper.js +++ b/js/ui/profileModal/ProfileDirectMessageHelper.js @@ -635,13 +635,49 @@ export class ProfileDirectMessageHelper { storedRecipient && actor ? this.buildDmConversationId(actor, storedRecipient) : ""; + + // If a recipient is explicitly selected but has no existing thread yet + // (e.g. the "Message" button on the profile of someone you've never DMed), + // synthesize an empty conversation so the UI focuses THEM. Without this the + // selected recipient falls through to fallbackConversationId and the inbox + // silently jumps to the top conversation — and any message you send goes to + // the wrong person. + if ( + storedRecipient && + storedConversationId && + !conversationMap.has(storedConversationId) && + !isRemoteBlocked(storedRecipient) + ) { + const summary = this.resolveProfileSummaryForPubkey(storedRecipient); + const recipientContext = this.buildDmRecipientContext(storedRecipient); + conversations.unshift({ + id: storedConversationId, + name: summary.displayName, + preview: "No messages yet", + timestamp: "", + unreadCount: 0, + avatarSrc: summary.avatarSrc, + status: summary.status, + pubkey: storedRecipient, + lightningAddress: summary.lightningAddress, + relayHints: Array.isArray(recipientContext?.relayHints) + ? recipientContext.relayHints + : [], + }); + } + const preferredConversationId = this.controller.activeDmConversationId || storedConversationId; const fallbackConversationId = conversations[0]?.id || ""; - const activeConversationId = - preferredConversationId && conversationMap.has(preferredConversationId) - ? preferredConversationId - : fallbackConversationId; + // A selected recipient with no messages yet won't be in conversationMap, but + // it IS a valid focus target (storedConversationId / the synthetic entry). + const preferredIsSelectable = + preferredConversationId && + (conversationMap.has(preferredConversationId) || + preferredConversationId === storedConversationId); + const activeConversationId = preferredIsSelectable + ? preferredConversationId + : fallbackConversationId; const activeThread = activeConversationId ? conversationMap.get(activeConversationId) : null; @@ -649,7 +685,11 @@ export class ProfileDirectMessageHelper { activeThread?.remoteHex || (activeConversationId ? this.resolveRemoteForConversationId(activeConversationId, actor) - : storedRecipient) || + : null) || + // A freshly-selected recipient has no messages, so it can't be resolved + // from the cache above — fall back to the stored recipient for the + // synthetic conversation so the composer targets the right person. + (activeConversationId === storedConversationId ? storedRecipient : null) || null; if (activeConversationId && this.controller.activeDmConversationId !== activeConversationId) { diff --git a/js/ui/profileModal/ProfileDirectMessageRenderer.js b/js/ui/profileModal/ProfileDirectMessageRenderer.js index 7595b1af..fe11cfd2 100644 --- a/js/ui/profileModal/ProfileDirectMessageRenderer.js +++ b/js/ui/profileModal/ProfileDirectMessageRenderer.js @@ -566,11 +566,14 @@ export class ProfileDirectMessageRenderer { } const activeRecipient = this.controller.helper.resolveActiveDmRecipient(); - const hasActiveRecipient = - activeRecipient && - threadsToRender.some((thread) => thread.remoteHex === activeRecipient); - if (threadsToRender.length && !hasActiveRecipient) { + if (activeRecipient) { + // Respect an explicitly-selected recipient even if it has no thread yet + // (e.g. the "Message" button for someone you've never DMed). Defaulting + // to threadsToRender[0] here is what made the inbox jump to the top + // conversation instead of the person you chose. + this.updateMessageThreadSelection(activeRecipient); + } else if (threadsToRender.length) { this.controller.setDirectMessageRecipient(threadsToRender[0].remoteHex, { reason: "thread-default", }); @@ -579,8 +582,6 @@ export class ProfileDirectMessageRenderer { threadsToRender[0].remoteHex, ); this.controller.setFocusedDmConversation(conversationId); - } else if (hasActiveRecipient) { - this.updateMessageThreadSelection(activeRecipient); } this.profileMessagesList.classList.remove("hidden"); diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index 752e36ca..317fdd31 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -1,6 +1,7 @@ import { devLogger } from "../../utils/logger.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js"; import { createWalletSyncService } from "../../services/walletSyncService.js"; +import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js"; const noop = () => {}; const SECRET_PLACEHOLDER = "*****"; @@ -632,6 +633,11 @@ export class ProfileWalletController { finalVariant = "success"; this.mainController.showSuccess("Wallet settings saved."); context.reason = "saved"; + // Proactively check whether THIS creator's own Lightning address is + // reachable from a browser. If their LNURL host is offline or blocks + // browser requests (no CORS), nobody can zap them on bitvid — warn now. + // Non-blocking: the network probe must not delay the save. + void this.warnIfLightningAddressUnzappable(normalizedActive); // Keep the encrypted synced copy current if the user opted in. Warn // before overwriting a newer copy changed on another device. const sync = this.getWalletSyncService(); @@ -691,6 +697,12 @@ export class ProfileWalletController { return context; } + // Warn the creator if their OWN Lightning address can't be reached from a browser + // (delegated; runs after a successful wallet save). See walletZappabilityCheck.js. + async warnIfLightningAddressUnzappable(pubkey) { + return runWalletZappabilityCheck(this, pubkey); + } + async handleWalletTest() { const { uri, defaultZap, error } = this.getWalletFormValues(); const context = { diff --git a/js/ui/profileModal/walletZappabilityCheck.js b/js/ui/profileModal/walletZappabilityCheck.js new file mode 100644 index 00000000..b17f7636 --- /dev/null +++ b/js/ui/profileModal/walletZappabilityCheck.js @@ -0,0 +1,63 @@ +import { checkLightningAddressZappable } from "../../payments/lnurl.js"; +import { devLogger } from "../../utils/logger.js"; + +// Warn the creator if their OWN receiving Lightning address can't be reached from +// a browser (so people can't zap them on bitvid). Best-effort, non-blocking; runs +// after a successful wallet save. The probe is the exact fetch a zapper's browser +// would make, so a failure here means a failure for everyone. `controller` is the +// ProfileWalletController (provides mainController + updateWalletStatus). +export async function runWalletZappabilityCheck(controller, pubkey) { + try { + const main = controller?.mainController; + const entry = + typeof main?.getProfileCacheEntry === "function" + ? main.getProfileCacheEntry(pubkey) + : null; + const profile = entry?.profile || null; + // No cached profile -> can't determine; don't guess (avoid a false warning). + if (!profile) { + return; + } + + const address = + (typeof profile.lud16 === "string" && profile.lud16.trim()) || + (typeof profile.lud06 === "string" && profile.lud06.trim()) || + ""; + + if (!address) { + controller.updateWalletStatus( + "Wallet saved. Tip: add a Lightning address to your Nostr profile so people can zap you on bitvid.", + "info", + ); + return; + } + + const result = await checkLightningAddressZappable(address); + if (!result.ok) { + const message = + `Heads up: your Lightning address (${address}) can't be reached from a browser ` + + "— its host may be offline or blocking browser requests (no CORS). People may " + + "not be able to zap you on bitvid. A host with proper CORS support (e.g. Alby, " + + "Coinos, Wallet of Satoshi) fixes this."; + controller.updateWalletStatus(message, "error"); + main.showError(message); + return; + } + + if (result.reason === "no-nostr") { + controller.updateWalletStatus( + `Your Lightning address (${address}) is reachable, but its host doesn't advertise ` + + "Nostr zaps — payments may work without a zap receipt.", + "info", + ); + return; + } + + controller.updateWalletStatus( + `Wallet saved. Your Lightning address (${address}) is reachable — people can zap you on bitvid.`, + "success", + ); + } catch (error) { + devLogger.warn("[ProfileModal] Lightning zappability check failed:", error); + } +} diff --git a/js/ui/zapController.js b/js/ui/zapController.js index c4985272..34425ae8 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -213,7 +213,9 @@ export default class ZapController { this.modalZapInFlight = true; this.videoModal?.setZapPending(true); - this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "warning"); + // In-flight/pending status is informational, not a warning — "warning" colored + // it alarmingly (text-warning-strong) and counted as a warning in the flow. + this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "neutral"); this.videoModal?.clearZapReceipts(); try { @@ -351,54 +353,37 @@ export default class ZapController { return validationStatus !== "passed"; }); - if (unvalidatedReceipts.length) { - this.videoModal?.renderZapReceipts(receipts, { partial: true }); - - const warningSummary = unvalidatedReceipts - .map((receipt) => { - const type = receipt.recipientType || receipt.type || "creator"; - const label = - type === "platform" ? "Platform" : type === "creator" ? "Creator" : "Lightning"; - const address = - typeof receipt.address === "string" && receipt.address - ? ` (${receipt.address})` - : ""; - const reason = - typeof receipt.validation?.reason === "string" && receipt.validation.reason - ? ` — ${receipt.validation.reason}` - : " — Awaiting compliant receipt."; - return `${label}${address}${reason}`; - }) - .join(" "); - - const warningMessage = warningSummary - ? `Awaiting validated zap receipt. ${warningSummary}` - : "Awaiting validated zap receipt from the recipient."; - - this.videoModal?.setZapStatus(warningMessage, "warning"); - this.notifyError(warningMessage); - - this.resetRetryState(); - this.modalZapCommentValue = ""; - this.videoModal?.resetZapForm({ amount: "", comment: "" }); - this.videoModal?.setZapCompleted(true); - this.applyDefaultAmount(); - return; - } - - this.videoModal?.renderZapReceipts(receipts, { partial: false }); + // NOTE: `unvalidatedReceipts` are shares whose PAYMENT SUCCEEDED but whose + // on-relay zap receipt (kind 9735) couldn't be validated. This is NOT a + // failure: the NWC payment already returned a preimage (proof the invoice + // was paid), and the 9735 is supplementary — the recipient's service may + // publish it to relays we don't watch, or late, or not at all. Treating it + // as an error wrongly told users a successful zap had failed. So we report + // SUCCESS and only append a soft, non-alarming note that the receipt + // couldn't be confirmed on relays. + this.videoModal?.renderZapReceipts(receipts, { + partial: unvalidatedReceipts.length > 0, + }); const creatorShare = context.shares.creatorShare; const platformShare = context.shares.platformShare; - const summary = platformShare + let summary = platformShare ? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).` : `Sent ${context.shares.total} sats to the creator.`; + if (unvalidatedReceipts.length) { + summary += + " Payment went through; couldn't confirm the zap receipt on relays."; + } this.videoModal?.setZapStatus(summary, "success"); this.notifySuccess("Zap sent successfully!"); this.resetRetryState(); this.modalZapCommentValue = ""; - this.videoModal?.setZapComment(""); + // Reset the whole form (amount + comment) after a successful send, matching + // the validation-warning terminal path above. Previously this only cleared + // the comment via setZapComment, leaving the form in a half-reset state + // inconsistent with the other post-send paths and the modal's reset API. + this.videoModal?.resetZapForm({ amount: "", comment: "" }); this.videoModal?.setZapCompleted(true); this.applyDefaultAmount(); } catch (error) { @@ -486,9 +471,28 @@ export default class ZapController { const lightningAddress = this.getCurrentVideo()?.lightningAddress || ""; if (lightningAddress) { try { - await fetchLightningMetadata(lightningAddress); + const entry = await fetchLightningMetadata(lightningAddress); + // Reachable from the browser. If the host doesn't advertise Nostr zaps, + // a payment may still go through but won't yield a zap receipt — note it. + if (entry?.metadata && entry.metadata.allowsNostr === false) { + this.videoModal?.setZapStatus( + "This creator's Lightning host doesn't advertise Nostr zaps — a payment may not produce a zap receipt.", + "warning", + ); + } } catch (error) { devLogger.warn("[zap] Failed to preload creator metadata:", error); + // Passive heads-up to the zapper: if WE can't reach the creator's LNURL + // host from the browser, the actual zap will fail the same way. Surface it + // up front so they aren't surprised mid-send. (sendZap overwrites this with + // its own status once they try.) + if (error?.code === "lnurl-unreachable") { + this.videoModal?.setZapStatus( + "This creator's Lightning address can't be reached from a browser (its host may be " + + "offline or blocking browser requests), so zapping them on bitvid may fail.", + "warning", + ); + } } } diff --git a/js/userBlocks.js b/js/userBlocks.js index 9d359813..6becbb2a 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -27,7 +27,7 @@ import { assertAnyRelayAccepted, } from "./nostrPublish.js"; import { profileCache } from "./state/profileCache.js"; -import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, @@ -741,6 +741,12 @@ class UserBlockListManager { return null; } + // Bound the live block-list subscription to the §17 read cap (user relays + // first + reserved defaults, ≤8). Without this the subscription fanned out + // across the user's full NIP-65 set, a primary contributor to the cold-login + // REQ storm. + const cappedRelays = capReadRelays(relays); + const key = `block-list:${normalized}`; this.blockListSubscriptionKey = key; @@ -758,7 +764,7 @@ class UserBlockListManager { if (manager) { this.blockListSubscriptionHandle = manager.subscribe({ key, - relays, + relays: cappedRelays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), @@ -769,7 +775,7 @@ class UserBlockListManager { return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, - relays, + relays: cappedRelays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), diff --git a/js/viewCountChart.js b/js/viewCountChart.js new file mode 100644 index 00000000..c7c337d3 --- /dev/null +++ b/js/viewCountChart.js @@ -0,0 +1,368 @@ +// js/viewCountChart.js +// +// Public "Popularity" view: a views-over-time chart for a video, opened from the +// ⋯ menu. View data is public (kind-30079 events), so any viewer can see it. +// The chart buckets view events by the same dedupe window the counter uses (one +// view per viewer per day), and updates live as more view events stream in. + +import { + listVideoViewEventsWithDefaultClient, + subscribeVideoViewEventsWithDefaultClient, +} from "./nostrViewEventsFacade.js"; +import { resolveVideoPointer } from "./utils/videoPointer.js"; +import { + formatViewCount, + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, +} from "./viewCounter.js"; +import { VIEW_COUNT_BACKFILL_MAX_DAYS } from "./config.js"; +import { devLogger } from "./utils/logger.js"; + +export const VIEW_CHART_WINDOW_SECONDS = 86400; // 1 day, matches the counter dedupe window +const SVG_NS = "http://www.w3.org/2000/svg"; + +// ---- Pure data --------------------------------------------------------------- + +// Bucket raw view events into a cumulative time series. Dedupes by +// (viewer pubkey, day bucket) so it reflects the same "one view per viewer per +// window" semantics the counter displays. Returns ordered buckets + the total. +export function buildViewCountTimeSeries( + events, + { windowSeconds = VIEW_CHART_WINDOW_SECONDS } = {}, +) { + const w = Math.max(1, Number(windowSeconds) || VIEW_CHART_WINDOW_SECONDS); + const seen = new Set(); + const perBucket = new Map(); + for (const event of Array.isArray(events) ? events : []) { + const createdAt = Number(event?.created_at); + const pubkey = typeof event?.pubkey === "string" ? event.pubkey : ""; + if (!Number.isFinite(createdAt) || createdAt <= 0 || !pubkey) { + continue; + } + const bucket = Math.floor(createdAt / w); + const key = `${pubkey}:${bucket}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + perBucket.set(bucket, (perBucket.get(bucket) || 0) + 1); + } + const buckets = Array.from(perBucket.keys()).sort((a, b) => a - b); + const series = []; + let cumulative = 0; + for (const bucket of buckets) { + const count = perBucket.get(bucket) || 0; + cumulative += count; + series.push({ bucketStart: bucket * w, count, cumulative }); + } + return { series, total: cumulative }; +} + +function formatDay(seconds) { + try { + return new Date(seconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + } catch (error) { + return ""; + } +} + +// ---- SVG chart --------------------------------------------------------------- + +// Cumulative views-over-time as a token-colored SVG area+line chart. Uses +// currentColor (set via a text-* utility on the container) so it honors theme +// tokens — no raw colors. +export function buildViewCountChartSvg(doc, series, { width = 320, height = 120 } = {}) { + const pad = { top: 8, right: 8, bottom: 18, 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"); + svg.classList.add("text-accent"); + + const points = Array.isArray(series) ? series : []; + if (points.length === 0) { + svg.setAttribute("aria-label", "No views yet"); + return svg; + } + + const innerW = Math.max(1, width - pad.left - pad.right); + const innerH = Math.max(1, height - pad.top - pad.bottom); + const maxY = points[points.length - 1].cumulative || 1; + const minX = points[0].bucketStart; + const maxX = points[points.length - 1].bucketStart; + const spanX = maxX - minX || 1; + + const x = (bucketStart) => pad.left + ((bucketStart - minX) / spanX) * innerW; + const y = (cumulative) => pad.top + (1 - cumulative / maxY) * innerH; + + // A single bucket can't draw a line — draw a flat baseline to its value. + const coords = + points.length === 1 + ? [ + [pad.left, y(points[0].cumulative)], + [pad.left + innerW, 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 baseY = pad.top + innerH; + const areaPoints = + `${coords[0][0].toFixed(1)},${baseY.toFixed(1)} ` + + linePoints + + ` ${coords[coords.length - 1][0].toFixed(1)},${baseY.toFixed(1)}`; + + 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"); + svg.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"); + svg.appendChild(line); + + svg.setAttribute( + "aria-label", + `${maxY} total views from ${formatDay(minX)} to ${formatDay(maxX)}`, + ); + return svg; +} + +// ---- Modal ------------------------------------------------------------------- + +function pointerForVideo(video) { + if (!video || typeof video !== "object") { + return null; + } + const tags = Array.isArray(video.tags) ? video.tags : []; + const dTag = tags.find((t) => Array.isArray(t) && t[0] === "d"); + const dValue = dTag && typeof dTag[1] === "string" ? dTag[1] : ""; + return resolveVideoPointer({ + kind: video.kind, + pubkey: video.pubkey, + videoRootId: video.videoRootId, + dTag: dValue, + fallbackEventId: video.id, + relay: video.relay, + }); +} + +function el(doc, tag, className, text) { + const node = doc.createElement(tag); + if (className) node.className = className; + if (typeof text === "string") node.textContent = text; + return node; +} + +// At most one popularity modal open at a time. +let activePopularityModal = null; + +// Opens the popularity modal for a video. Dependencies are injectable for tests. +export function openPopularityModal({ + document: doc = typeof document !== "undefined" ? document : null, + video, + listViewEvents = listVideoViewEventsWithDefaultClient, + subscribeViewEvents = subscribeVideoViewEventsWithDefaultClient, + mount = null, +} = {}) { + if (!doc) return null; + const pointerInfo = pointerForVideo(video); + if (!pointerInfo?.pointer) { + return null; + } + const pointer = pointerInfo.pointer; + + // Close any already-open popularity modal (proper cleanup of its subscription + // + listeners) so re-triggering — or two menu paths firing for one click — + // can't stack duplicates. + if (activePopularityModal) { + try { + activePopularityModal.close(); + } catch (error) { + // ignore + } + activePopularityModal = null; + } + + const root = mount || doc.getElementById("uiOverlay") || doc.body; + const backdrop = el(doc, "div", "ds-overlay-backdrop popularity-modal__backdrop"); + const dialog = el(doc, "div", "popularity-modal card p-md"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-label", "Video popularity"); + + const header = el(doc, "div", "flex items-center justify-between gap-3 mb-2"); + header.appendChild(el(doc, "h2", "text-lg font-semibold text-text", "Popularity")); + const closeBtn = el(doc, "button", "icon-button", ""); + closeBtn.type = "button"; + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.textContent = "✕"; + header.appendChild(closeBtn); + dialog.appendChild(header); + + const totalLine = el(doc, "p", "text-sm text-muted mb-3", "Loading views…"); + dialog.appendChild(totalLine); + + const chartHost = el(doc, "div", "popularity-modal__chart"); + dialog.appendChild(chartHost); + + const note = el( + doc, + "p", + "text-2xs text-muted mt-2", + "Public view data · updates as more views load.", + ); + dialog.appendChild(note); + + backdrop.appendChild(dialog); + root.appendChild(backdrop); + + // ---- data + render ---- + const eventsById = new Map(); + let closed = false; + let unsubscribe = null; + let countToken = null; + let counterTotal = null; + let rerenderTimer = null; + + const addEvent = (event) => { + const id = typeof event?.id === "string" ? event.id : ""; + if (!id || eventsById.has(id)) return false; + eventsById.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 + // window). The chart below shows only the time DISTRIBUTION of recorded views. + const renderHeadline = () => { + if (closed) return; + const total = Number.isFinite(counterTotal) + ? counterTotal + : buildViewCountTimeSeries([...eventsById.values()]).total; + if (!Number.isFinite(total)) { + return; + } + totalLine.textContent = + total > 0 + ? `${formatViewCount(total)} ${total === 1 ? "view" : "views"}` + : "No views recorded yet."; + }; + + const renderChart = () => { + if (closed) return; + const { series } = buildViewCountTimeSeries([...eventsById.values()]); + chartHost.textContent = ""; + chartHost.appendChild(buildViewCountChartSvg(doc, series)); + renderHeadline(); + }; + + const scheduleRender = () => { + if (rerenderTimer) return; + rerenderTimer = setTimeout(() => { + rerenderTimer = null; + renderChart(); + }, 250); + }; + + const close = () => { + if (closed) return; + closed = true; + if (rerenderTimer) { + clearTimeout(rerenderTimer); + rerenderTimer = null; + } + if (typeof unsubscribe === "function") { + try { + unsubscribe(); + } catch (error) { + // best effort + } + } + if (countToken != null) { + try { + unsubscribeFromVideoViewCount(pointer, countToken); + } catch (error) { + // best effort + } + } + doc.removeEventListener("keydown", onKeydown, true); + backdrop.remove(); + if (activePopularityModal === handle) { + activePopularityModal = null; + } + }; + + function onKeydown(event) { + if (event.key === "Escape") { + event.stopPropagation(); + close(); + } + } + + closeBtn.addEventListener("click", close); + backdrop.addEventListener("click", (event) => { + if (event.target === backdrop) close(); + }); + doc.addEventListener("keydown", onKeydown, true); + + // Headline total: subscribe to the same counter the card uses (stable + cached). + try { + countToken = subscribeToVideoViewCount( + pointer, + ({ total } = {}) => { + if (closed) return; + if (Number.isFinite(total)) { + counterTotal = Number(total); + renderHeadline(); + } + }, + { exact: true }, + ); + } catch (error) { + devLogger.warn("[popularity] Failed to subscribe to view count:", error); + } + + // Chart shape: list the recorded view events within the counter's backfill + // window so the distribution lines up with the headline's window. + const since = + Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * 86400; + Promise.resolve() + .then(() => listViewEvents(pointer, { since })) + .then((events) => { + if (closed) return; + (Array.isArray(events) ? events : []).forEach(addEvent); + renderChart(); + }) + .catch((error) => { + devLogger.warn("[popularity] Failed to load view events:", error); + }); + + try { + unsubscribe = subscribeViewEvents(pointer, { + onEvent: (event) => { + if (closed) return; + if (addEvent(event)) { + scheduleRender(); + } + }, + }); + } catch (error) { + devLogger.warn("[popularity] Failed to subscribe to view events:", error); + } + + const handle = { close }; + activePopularityModal = handle; + return handle; +} diff --git a/js/viewCounter.js b/js/viewCounter.js index eb86b9c4..a1da08b3 100644 --- a/js/viewCounter.js +++ b/js/viewCounter.js @@ -39,6 +39,29 @@ const countVideoViewEventsApi = (pointer, options) => const pointerStates = new Map(); /** @type {Map} */ const pointerListeners = new Map(); + +// Global "any view count changed" listeners (used by the Trending feed to +// debounce-re-rank as counts stream in). Coalesced so a burst of count arrivals +// from the batched backfill fires one notification, not dozens. +const globalChangeListeners = new Set(); +let globalChangeTimer = null; +const VIEW_COUNT_GLOBAL_NOTIFY_DEBOUNCE_MS = 400; + +function scheduleGlobalChangeNotify() { + if (!globalChangeListeners.size || globalChangeTimer) { + return; + } + globalChangeTimer = setTimeout(() => { + globalChangeTimer = null; + for (const listener of Array.from(globalChangeListeners)) { + try { + listener(); + } catch (error) { + userLogger.warn("[viewCounter] Global view-count listener threw:", error); + } + } + }, VIEW_COUNT_GLOBAL_NOTIFY_DEBOUNCE_MS); +} let persistTimer = null; let nextTokenId = 1; @@ -226,35 +249,84 @@ async function runBatchedBackfill() { // Exact NIP-45 COUNT for a single pointer (watch page). Cheap — one pointer, // no storm — and accurate where a viewer might actually scrutinize the number. +const EXACT_LIST_LIMIT = 2000; + +// Accurate single-pointer count (watch page / popularity chart). Lists view +// events across ALL relays and dedupes by (viewer, window) — a true cross-relay +// UNION. This replaces relying on the NIP-45 COUNT, which returns the single +// relay with the most events (it can't union across relays) and therefore +// UNDER-counts whenever views are spread across relays. The deduped set accrues +// monotonically, so the number is accurate AND never spuriously decreases. For +// extremely popular videos where the list truncates, the per-relay COUNT is used +// as a lower-bound floor and the result is flagged partial. async function exactCountForPointer(key, pointer, options = {}) { + const sinceSeconds = + Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY; + + let events; try { - const result = await countVideoViewEventsApi(pointer, { + events = await listVideoViewEventsApi(pointer, { + since: sinceSeconds, + limit: EXACT_LIST_LIMIT, relays: options.relays, signal: options.signal, }); - const best = Number.isFinite(result?.best?.count) - ? Number(result.best.count) - : Number.isFinite(result?.total) - ? Number(result.total) - : null; - if (best === null) { + } catch (error) { + if (error?.name === "AbortError") { return; } - const state = ensurePointerState(key); - if (result && typeof result === "object") { - state.partial = Boolean(result.partial); + userLogger.warn("[viewCounter] Exact view count list failed:", error); + return; + } + + const eventList = Array.isArray(events) ? events : []; + const truncated = eventList.length >= EXACT_LIST_LIMIT; + + const state = ensurePointerState(key); + // Fold the union into the deduped bucket set (one entry per viewer-window). + for (const event of eventList) { + const bucketKey = deriveBucketKey(event) || deriveFallbackKey(event); + if (!bucketKey) { + continue; } - if (best > state.total) { - state.total = best; - state.lastSyncedAt = Date.now(); - } - schedulePersist(); - notifyHandlers(key); - } catch (error) { - if (error?.name !== "AbortError") { - userLogger.warn("[viewCounter] Exact view count failed:", error); + const createdAt = Number.isFinite(event?.created_at) + ? Number(event.created_at) + : Math.floor(Date.now() / 1000); + state.dedupeBuckets.set(bucketKey, createdAt); + } + + let total = state.dedupeBuckets.size; + let partial = truncated; + + if (truncated) { + try { + const result = await countVideoViewEventsApi(pointer, { + relays: options.relays, + signal: options.signal, + }); + const best = Number.isFinite(result?.best?.count) + ? Number(result.best.count) + : Number.isFinite(result?.total) + ? Number(result.total) + : null; + if (Number.isFinite(best) && best > total) { + total = best; + } + partial = partial || Boolean(result?.partial); + } catch (error) { + if (error?.name === "AbortError") { + return; + } } } + + state.partial = partial; + if (total !== state.total) { + state.total = total; + state.lastSyncedAt = Date.now(); + } + schedulePersist(); + notifyHandlers(key); } restoreCacheSnapshot(); @@ -583,6 +655,9 @@ function ensurePointerListeners(key, pointer) { } function notifyHandlers(key) { + // Fire the global "counts changed" signal regardless of per-pointer listeners, + // so the Trending feed re-ranks even for cards it isn't directly subscribed to. + scheduleGlobalChangeNotify(); const listeners = pointerListeners.get(key); if (!listeners || !listeners.handlers.size) { return; @@ -916,3 +991,32 @@ export function formatViewCount(total) { } return String(value); } + +// Synchronous read of the cached, deduped view total for a pointer (or null if +// not yet known). The Trending sorter uses this to rank the active feed by views +// without subscribing to every pointer itself — counts are populated by the grid +// cards' batched subscriptions into this same shared cache. +export function getVideoViewCountSnapshot(pointerInput) { + try { + const { key } = canonicalizePointer(pointerInput); + if (!key) { + return null; + } + const state = pointerStates.get(key); + return state && Number.isFinite(state.total) ? Number(state.total) : null; + } catch (error) { + return null; + } +} + +// Subscribe to a coalesced "any view count changed" signal. Returns an +// unsubscribe function. Used by the Trending feed to debounce-re-rank. +export function onViewCountsChanged(listener) { + if (typeof listener !== "function") { + return () => {}; + } + globalChangeListeners.add(listener); + return () => { + globalChangeListeners.delete(listener); + }; +} diff --git a/js/viewManager.js b/js/viewManager.js index b3852bd7..8d9ef461 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -3,6 +3,7 @@ import { initChannelProfileView } from "./channelProfile.js"; import { initForYouView } from "./forYouView.js"; import { initKidsView } from "./kidsView.js"; import { initExploreView } from "./exploreView.js"; +import { initTrendingView } from "./trendingView.js"; import { subscriptions } from "./subscriptions.js"; import { getApplication } from "./applicationContext.js"; import { applyDesignSystemAttributes } from "./designSystem.js"; @@ -139,6 +140,26 @@ export const viewInitRegistry = { refreshApp.forceRefreshAllProfiles(); } }, + [FEED_TYPES.TRENDING]: () => { + const app = getApplication(); + initTrendingView({ + getApp: getApplication, + isActive: () => + typeof window !== "undefined" && + typeof window.location?.hash === "string" && + window.location.hash.startsWith(`#view=${FEED_TYPES.TRENDING}`), + }); + if (app && typeof app.loadTrendingVideos === "function") { + if (typeof app.mountVideoListView === "function") { + app.mountVideoListView({ includeTags: false }); + } + app.loadTrendingVideos(); + } + const refreshApp = getApplication(); + if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") { + refreshApp.forceRefreshAllProfiles(); + } + }, [FEED_TYPES.DOCS]: async () => { await initDocsView(); }, diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 4b9f507c..3b3fcb81 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -42,7 +42,8 @@ const GRANDFATHERED = { "js/nostr/watchHistory.js": 2388, "js/nostr/nip46Client.js": 2102, "js/state/cache.js": 1827, - "js/app/feedCoordinator.js": 1841, + "js/app/feedCoordinator.js": 1857, + "js/viewCounter.js": 1023, "js/app/authSessionCoordinator.js": 1803, "js/ui/components/RevertModal.js": 1663, "js/payments/nwcClient.js": 1642, @@ -62,7 +63,7 @@ const GRANDFATHERED = { "js/services/authService.js": 1335, "js/ui/zapController.js": 1206, "js/services/r2Service.js": 1192, - "js/ui/applicationBootstrap.js": 1163, + "js/ui/applicationBootstrap.js": 1217, "js/ui/components/EditModal.js": 1130, "js/services/commentThreadService.js": 1100, "js/nostr/managers/SignerManager.js": 1160, diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 20a3ac5c..8c1bcfb7 100755 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -7,6 +7,21 @@ import path from "node:path"; // Ensure NODE_ENV is set to "test" so that nostrToolsBootstrap.js uses the mock toolkit process.env.NODE_ENV = "test"; +// Quarantine: KNOWN-BROKEN unit test files that exist but currently fail or hang. +// They are NOT silently dropped — they are loudly reported on every run so they +// can't rot invisibly (the historical bug: the runner only collected files that +// 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 — triage todo-11b"], + ["tests/nostr-count-fallback.test.mjs", "FAIL — triage todo-11b"], + ["tests/admin-list-store.test.mjs", "FAIL — triage todo-11b"], + ["tests/nostr-boost-actions.test.mjs", "FAIL — 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)"], + ["tests/nostr-publish-rejection.test.mjs", "FAIL — triage todo-11b"], +]); + const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const testsDir = path.join(rootDir, "tests"); const testFiles = []; @@ -50,10 +65,11 @@ async function collectTests(dir) { (entry.name.endsWith(".test.mjs") || entry.name.endsWith(".test.js")); if (isTestFile) { - const content = await readFile(fullPath, "utf8"); - if (content.includes("node:test")) { - testFiles.push(fullPath); - } + // Collect EVERY test file. Both styles run fine through the spawn below: + // node:test files self-execute and exit non-zero on failure; bare top-level + // `assert` files throw on failure (also non-zero). Do not re-introduce a + // content-based include filter — that is what silently dropped files before. + testFiles.push(fullPath); } } } @@ -92,7 +108,41 @@ async function run() { return; } - let selectedTests = testFiles; + // Partition off the quarantined (known-broken) files and report them loudly so + // they are never silently skipped. A stale entry (file deleted/renamed) is also + // surfaced so the list stays honest. + const quarantinedPresent = []; + let selectedTests = testFiles.filter((file) => { + const rel = path.relative(rootDir, file).split(path.sep).join("/"); + if (QUARANTINE.has(rel)) { + quarantinedPresent.push(rel); + return false; + } + return true; + }); + + if (quarantinedPresent.length > 0) { + console.warn( + `\n⚠ ${quarantinedPresent.length} quarantined (known-broken) test file(s) SKIPPED — fix and remove from QUARANTINE (todo todo-11b):`, + ); + for (const rel of quarantinedPresent.sort()) { + console.warn(` • ${rel} — ${QUARANTINE.get(rel)}`); + } + } + + const staleQuarantine = [...QUARANTINE.keys()].filter( + (rel) => !quarantinedPresent.includes(rel), + ); + if (staleQuarantine.length > 0) { + console.warn( + `\n⚠ QUARANTINE lists ${staleQuarantine.length} file(s) that were not found — remove the stale entr${ + staleQuarantine.length === 1 ? "y" : "ies" + }:`, + ); + for (const rel of staleQuarantine.sort()) { + console.warn(` • ${rel}`); + } + } if (filters.length > 0) { selectedTests = selectedTests.filter((file) => diff --git a/tests/app/dm-composer-login-gate.test.mjs b/tests/app/dm-composer-login-gate.test.mjs new file mode 100644 index 00000000..4e31997e --- /dev/null +++ b/tests/app/dm-composer-login-gate.test.mjs @@ -0,0 +1,88 @@ +// Regression: the "Message" button opened the profile modal's messages tab even +// when nobody was logged in — but DMs need a signing identity, so the tab was +// unusable. openDirectMessageComposer must prompt for login instead. +// +// Scenario (SCN-dm-composer-login-gate): +// Given no user is logged in, +// When openDirectMessageComposer is called for a valid recipient, +// Then it surfaces a login prompt (login modal), does NOT open the messages +// pane, and returns false. +// And when a user IS logged in, it proceeds to open the composer. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { Application } from "../../js/app.js"; + +const RECIPIENT = "d".repeat(64); + +function createTestApp({ loggedIn }) { + const calls = { + showError: [], + openModal: 0, + requestLogin: 0, + setRecipient: 0, + showMessagesPane: 0, + }; + const app = { + calls, + normalizeHexPubkey: (value) => + typeof value === "string" && value.trim() ? value.trim() : null, + showError: (msg) => calls.showError.push(msg), + isUserLoggedIn: () => loggedIn, + loginModalController: { + openModal: () => { + calls.openModal += 1; + return true; + }, + }, + authService: { + requestLogin: () => { + calls.requestLogin += 1; + return Promise.resolve(true); + }, + }, + setDmRecipientPubkey: () => { + calls.setRecipient += 1; + }, + profileController: { + setDirectMessageRecipient: () => {}, + show: () => { + calls.showMessagesPane += 1; + }, + focusMessageComposer: () => {}, + }, + openDirectMessageComposer: Application.prototype.openDirectMessageComposer, + }; + return app; +} + +test("prompts for login and does not open messages when logged out", () => { + const app = createTestApp({ loggedIn: false }); + + const result = app.openDirectMessageComposer({ recipientPubkey: RECIPIENT }); + + assert.equal(result, false, "returns false when not logged in"); + assert.equal(app.calls.openModal, 1, "opens the login modal"); + assert.equal( + app.calls.showMessagesPane, + 0, + "must NOT open the messages pane when logged out", + ); + assert.equal(app.calls.setRecipient, 0, "must not set a DM recipient"); + assert.ok( + app.calls.showError.some((m) => /log in/i.test(m)), + "surfaces a login message", + ); +}); + +test("opens the composer when logged in", () => { + const app = createTestApp({ loggedIn: true }); + + const result = app.openDirectMessageComposer({ recipientPubkey: RECIPIENT }); + + assert.equal(result, true, "returns true when logged in"); + assert.equal(app.calls.openModal, 0, "does not prompt for login"); + assert.equal(app.calls.setRecipient, 1, "sets the DM recipient"); + assert.equal(app.calls.showMessagesPane, 1, "opens the messages pane"); +}); diff --git a/tests/channel-video-merge.test.mjs b/tests/channel-video-merge.test.mjs new file mode 100644 index 00000000..82acf29a --- /dev/null +++ b/tests/channel-video-merge.test.mjs @@ -0,0 +1,52 @@ +// Regression (#20): the channel grid render replaces the container with the live +// fetch, so a transiently-failing relay produced a smaller set and previously- +// known videos vanished ("some of a creator's videos are missing"). The live +// fetch must be unioned with already-known events so the grid never shrinks. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mergeChannelVideoSources } from "../js/channelProfileVideos.js"; + +const ev = (id, extra = {}) => ({ id, kind: 30078, ...extra }); + +test("union is a superset of both live and known events", () => { + const live = [ev("a"), ev("b")]; + const known = [ev("b"), ev("c"), ev("d")]; + + const merged = mergeChannelVideoSources(live, known); + const ids = merged.map((e) => e.id).sort(); + + assert.deepEqual(ids, ["a", "b", "c", "d"], "all unique ids from both sources"); +}); + +test("a known video missing from the live fetch still appears (the bug)", () => { + // The live fetch this load only returned one video; the other is known in + // memory (e.g. from a prior load / the main feed). It must not disappear. + const live = [ev("only-live")]; + const known = [ev("only-live"), ev("dropped-by-slow-relay")]; + + const merged = mergeChannelVideoSources(live, known); + const ids = merged.map((e) => e.id).sort(); + + assert.deepEqual(ids, ["dropped-by-slow-relay", "only-live"]); +}); + +test("live wins on id collision (freshest copy)", () => { + const live = [ev("x", { created_at: 200, source: "live" })]; + const known = [ev("x", { created_at: 100, source: "cache" })]; + + const merged = mergeChannelVideoSources(live, known); + assert.equal(merged.length, 1); + assert.equal(merged[0].source, "live", "live copy must win the dedupe"); +}); + +test("preserves id-less entries and tolerates non-array / junk input", () => { + const live = [ev("a"), { kind: 30078 /* no id */ }, null, "junk"]; + const merged = mergeChannelVideoSources(live, undefined); + const withId = merged.filter((e) => e && e.id === "a"); + const withoutId = merged.filter((e) => e && typeof e === "object" && !e.id); + + assert.equal(withId.length, 1); + assert.equal(withoutId.length, 1, "id-less event kept, not dropped"); + assert.deepEqual(mergeChannelVideoSources(null, null), []); +}); diff --git a/tests/dm-controller-delegations.test.mjs b/tests/dm-controller-delegations.test.mjs new file mode 100644 index 00000000..b402bc78 --- /dev/null +++ b/tests/dm-controller-delegations.test.mjs @@ -0,0 +1,70 @@ +// Regression (#19): the DM app-shell renderer wires its callbacks on +// `this.controller` (the ProfileDirectMessageController), but the controller was +// missing the delegation methods that forward to `this.actions` — so clicking Send +// threw "this.controller.handleDmAppShellSendMessage is not a function" and no DM +// could be sent (same class for mark-read / settings / toggles). + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { ProfileDirectMessageController } = await import( + "../js/ui/profileModal/ProfileDirectMessageController.js" +); + +// These are the app-shell callbacks the renderer invokes on the controller; each +// must exist and forward to the matching method on `this.actions`. +const DELEGATED = [ + "handleDmAppShellSendMessage", + "handleDmConversationMarkRead", + "handleDmMarkAllConversationsRead", + "handleReadReceiptsToggle", + "handleTypingIndicatorsToggle", + "openDmSettingsModal", +]; + +test("controller delegates every DM app-shell callback to this.actions", () => { + const proto = ProfileDirectMessageController.prototype; + for (const name of DELEGATED) { + assert.equal( + typeof proto[name], + "function", + `controller.${name} delegation must exist (renderer calls it)`, + ); + + const received = []; + const fakeController = { + actions: { + [name]: (...args) => { + received.push(args); + return `ok:${name}`; + }, + }, + }; + const result = proto[name].call(fakeController, "arg-a", "arg-b"); + assert.equal(result, `ok:${name}`, `${name} must return the actions result`); + assert.deepEqual( + received[0], + ["arg-a", "arg-b"], + `${name} must forward its arguments to this.actions.${name}`, + ); + } +}); + +test("controller delegates resolveProfileSummaryForPubkey to this.helper", () => { + // ProfileModerationController calls dmController.resolveProfileSummaryForPubkey; + // the method lives on the helper, so the controller must delegate it. + const proto = ProfileDirectMessageController.prototype; + assert.equal(typeof proto.resolveProfileSummaryForPubkey, "function"); + const received = []; + const fake = { + helper: { + resolveProfileSummaryForPubkey: (...args) => { + received.push(args); + return "summary"; + }, + }, + }; + const result = proto.resolveProfileSummaryForPubkey.call(fake, "pubkey-x"); + assert.equal(result, "summary"); + assert.deepEqual(received[0], ["pubkey-x"]); +}); diff --git a/tests/dm-decryptor-order.test.mjs b/tests/dm-decryptor-order.test.mjs index 86c5eafe..ad47e52e 100644 --- a/tests/dm-decryptor-order.test.mjs +++ b/tests/dm-decryptor-order.test.mjs @@ -55,3 +55,59 @@ test("decryptDM prefers higher priority decryptor even if slower", async () => { assert.equal(result.plaintext, "preferred-plaintext", `Expected preferred-plaintext but got ${result.plaintext}. Current implementation likely uses Promise.any which is sensitive to race conditions.`); }); + +// Regression: a kind-4 (legacy/NIP-04) DM must attempt the nip04 decryptor FIRST, +// even though the signer registers its nip44 candidate at a lower (earlier) +// `priority`. Previously the priority comparison ran before the legacy +// preference, so every kind-4 message tried nip44 on the `?iv=` ciphertext first +// (guaranteed "invalid base64" failure on the serialized extension) before +// falling back to nip04 — doubling signer round-trips and spamming the console. +test("kind-4 DM tries nip04 before nip44 despite nip44's earlier priority", async () => { + const event = { + kind: 4, + content: "deadbeef?iv=cafe", + pubkey: SENDER_HEX, + tags: [["p", RECIPIENT_HEX]], + }; + + const calls = []; + const context = { + actorPubkey: RECIPIENT_HEX, + // Mirror the signer registration: nip44 at priority -20, nip04 at -10. + decryptors: [ + { + scheme: "nip44", + priority: -20, + source: "extension", + supportsGiftWrap: true, + decrypt: async () => { + calls.push("nip44"); + // nip44 on a NIP-04 ?iv= ciphertext always fails. + throw new Error('invalid base64: Unknown letter: "?"'); + }, + }, + { + scheme: "nip04", + priority: -10, + source: "extension", + decrypt: async (remote) => { + calls.push("nip04"); + if (remote === SENDER_HEX) { + return "hello"; + } + throw new Error("wrong remote: " + remote); + }, + }, + ], + }; + + const result = await decryptDM(event, context); + + assert.equal(result.ok, true, "decrypt should succeed via nip04"); + assert.equal(result.plaintext, "hello"); + assert.deepEqual( + calls, + ["nip04"], + `nip04 must be tried first and succeed without ever calling nip44; got call order ${JSON.stringify(calls)}`, + ); +}); diff --git a/tests/feed-engine/explore-scorer.test.mjs b/tests/feed-engine/explore-scorer.test.mjs index 872a391e..c579e7fc 100644 --- a/tests/feed-engine/explore-scorer.test.mjs +++ b/tests/feed-engine/explore-scorer.test.mjs @@ -158,3 +158,34 @@ test("explore scorer rewards novelty and new tag fraction", async () => { assert.equal(novel.novelty, 1); assert.equal(novel.newTagFraction, 1); }); + +// Explore identity: "new-to-you". A creator you already follow gets a soft +// penalty so non-followed creators surface, without filtering the feed empty. +test("explore soft-penalizes authors you already follow", async () => { + const NOW = Math.floor(Date.now() / 1000); + const FOLLOWED = "f".repeat(64); + const STRANGER = "1".repeat(64); + const tags = [["t", "music"]]; + const items = [ + { video: { id: "stranger", pubkey: STRANGER, created_at: NOW, tags }, metadata: {} }, + { video: { id: "followed", pubkey: FOLLOWED, created_at: NOW, tags }, metadata: {} }, + ]; + + await createExploreScorerStage()(items, { + now: NOW, + runtime: { + subscriptionAuthors: [FOLLOWED], + tagPreferences: { interests: [], disinterests: [] }, + }, + addWhy() {}, + }); + + const strangerScore = items[0].metadata.exploreScore; + const followedScore = items[1].metadata.exploreScore; + assert.ok( + followedScore < strangerScore, + `followed (${followedScore}) must score below an identical stranger (${strangerScore})`, + ); + assert.equal(items[1].metadata.exploreComponents.followedMatch, 1); + assert.equal(items[0].metadata.exploreComponents.followedMatch, 0); +}); diff --git a/tests/filter-hex-sanitize.test.mjs b/tests/filter-hex-sanitize.test.mjs index a3701af7..ab56bbe1 100644 --- a/tests/filter-hex-sanitize.test.mjs +++ b/tests/filter-hex-sanitize.test.mjs @@ -62,3 +62,20 @@ test("ignores empty/absent hex fields", () => { const out = sanitizeHexFilterFields({ kinds: [1], authors: [] }); assert.deepEqual(out, { kinds: [1], authors: [] }); }); + +test("sanitizes NIP-22 uppercase root tags #E/#P (kind-1111 comments)", () => { + // Relays hex-decode #E (root event id) and #P (root author pubkey) too. An + // odd-length value there previously bypassed the sanitizer and got the whole + // comment REQ rejected ("uneven size input to from_hex"). + const out = sanitizeHexFilterFields({ + kinds: [1111], + "#E": [HEX_A, ODD], + "#P": [HEX_B], + "#K": ["30078"], // a non-hex tag must be left untouched + "#A": ["30078:" + HEX_A + ":dtag"], // address tag is not hex-decoded + }); + assert.deepEqual(out["#E"], [HEX_A], "drops the odd-length root event id"); + assert.deepEqual(out["#P"], [HEX_B]); + assert.deepEqual(out["#K"], ["30078"], "#K (kind) untouched"); + assert.deepEqual(out["#A"], ["30078:" + HEX_A + ":dtag"], "#A (address) untouched"); +}); diff --git a/tests/for-you-tiering.test.mjs b/tests/for-you-tiering.test.mjs new file mode 100644 index 00000000..8fd1d9df --- /dev/null +++ b/tests/for-you-tiering.test.mjs @@ -0,0 +1,114 @@ +// Scenario (SCN-for-you-tiering): "your people first". +// Given For You items tagged with forYouTier (2=followed author, 1=interest/ +// watch match, 0=other) and forYouScore, +// When sorted by createForYouScoreSorter, +// Then higher tiers lead REGARDLESS of raw score (a followed/interest video +// outranks a higher-scoring stranger), trusted-muted sinks last, and when +// NOTHING is tiered the order is author-interleaved (discovery) rather than a +// straight score/recency clone of Recently-added. +// +// Cheat-resistant: tier-2/tier-1 winners are given deliberately LOWER scores than +// the tier-0 item, so they can only lead via the tier, not the score. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createForYouScoreSorter, + interleaveByAuthor, +} from "../js/feedEngine/sorters.js"; +import { createForYouScorerStage } from "../js/feedEngine/exploreScoring.js"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const C = "c".repeat(64); + +const item = (id, { tier = 0, score = 0, pubkey = A, muted = false } = {}) => ({ + video: { id, pubkey, created_at: 1000 }, + metadata: { + forYouTier: tier, + forYouScore: score, + ...(muted ? { moderation: { trustedMuted: true } } : {}), + }, +}); + +const order = (items) => + createForYouScoreSorter()(items, {}).map((i) => i.video.id); + +test("a followed (tier 2) video leads a higher-scoring stranger (tier 0)", () => { + const items = [ + item("stranger", { tier: 0, score: 0.95, pubkey: B }), + item("followed", { tier: 2, score: 0.1, pubkey: A }), + ]; + assert.deepEqual(order(items), ["followed", "stranger"]); +}); + +test("tiers stack: follow > interest > other, regardless of score", () => { + const items = [ + item("other", { tier: 0, score: 0.9, pubkey: C }), + item("interest", { tier: 1, score: 0.2, pubkey: B }), + item("followed", { tier: 2, score: 0.05, pubkey: A }), + ]; + assert.deepEqual(order(items), ["followed", "interest", "other"]); +}); + +test("trusted-muted sinks last even if tiered", () => { + const items = [ + item("muted-follow", { tier: 2, score: 0.9, pubkey: A, muted: true }), + item("plain", { tier: 0, score: 0.1, pubkey: B }), + ]; + assert.deepEqual(order(items), ["plain", "muted-follow"]); +}); + +test("no-signal fallback interleaves authors (not a chronological clone)", () => { + // All tier 0 (no follows/interests). Author A dominates by count; a straight + // score/recency sort would clump all A's first. Interleave must break that up. + const items = [ + item("a1", { tier: 0, score: 0.9, pubkey: A }), + item("a2", { tier: 0, score: 0.8, pubkey: A }), + item("a3", { tier: 0, score: 0.7, pubkey: A }), + item("b1", { tier: 0, score: 0.6, pubkey: B }), + item("c1", { tier: 0, score: 0.5, pubkey: C }), + ]; + const result = order(items); + // First three must NOT all be author A (the clumping a pure sort would produce). + const firstThreeAuthors = result.slice(0, 3).map((id) => id[0]); + assert.notDeepEqual(firstThreeAuthors, ["a", "a", "a"]); + // Highest-scored item still leads its pass. + assert.equal(result[0], "a1"); + // No items lost. + assert.deepEqual([...result].sort(), ["a1", "a2", "a3", "b1", "c1"]); +}); + +test("interleaveByAuthor is a no-op for <3 items", () => { + const items = [item("x", { pubkey: A }), item("y", { pubkey: A })]; + assert.deepEqual( + interleaveByAuthor(items).map((i) => i.video.id), + ["x", "y"], + ); +}); + +test("scorer tags forYouTier: 2 for follows, 1 for interest, 0 for neither", async () => { + const NOW = Math.floor(Date.now() / 1000); + const mk = (id, pubkey, tags = []) => ({ + video: { id, pubkey, created_at: NOW, tags: tags.map((t) => ["t", t]) }, + metadata: {}, + }); + const items = [ + mk("followed", A, []), + mk("interested", B, ["nostr"]), + mk("neither", C, []), + ]; + await createForYouScorerStage()(items, { + now: NOW, + runtime: { + subscriptionAuthors: [A], + tagPreferences: { interests: ["nostr"], disinterests: [] }, + }, + }); + const tierById = Object.fromEntries( + items.map((i) => [i.video.id, i.metadata.forYouTier]), + ); + assert.equal(tierById.followed, 2, "followed author → tier 2"); + assert.equal(tierById.interested, 1, "interest match → tier 1"); + assert.equal(tierById.neither, 0, "no signal → tier 0"); +}); diff --git a/tests/lnurl-fetch-error.test.mjs b/tests/lnurl-fetch-error.test.mjs index e3e96b53..2f7493bf 100644 --- a/tests/lnurl-fetch-error.test.mjs +++ b/tests/lnurl-fetch-error.test.mjs @@ -45,3 +45,57 @@ test("a valid LNURL pay response parses (no false friendly error)", async () => }); assert.equal(meta.callback, "https://example.com/cb"); }); + +import { checkLightningAddressZappable } from "../js/payments/lnurl.js"; + +// Helper: a fetcher that returns a valid LNURL pay-service-data JSON response. +function okFetcher({ allowsNostr = true } = {}) { + return async () => ({ + ok: true, + status: 200, + json: async () => ({ + tag: "payRequest", + callback: "https://host/callback", + minSendable: 1000, + maxSendable: 1000000, + metadata: "[]", + allowsNostr, + nostrPubkey: allowsNostr ? "a".repeat(64) : "", + }), + }); +} + +test("checkLightningAddressZappable: reachable + Nostr-enabled host -> ok", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: okFetcher({ allowsNostr: true }), + }); + assert.equal(result.ok, true); + assert.equal(result.reason, "ok"); + assert.equal(result.address, "name@host.example"); +}); + +test("checkLightningAddressZappable: CORS/offline host -> not ok, coded", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: async () => { + throw new TypeError("Failed to fetch"); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "lnurl-unreachable"); + assert.equal(result.address, "name@host.example"); +}); + +test("checkLightningAddressZappable: reachable but not Nostr-aware -> ok with no-nostr", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: okFetcher({ allowsNostr: false }), + }); + assert.equal(result.ok, true); + assert.equal(result.reason, "no-nostr"); + assert.equal(result.allowsNostr, false); +}); + +test("checkLightningAddressZappable: invalid address -> not ok", async () => { + const result = await checkLightningAddressZappable("", { fetcher: okFetcher() }); + assert.equal(result.ok, false); + assert.equal(result.reason, "invalid-address"); +}); diff --git a/tests/more-menu-controller.test.mjs b/tests/more-menu-controller.test.mjs index a73c3e11..703cf188 100644 --- a/tests/more-menu-controller.test.mjs +++ b/tests/more-menu-controller.test.mjs @@ -252,3 +252,110 @@ test('unmute-author removes creators from viewer mute list', async () => { ], ]); }); + +// Regression: card popovers don't always carry the video object on the menu +// context. The view-stats and event-details actions must resolve the video from +// the menu item's eventId so the buttons work for cards (not just the modal). +test('event-details forwards the eventId so the app can resolve a card video', async () => { + let received = null; + const controller = new MoreMenuController({ + callbacks: { + getCurrentVideo: () => null, + handleEventDetailsAction: async (payload) => { + received = payload; + }, + showError: (message) => { + throw new Error(`Unexpected error: ${message}`); + }, + }, + }); + + // video === null mimics entry.context.video being absent on a card popover. + await controller.handleMoreMenuAction( + 'event-details', + { eventId: 'card-event-id', context: 'card' }, + null, + ); + + assert.equal(received?.eventId, 'card-event-id'); + assert.equal(received?.video, null); +}); + +test('view-stats looks the card video up by eventId instead of the modal video', async () => { + const resolvedVideo = { id: 'card-event-id', pubkey: 'authorhex' }; + const lookups = []; + const controller = new MoreMenuController({ + callbacks: { + // The old code used `|| currentVideo`, which would show this video's stats + // for any card whose popover lacked the object — the bug we're guarding. + getCurrentVideo: () => ({ id: 'wrong-modal-video' }), + getVideoByEventId: (eventId) => { + lookups.push(eventId); + return eventId === 'card-event-id' ? resolvedVideo : null; + }, + showError: (message) => { + throw new Error(`Unexpected error: ${message}`); + }, + }, + }); + + // Drive the real action with no video object (card popover) in a DOM-less + // harness — openPopularityModal no-ops without a document, so we assert the + // controller resolved the right video by eventId before opening. + await controller.handleMoreMenuAction( + 'view-stats', + { eventId: 'card-event-id', context: 'card' }, + null, + ); + + assert.deepEqual(lookups, ['card-event-id']); +}); + +// Regression: the modal ⋯ trigger declares its context via `data-more-dropdown` +// ("modal"), not `data-context`. Reading only `data-context` made it default to +// "card" and skip modal video resolution, so the popover opened with a null +// video and every action reported "No video selected." attachMoreMenuHandlers +// must treat the trigger as modal context and resolve the modal's active video. +test('modal ⋯ trigger resolves its video via data-more-dropdown + activeVideo fallback', async () => { + const activeVideo = { id: 'modal-video-id', pubkey: 'authorhex' }; + + const controller = new MoreMenuController({ + callbacks: { + // currentVideo is null (e.g. deep-link open without playback) — the bug. + getCurrentVideo: () => null, + canCurrentUserManageBlacklist: () => false, + }, + }); + // The modal is the authoritative on-screen video source. + controller.setVideoModal({ activeVideo }); + // Avoid the real DOM/popover engine; capture what toggleMoreMenu receives. + let toggled = null; + controller.toggleMoreMenu = (detail) => { + toggled = detail; + }; + controller.ensureGlobalMoreMenuHandlers = () => {}; + controller.getHTMLElementConstructor = () => null; + controller.document = {}; + + // Minimal trigger button mimicking the modal ⋯ markup. + let clickHandler = null; + const button = { + dataset: {}, + getAttribute: (name) => + name === 'data-more-dropdown' ? 'modal' : null, + addEventListener: (type, handler) => { + if (type === 'click') clickHandler = handler; + }, + }; + const container = { + querySelectorAll: () => [button], + }; + + controller.attachMoreMenuHandlers(container); + assert.equal(typeof clickHandler, 'function', 'click handler bound'); + + await clickHandler({ preventDefault() {}, stopPropagation() {} }); + + assert.equal(toggled?.context, 'modal'); + assert.equal(toggled?.video, activeVideo); +}); diff --git a/tests/nip71-mirror-flags.test.mjs b/tests/nip71-mirror-flags.test.mjs index 62318e16..992fe006 100644 --- a/tests/nip71-mirror-flags.test.mjs +++ b/tests/nip71-mirror-flags.test.mjs @@ -70,10 +70,19 @@ test("resolveEditSync: keeps a mirrored video in lockstep, unshares when ineligi assert.deepEqual(resolveEditSync({ featureOn: true, enabled: true, eligible: false }), { action: "unshare" }); }); -test("resolveDeleteSync: removes the mirror only when it was shared", () => { +test("resolveDeleteSync: attempts teardown whenever the feature is on, regardless of the local flag", () => { + // Spec correction: the previous expectation gated teardown on the local + // `enabled` flag (enabled:false -> none). That flag is browser-local only, so + // a video shared on one device and deleted from another (or after a cache + // clear) would skip teardown and orphan the NIP-71 mirror on other apps. The + // teardown (NIP-09 + empty tombstone) is idempotent, so delete must attempt it + // whenever the feature is on. `enabled` is intentionally ignored for delete. assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: true }), { action: "unshare" }); - assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "none" }); + assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: false }), { action: "unshare" }); + assert.deepEqual(resolveDeleteSync({ featureOn: true, enabled: undefined }), { action: "unshare" }); + // The feature flag still fully gates it: a build with the mirror off never publishes. assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: true }), { action: "none" }); + assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: false }), { action: "none" }); }); test("auto-share preference persists per pubkey, off by default", () => { diff --git a/tests/nip71-mirror-sync.test.mjs b/tests/nip71-mirror-sync.test.mjs index f76d210e..252a5191 100644 --- a/tests/nip71-mirror-sync.test.mjs +++ b/tests/nip71-mirror-sync.test.mjs @@ -4,26 +4,63 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; -import test from "node:test"; +import test, { mock } from "node:test"; import { syncNip71MirrorAfterDelete, initNip71MirrorSync, } from "../js/services/nip71MirrorSync.js"; import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js"; +import { nip71MirrorService } from "../js/services/nip71MirrorService.js"; const PK = "a".repeat(64); -test("deleting a SHARED video clears its opt-in flag (mirror torn down)", async () => { +test("deleting a SHARED video tears the mirror down and clears its opt-in flag", async () => { localStorage.clear(); setMirrorEnabled(PK, "root-x", true); - await syncNip71MirrorAfterDelete({ videoRootId: "root-x", video: {}, pubkey: PK }); - assert.equal(isMirrorEnabled(PK, "root-x"), false, "flag cleared after delete"); + const removeSpy = mock.method(nip71MirrorService, "remove", async () => ({ ok: true })); + try { + await syncNip71MirrorAfterDelete({ videoRootId: "root-x", video: {}, pubkey: PK }); + assert.equal(removeSpy.mock.callCount(), 1, "mirror teardown attempted"); + assert.equal(isMirrorEnabled(PK, "root-x"), false, "flag cleared after delete"); + } finally { + removeSpy.mock.restore(); + } }); -test("deleting a NON-shared video is a no-op (and never throws)", async () => { +test("deleting an UNFLAGGED video still attempts teardown (cross-device / cleared cache)", async () => { + // Regression: the per-video opt-in flag is browser-local, so a video shared on + // one device and deleted from another (or after a cache clear) has no flag set + // here. Teardown (NIP-09 + empty tombstone) is idempotent, so delete must still + // attempt it — otherwise the NIP-71 mirror is orphaned on other apps. localStorage.clear(); - await syncNip71MirrorAfterDelete({ videoRootId: "root-y", video: {}, pubkey: PK }); - assert.equal(isMirrorEnabled(PK, "root-y"), false); + assert.equal(isMirrorEnabled(PK, "root-y"), false, "precondition: no local flag"); + const removeSpy = mock.method(nip71MirrorService, "remove", async () => ({ ok: true })); + try { + await syncNip71MirrorAfterDelete({ videoRootId: "root-y", video: {}, pubkey: PK }); + assert.equal( + removeSpy.mock.callCount(), + 1, + "teardown must be attempted even with no local opt-in flag", + ); + const arg = removeSpy.mock.calls[0].arguments[0]; + assert.equal(arg.videoRootId, "root-y", "teardown targets the deleted root"); + assert.equal(arg.pubkey, PK, "teardown targets the author"); + } finally { + removeSpy.mock.restore(); + } +}); + +test("delete sync never throws when teardown fails (best-effort)", async () => { + localStorage.clear(); + const removeSpy = mock.method(nip71MirrorService, "remove", async () => { + throw new Error("relay unreachable"); + }); + try { + await syncNip71MirrorAfterDelete({ videoRootId: "root-z", video: {}, pubkey: PK }); + assert.equal(isMirrorEnabled(PK, "root-z"), false, "flag stays clear even on failure"); + } finally { + removeSpy.mock.restore(); + } }); test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => { diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index 90b50021..fafcbf0c 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -1,9 +1,11 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; +import test from "node:test"; import { NostrClient } from "../js/nostr/client.js"; import { NostrService } from "../js/services/nostrService.js"; +import { VideoEventBuffer } from "../js/nostr/videoEventBuffer.js"; -await (async function testDeleteFlowPublishesDeletionFlag() { +test("delete flow publishes the deletion flag and surfaces relay failures", async () => { localStorage.clear(); const client = new NostrClient(); client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); @@ -48,7 +50,12 @@ await (async function testDeleteFlowPublishesDeletionFlag() { }); client.ensureExtensionPermissions = async () => ({ ok: true }); + // Deletes publish to the WRITE set via getDeletePublishRelays() (the afb6200b + // relay-scope fix), NOT client.relays. writeRelays defaults to the bundled + // relays, so the fail-relay must be configured here or the injected failure is + // never exercised and the "surface relay failures" assertions silently pass. client.relays = ["wss://relay.ok", "wss://relay.fail"]; + client.writeRelays = ["wss://relay.ok", "wss://relay.fail"]; client.rawEvents.set(baseVideo.id, { id: baseVideo.id, kind: 30078, @@ -189,44 +196,41 @@ await (async function testDeleteFlowPublishesDeletionFlag() { created_at: baseVideo.created_at, }); + // Live-stream guard. subscribeVideos routes relay events through a + // VideoEventBuffer (via the SubscriptionManager); we drive that real buffer + // directly here. A deletion event arriving on the stream must clear the active + // map entry and advance the tombstone, and must never surface as a *live* + // video. (Note: under the current architecture a deletion DOES surface to the + // callback marked deleted:true so the UI can remove the card — the obsolete + // pre-buffer expectation of "no callback at all" was incorrect.) const seenUpdates = []; - const handlers = {}; - const fakeSub = { - on(type, handler) { - handlers[type] = handler; + const buffer = new VideoEventBuffer( + client, + (videos) => { + if (Array.isArray(videos)) { + seenUpdates.push(...videos); + } }, - unsub() {}, - }; - - client.pool = { - sub() { - return fakeSub; - }, - }; - - client.subscribeVideos((videos) => { - if (Array.isArray(videos)) { - seenUpdates.push(...videos); - } - }); + { verifyEvents: client.videoEventVerifier }, + ); const deleteEvent = { id: "delete-event-1", + kind: 30078, pubkey, created_at: revertEvents[0].created_at + 100, content: revertCalls[0].content, tags: baseVideo.tags, }; - handlers.event(deleteEvent); - if (typeof handlers.eose === "function") { - await handlers.eose(); - } + buffer.push(deleteEvent); + await buffer.scheduleFlush(true); assert.equal( - seenUpdates.length, + seenUpdates.filter((video) => video.id === deleteEvent.id && !video.deleted) + .length, 0, - "deleted events should not trigger subscription callbacks", + "a deletion event must not surface as a live (non-deleted) video", ); assert.equal( client.activeMap.has(activeKey), @@ -242,9 +246,9 @@ await (async function testDeleteFlowPublishesDeletionFlag() { "subscription delete event should update tombstone", ); delete window.nostr; -})(); +}); -await (async function testTombstonePersistenceAcrossSaveRestore() { +test("tombstone persists across save/restore", async () => { localStorage.clear(); const client = new NostrClient(); client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); @@ -290,34 +294,30 @@ await (async function testTombstonePersistenceAcrossSaveRestore() { false, "restored tombstoned video should not populate activeMap", ); -})(); +}); -await (async function testSubscribeVideosSkipsOlderThanTombstone() { +test("buffered feed guards events older than a tombstone (no resurrection)", async () => { + // subscribeVideos streams relay events through a VideoEventBuffer; we drive the + // real buffer + real NostrClient here (the buffer's own unit test mocks the + // client's tombstone guard, so this is the integration-level check). A delete + // event seeds the tombstone; an OLDER non-deleted event arriving afterward must + // be guarded (marked deleted, kept out of activeMap) so it can never resurrect + // the video as live. localStorage.clear(); const client = new NostrClient(); client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id)); client.populateNip71MetadataForVideos = async () => {}; const seenUpdates = []; - const handlers = {}; - const fakeSub = { - on(type, handler) { - handlers[type] = handler; + const buffer = new VideoEventBuffer( + client, + (videos) => { + if (Array.isArray(videos)) { + seenUpdates.push(...videos); + } }, - unsub() {}, - }; - - client.pool = { - sub() { - return fakeSub; - }, - }; - - client.subscribeVideos((videos) => { - if (Array.isArray(videos)) { - seenUpdates.push(...videos); - } - }); + { verifyEvents: client.videoEventVerifier }, + ); const pubkey = "PUBKEY789"; const videoRootId = "root-sub"; @@ -328,6 +328,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { const deleteEvent = { id: "delete-event-guard", + kind: 30078, pubkey, created_at: 1_700_100_000, content: JSON.stringify({ @@ -344,10 +345,8 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { tags, }; - handlers.event(deleteEvent); - if (typeof handlers.eose === "function") { - await handlers.eose(); - } + buffer.push(deleteEvent); + await buffer.scheduleFlush(true); const activeKey = `ROOT:${videoRootId}`; assert.equal( @@ -363,6 +362,7 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { const olderEvent = { id: "older-event", + kind: 30078, pubkey, created_at: deleteEvent.created_at - 60, content: JSON.stringify({ @@ -379,15 +379,14 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { tags, }; - handlers.event(olderEvent); - if (typeof handlers.eose === "function") { - await handlers.eose(); - } + buffer.push(olderEvent); + await buffer.scheduleFlush(true); assert.equal( - seenUpdates.length, + seenUpdates.filter((video) => video.id === olderEvent.id && !video.deleted) + .length, 0, - "older events published after tombstone should not trigger callbacks", + "a tombstone-guarded older event must not surface as a live video", ); const storedOlder = client.allEvents.get(olderEvent.id); @@ -397,9 +396,9 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() { false, "older event should not repopulate the active map", ); -})(); +}); -await (async function testNostrServiceBubblesDeleteFailures() { +test("nostrService bubbles delete + revert relay failures", async () => { const service = new NostrService(); const pubkey = "PUBKEY123"; const videoRootId = "root-service"; @@ -485,6 +484,4 @@ await (async function testNostrServiceBubblesDeleteFailures() { "wss://relay.fail", "delete failures should include the failing relay url", ); -})(); - -console.log("nostr delete flow tests passed"); +}); diff --git a/tests/nostr/client.test.mjs b/tests/nostr/client.test.mjs index c9b7126e..a79495e1 100644 --- a/tests/nostr/client.test.mjs +++ b/tests/nostr/client.test.mjs @@ -310,4 +310,60 @@ describe("NostrClient", () => { // and focus on `fetch`/`subscribe` which use `this.pool`. }); }); + + describe("tombstone guard (legacy zombie suppression)", () => { + // Regression for pre-launch TODO #1 ("getActiveKey mismatch"). The deletion + // builder synthesizes videoRootId = "LEGACY::" for legacy + // videos with no real root, and deleteAllVersions keys the tombstone via the + // client's active-key derivation. A stale local copy of getActiveKey (missing + // the LEGACY: guard) keyed the tombstone as "ROOT:LEGACY:..." while a bare + // zombie copy of the original event (no videoRootId) keyed as + // ":", so the guard never matched and deleted legacy videos + // resurrected. The client must derive both from the same canonical key. + it("suppresses a bare legacy zombie that matches a synthesized-root tombstone", () => { + const pubkey = "PUBKEY_LEGACY"; + const dTag = "legacy-d-tag"; + + // The deletion record as written by deleteAllVersions. + const deletionRecord = { + id: "deletion-note", + pubkey, + created_at: 1_700_000_500, + videoRootId: `LEGACY:${pubkey}:${dTag}`, + tags: [["d", dTag]], + }; + + // The original/zombie event as parsed fresh from a relay: no videoRootId. + const zombie = { + id: "original-note", + pubkey, + created_at: 1_700_000_000, // strictly older than the tombstone + tags: [["d", dTag]], + deleted: false, + }; + + const deletionKey = client.getActiveKey(deletionRecord); + const zombieKey = client.getActiveKey(zombie); + + assert.equal( + zombieKey, + `${pubkey}:${dTag}`, + "legacy events must key as pubkey:dTag, not ROOT:LEGACY:...", + ); + assert.equal( + deletionKey, + zombieKey, + "synthesized LEGACY deletion must key identically to the bare event", + ); + + client.recordTombstone(deletionKey, deletionRecord.created_at); + + assert.equal( + client.applyTombstoneGuard(zombie), + true, + "older legacy zombie must be guarded by the tombstone", + ); + assert.equal(zombie.deleted, true, "zombie event must be marked deleted"); + }); + }); }); diff --git a/tests/nwc-parse-uri.test.mjs b/tests/nwc-parse-uri.test.mjs new file mode 100644 index 00000000..6468d330 --- /dev/null +++ b/tests/nwc-parse-uri.test.mjs @@ -0,0 +1,47 @@ +// Real-tools regression test for the NWC URI parse fix. Unlike nwc-client.test.mjs +// (which mocks nostr-tools and is currently shadowed by the frozen canonical +// toolkit the bootstrap installs), this runs against the real nostr-tools — which +// is exactly the environment the production bug occurs in. +import test from "node:test"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const { __TESTING__ } = await import("../js/payments/nwcClient.js"); +const { parseNwcUri } = __TESTING__; + +test("parseNwcUri derives clientPubkey from a hex secret without throwing", () => { + // Regression (#3 NWC): nostr-tools >= 2.x getPublicKey requires a Uint8Array, + // not a hex string. parseNwcUri passed the hex secret directly, so it threw + // "expected Uint8Array, got type=string" and NWC wallet connection failed + // entirely (the only zap payment path). The fix converts hex -> bytes. + const walletPubkey = "b".repeat(64); + const secretKey = "a".repeat(64); // a valid secp256k1 scalar (0 < k < n) + const relay = "wss://relay.example"; + const uri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent(relay)}` + + `&secret=${secretKey}`; + + const parsed = parseNwcUri(uri); + + assert.equal(parsed.secretKey, secretKey); + assert.equal(parsed.walletPubkey, walletPubkey); + assert.deepEqual(parsed.relays, [relay]); + assert.match( + parsed.clientPubkey, + /^[0-9a-f]{64}$/, + "clientPubkey must be derived as 64-char hex (getPublicKey accepted the secret)", + ); +}); + +test("parseNwcUri rejects a non-hex / wrong-length secret", () => { + const walletPubkey = "b".repeat(64); + const uri = + `nostr+walletconnect://${walletPubkey}` + + `?relay=${encodeURIComponent("wss://relay.example")}` + + `&secret=not-a-valid-secret`; + assert.throws(() => parseNwcUri(uri), /secret must be a 64 character hex string/i); +}); diff --git a/tests/services/playbackService_multisource.test.mjs b/tests/services/playbackService_multisource.test.mjs new file mode 100644 index 00000000..b84dba48 --- /dev/null +++ b/tests/services/playbackService_multisource.test.mjs @@ -0,0 +1,81 @@ +// #5 multi-source fail-over: the PlaybackSession builds an ordered, de-duped list +// of hosted sources (primary URL first, then the video's imeta mirrors) that the +// execution flow tries in turn before dropping to WebTorrent. This verifies the +// candidate-list construction (deterministic). The full async fail-over flow is +// exercised by the playbackService_order suite in CI (the async/mock-timers +// playback harness doesn't run in this sandbox). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PlaybackService } from "../../js/services/playbackService.js"; + +const makeSession = ({ url, sources, magnet = "" }) => { + const service = new PlaybackService({ + logger: () => {}, + urlFirstEnabled: true, + isValidMagnetUri: () => true, + }); + return service.createSession({ url, sources, magnet }); +}; + +test("primary URL leads, then the imeta mirrors, de-duped", () => { + const session = makeSession({ + url: "https://primary.example/v.mp4", + sources: [ + { url: "https://mirror-a.example/v.mp4" }, + { url: "https://mirror-b.example/v.mp4" }, + ], + }); + assert.deepEqual(session.hostedSourceCandidates, [ + "https://primary.example/v.mp4", + "https://mirror-a.example/v.mp4", + "https://mirror-b.example/v.mp4", + ]); +}); + +test("de-dupes the primary when it also appears in sources (case-insensitive)", () => { + const session = makeSession({ + url: "https://primary.example/v.mp4", + sources: [ + { url: "https://PRIMARY.example/v.mp4" }, + { url: "https://mirror.example/v.mp4" }, + { url: "https://mirror.example/v.mp4" }, + ], + }); + assert.deepEqual(session.hostedSourceCandidates, [ + "https://primary.example/v.mp4", + "https://mirror.example/v.mp4", + ]); +}); + +test("accepts plain-string sources too", () => { + const session = makeSession({ + url: "https://primary.example/v.mp4", + sources: ["https://mirror.example/v.mp4"], + }); + assert.deepEqual(session.hostedSourceCandidates, [ + "https://primary.example/v.mp4", + "https://mirror.example/v.mp4", + ]); +}); + +test("no sources → just the primary (single-source behavior unchanged)", () => { + const session = makeSession({ url: "https://only.example/v.mp4" }); + assert.deepEqual(session.hostedSourceCandidates, ["https://only.example/v.mp4"]); +}); + +test("torrent-only video (no URL) → empty hosted candidates", () => { + const session = makeSession({ url: "", sources: [], magnet: "magnet:?xt=urn:btih:abc" }); + assert.deepEqual(session.hostedSourceCandidates, []); +}); + +test("ignores junk source entries (null, missing url, blanks)", () => { + const session = makeSession({ + url: "https://primary.example/v.mp4", + sources: [null, {}, { url: " " }, { url: "https://good.example/v.mp4" }, " "], + }); + assert.deepEqual(session.hostedSourceCandidates, [ + "https://primary.example/v.mp4", + "https://good.example/v.mp4", + ]); +}); diff --git a/tests/settings-restore-prompt.test.mjs b/tests/settings-restore-prompt.test.mjs index 3c49b3e2..7f44913f 100644 --- a/tests/settings-restore-prompt.test.mjs +++ b/tests/settings-restore-prompt.test.mjs @@ -65,7 +65,7 @@ test("declining the prompt restores nothing", async () => { assert.equal(storageSync.pulls, 0, "decline must not pull"); }); -test("does not offer twice once it has prompted (per pubkey)", async () => { +test("does not offer the SAME item twice once it has prompted", async () => { localStorage.clear(); let confirmCount = 0; const prompt = createSettingsRestorePrompt({ @@ -79,9 +79,56 @@ test("does not offer twice once it has prompted (per pubkey)", async () => { await prompt.maybeOffer(PUBKEY); const second = await prompt.maybeOffer(PUBKEY); - assert.equal(confirmCount, 1, "must prompt only once"); + assert.equal(confirmCount, 1, "must prompt only once for storage"); assert.equal(second.offered, false); - assert.equal(second.reason, "already-offered"); + // Storage was offered+marked, wallet has no remote copy -> no candidates remain. + assert.equal(second.reason, "no-remote"); +}); + +test("offers the WALLET later even after storage was already offered (per-item)", async () => { + // Regression: a wallet note published AFTER storage was already offered must + // still be surfaced. The old per-pubkey flag flagged the whole account on the + // first offer, so the wallet never restored "like storage does". + localStorage.clear(); + const offeredKinds = []; + const storageSync = makeSyncStub({ remote: true }); + // Wallet has no remote copy yet on the first login, then appears later. + let walletRemote = false; + const walletSync = { + pulls: 0, + isAvailable: () => true, + isEnabled: () => false, + hasRemote: async () => walletRemote, + pull: async function () { + this.pulls += 1; + return { found: true, imported: true }; + }, + }; + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync, + confirm: (message) => { + offeredKinds.push(message); + return true; + }, + }); + + const first = await prompt.maybeOffer(PUBKEY); // offers + restores storage only + assert.deepEqual(first.restored, ["storage"]); + assert.equal(walletSync.pulls, 0); + + // Wallet sync gets set up on another device -> a note now exists. + walletRemote = true; + const second = await prompt.maybeOffer(PUBKEY); + assert.equal(second.offered, true, "wallet must still be offered on a later login"); + assert.deepEqual(second.restored, ["wallet"]); + assert.equal(walletSync.pulls, 1, "wallet is pulled the first time it appears"); + assert.equal(storageSync.pulls, 1, "storage is not pulled again"); + + // And it is not offered a third time. + const third = await prompt.maybeOffer(PUBKEY); + assert.equal(third.offered, false); + assert.equal(third.reason, "no-remote"); }); test("skips items already enabled on this device", async () => { diff --git a/tests/trending-sorter.test.mjs b/tests/trending-sorter.test.mjs new file mode 100644 index 00000000..8e17383a --- /dev/null +++ b/tests/trending-sorter.test.mjs @@ -0,0 +1,70 @@ +// #27: the Trending feed ranks the active set by VIEW COUNT (recency breaks +// ties), with unknown/not-yet-loaded counts treated as 0 and trusted-muted last. +// The sorter reads counts from the injected runtime.getViewCount (the shared +// viewCounter cache). + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createTrendingSorter } from "../js/feedEngine/sorters.js"; + +const item = (id, { views, created_at = 1000, muted = false } = {}) => ({ + video: { + id, + pubkey: "a".repeat(64), + created_at, + ...(muted ? { moderation: { trustedMuted: true } } : {}), + }, + metadata: {}, + __views: views, +}); + +const sort = (items) => { + const counts = new Map(items.map((i) => [i.video.id, i.__views])); + return createTrendingSorter()(items, { + runtime: { getViewCount: (video) => counts.get(video?.id) }, + }).map((i) => i.video.id); +}; + +test("ranks by view count descending", () => { + assert.deepEqual( + sort([item("low", { views: 5 }), item("high", { views: 100 }), item("mid", { views: 50 })]), + ["high", "mid", "low"], + ); +}); + +test("recency breaks ties on equal views", () => { + assert.deepEqual( + sort([ + item("older", { views: 10, created_at: 1000 }), + item("newer", { views: 10, created_at: 2000 }), + ]), + ["newer", "older"], + ); +}); + +test("unknown counts are treated as 0 (rank below known views, recency among them)", () => { + const ids = sort([ + item("unknown-old", { views: undefined, created_at: 1000 }), + item("known", { views: 3, created_at: 500 }), + item("unknown-new", { views: undefined, created_at: 2000 }), + ]); + assert.equal(ids[0], "known", "a known-view video leads the not-yet-loaded ones"); + assert.deepEqual(ids.slice(1), ["unknown-new", "unknown-old"], "unknowns fall back to recency"); +}); + +test("trusted-muted sinks last even if most-viewed", () => { + assert.deepEqual( + sort([item("muted-popular", { views: 1000, muted: true }), item("plain", { views: 1 })]), + ["plain", "muted-popular"], + ); +}); + +test("tolerates non-array input and a missing getViewCount", () => { + assert.deepEqual(createTrendingSorter()(null, {}), []); + // No runtime.getViewCount → all 0 → recency order. + const out = createTrendingSorter()( + [item("a", { created_at: 1 }), item("b", { created_at: 2 })], + {}, + ).map((i) => i.video.id); + assert.deepEqual(out, ["b", "a"]); +}); diff --git a/tests/ui/dm-conversation-focus.test.mjs b/tests/ui/dm-conversation-focus.test.mjs new file mode 100644 index 00000000..c19d187d --- /dev/null +++ b/tests/ui/dm-conversation-focus.test.mjs @@ -0,0 +1,116 @@ +// Regression: clicking the "Message" button on the profile of someone you've +// never DMed sets the recipient, but buildDmConversationData used to discard it +// (no existing thread => not in conversationMap => fall back to conversations[0]) +// and silently focus the TOP conversation instead. Any message you then sent +// went to the wrong person ("it says sent but I don't see it"). +// +// Scenario (SCN-dm-focus-new-recipient): +// Given an inbox with one existing thread (Bob), +// And the user has explicitly selected a brand-new recipient (Carol) with no +// messages yet, +// When the DM conversation data is built, +// Then the active conversation is Carol's (not Bob's), Carol appears as a +// synthesized conversation, and the active remote pubkey is Carol — so the +// composer targets Carol. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ProfileDirectMessageHelper } from "../../js/ui/profileModal/ProfileDirectMessageHelper.js"; + +const ACTOR = "a".repeat(64); +const BOB = "b".repeat(64); +const CAROL = "c".repeat(64); + +function buildHelper({ storedRecipient }) { + const mainController = { + normalizeHexPubkey: (value) => + typeof value === "string" && value.trim() ? value.trim() : "", + getActivePubkey: () => ACTOR, + safeEncodeNpub: (hex) => `npub_${hex.slice(0, 6)}`, + formatShortNpub: (npub) => npub, + services: { + nostrClient: {}, + getProfileCacheEntry: () => null, + }, + state: { + getDmRecipient: () => storedRecipient, + getDmRelayHints: () => [], + getDmRelayPreferences: () => [], + }, + nostrService: { + getDirectMessageUnseenCount: () => 0, + }, + }; + const controller = { + activeDmConversationId: "", + directMessagesCache: [], + }; + const helper = new ProfileDirectMessageHelper(mainController, controller); + + // Keep the focus-selection logic real; stub only presentation helpers that + // would otherwise require a full profile cache. + helper.resolveProfileSummaryForPubkey = (pubkey) => ({ + displayName: `name_${pubkey.slice(0, 4)}`, + avatarSrc: "", + status: "", + lightningAddress: "", + }); + helper.resolveDirectMessagePreviewForConversation = () => "preview"; + helper.buildDmMessageTimeline = () => []; + + return helper; +} + +// One existing message thread with Bob. +const MESSAGES = [ + { + ok: true, + actorPubkey: ACTOR, + senderPubkey: BOB, + recipientPubkey: ACTOR, + remotePubkey: BOB, + created_at: 1000, + content: "hi from bob", + scheme: "nip04", + }, +]; + +test("focuses a freshly-selected recipient with no thread instead of the top conversation", async () => { + const helper = buildHelper({ storedRecipient: CAROL }); + + const data = await helper.buildDmConversationData(MESSAGES, { actorPubkey: ACTOR }); + + const carolId = helper.buildDmConversationId(ACTOR, CAROL); + const bobId = helper.buildDmConversationId(ACTOR, BOB); + + assert.equal( + data.activeConversationId, + carolId, + "active conversation must be the selected recipient (Carol), not the top thread (Bob)", + ); + assert.notEqual(data.activeConversationId, bobId); + assert.equal( + data.activeRemotePubkey, + CAROL, + "composer target (activeRemotePubkey) must be Carol", + ); + assert.ok( + data.conversations.some((c) => c.pubkey === CAROL), + "a synthesized conversation for Carol must be present in the list", + ); +}); + +test("still defaults to the top conversation when no recipient is selected", async () => { + const helper = buildHelper({ storedRecipient: null }); + + const data = await helper.buildDmConversationData(MESSAGES, { actorPubkey: ACTOR }); + + const bobId = helper.buildDmConversationId(ACTOR, BOB); + assert.equal( + data.activeConversationId, + bobId, + "with no explicit recipient, default to the most recent thread (Bob)", + ); + assert.equal(data.activeRemotePubkey, BOB); +}); diff --git a/tests/ui/dm-set-recipient-active.test.mjs b/tests/ui/dm-set-recipient-active.test.mjs new file mode 100644 index 00000000..ec57e550 --- /dev/null +++ b/tests/ui/dm-set-recipient-active.test.mjs @@ -0,0 +1,94 @@ +// Regression: clicking the "Message" button added the user to the sidebar but +// the right-hand message pane stayed on the previously-active conversation. +// setDirectMessageRecipient relied on setFocusedDmConversation (which only +// tracks read-state) and never updated activeDmConversationId — the value +// buildDmConversationData reads to decide which thread the right pane renders. +// +// Scenario (SCN-dm-select-makes-active): +// Given a controller whose activeDmConversationId points at an old thread, +// When a new recipient is selected via setDirectMessageRecipient, +// Then activeDmConversationId becomes the new recipient's conversation id +// (so the right pane switches), and the mobile pane shows the thread. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ProfileDirectMessageController } from "../../js/ui/profileModal/ProfileDirectMessageController.js"; + +// The method does `instanceof HTMLElement` guards; the stubbed DOM refs are null, +// so the constructor just needs the global to exist. +if (typeof globalThis.HTMLElement === "undefined") { + globalThis.HTMLElement = class HTMLElement {}; +} + +const ACTOR = "a".repeat(64); +const OLD = "b".repeat(64); +const NEW = "c".repeat(64); + +function makeFakeController() { + const focusCalls = []; + return { + activeDmConversationId: `dm:${[ACTOR, OLD].sort().join(":")}`, + dmMobileView: "list", + dmPrivacyToggleTouched: true, + directMessagesCache: [], + focusCalls, + mainController: { + normalizeHexPubkey: (v) => (typeof v === "string" && v.trim() ? v.trim() : ""), + state: { setDmRecipient: () => {} }, + }, + helper: { + resolveActiveDmActor: () => ACTOR, + buildDmConversationId: (actor, remote) => + `dm:${[actor, remote].sort().join(":")}`, + ensureDmRecipientData: () => {}, + }, + renderer: { + profileMessagesList: null, + dmAppShellContainer: null, + setMessagesAnnouncement: () => {}, + renderDirectMessageConversation: () => {}, + renderDmAppShell: () => {}, + }, + updateMessageThreadSelection() {}, + setFocusedDmConversation(id) { + focusCalls.push(id); + }, + }; +} + +test("selecting a recipient makes its conversation active in the right pane", () => { + const ctx = makeFakeController(); + const expectedId = `dm:${[ACTOR, NEW].sort().join(":")}`; + + const result = ProfileDirectMessageController.prototype.setDirectMessageRecipient.call( + ctx, + NEW, + { reason: "external" }, + ); + + assert.equal(result, NEW, "returns the normalized recipient"); + assert.equal( + ctx.activeDmConversationId, + expectedId, + "activeDmConversationId must move to the selected recipient (not stay on the old thread)", + ); + assert.equal(ctx.dmMobileView, "thread", "mobile pane switches to the thread"); + assert.deepEqual( + ctx.focusCalls, + [expectedId], + "still tracks read-state for the focused conversation", + ); +}); + +test("the auto thread-default does not force the mobile thread view", () => { + const ctx = makeFakeController(); + ProfileDirectMessageController.prototype.setDirectMessageRecipient.call(ctx, NEW, { + reason: "thread-default", + }); + assert.equal( + ctx.dmMobileView, + "list", + "thread-default keeps the inbox list visible on mobile", + ); +}); diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs index bbad1da7..7b8a1c5e 100644 --- a/tests/ui/popoverEngine.test.mjs +++ b/tests/ui/popoverEngine.test.mjs @@ -762,3 +762,143 @@ test("applies token-based sizing and arrow positioning", async () => { popover.destroy(); }); + +test("relocates a pre-existing in-host panel into the overlay portal on open and restores it on close", async () => { + // Regression for pre-launch TODO #3a: the in-modal zap/embed dialog is a + // pre-existing element returned verbatim by its render fn. It was never moved + // into the body-level portal, so its `position: fixed` resolved against a + // transformed/contained modal ancestor and the panel anchored to the modal edge + // instead of bottom-end under its trigger. The engine must relocate such panels + // into the portal on open (viewport-relative positioning) and restore them on + // close so the host DOM stays intact and re-open still works. + const trigger = documentRef.getElementById("trigger"); + setupBoundingClientRect(trigger, { + x: 100, + y: 100, + top: 100, + bottom: 140, + left: 100, + right: 140, + width: 40, + height: 40, + }); + + const modal = documentRef.createElement("div"); + modal.id = "host-modal"; + const preExistingPanel = documentRef.createElement("div"); + preExistingPanel.id = "zap-like-panel"; + setupBoundingClientRect(preExistingPanel, { + x: 0, + y: 0, + top: 0, + bottom: 120, + left: 0, + right: 200, + width: 200, + height: 120, + }); + const sibling = documentRef.createElement("span"); + modal.appendChild(preExistingPanel); + modal.appendChild(sibling); // gives the panel a nextSibling to restore before + documentRef.body.appendChild(modal); + + // render returns the pre-existing panel verbatim (does NOT append to container). + const popover = createPopover(trigger, () => preExistingPanel, { + document: documentRef, + }); + + assert.equal( + preExistingPanel.parentNode, + modal, + "panel starts in the host modal", + ); + + await popover.open(); + + const overlayRoot = documentRef.getElementById("uiOverlay"); + const portal = overlayRoot.querySelector('[data-popover-portal="true"]'); + assert.ok(portal, "portal should exist under the overlay root"); + assert.equal( + preExistingPanel.parentNode, + portal, + "panel should be relocated into the body-level portal while open", + ); + + popover.close({ restoreFocus: false }); + + assert.equal( + preExistingPanel.parentNode, + modal, + "panel should be restored to the host modal on close", + ); + assert.equal( + preExistingPanel.nextSibling, + sibling, + "panel should be restored to its original position (before its sibling)", + ); + + await popover.open(); + assert.equal( + preExistingPanel.parentNode, + portal, + "panel should relocate again on re-open (element not lost)", + ); + + popover.destroy(); + assert.equal( + preExistingPanel.parentNode, + modal, + "destroy should leave the app-owned panel back in its host, not remove it", + ); +}); + +test("typing in a form field inside a panel is not hijacked by menu typeahead", async () => { + // Regression for pre-launch #3a/#3: the engine treats panels as ARIA menus and + // preventDefault'd single-character + arrow keys. For a panel with inputs (the + // zap comment/amount fields) that swallowed every keystroke -> "can't type in + // the popup". Keys originating from an editable control must pass through. + const trigger = documentRef.getElementById("trigger"); + setupBoundingClientRect(trigger, { + x: 100, y: 100, top: 100, bottom: 140, left: 100, right: 140, width: 40, height: 40, + }); + + let panelRef; + let inputRef; + const popover = createPopover( + trigger, + ({ container }) => { + const panel = documentRef.createElement("div"); + panel.className = "popover__panel"; + setupBoundingClientRect(panel, { + x: 0, y: 0, top: 0, bottom: 120, left: 0, right: 200, width: 200, height: 120, + }); + const input = documentRef.createElement("textarea"); + panel.appendChild(input); + container.appendChild(panel); + panelRef = panel; + inputRef = input; + return panel; + }, + { document: documentRef }, + ); + + await popover.open(); + + // A printable character typed in the textarea must NOT be preventDefault'd. + const charEvent = new windowRef.KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true }); + inputRef.dispatchEvent(charEvent); + assert.equal(charEvent.defaultPrevented, false, "character keys must reach the input"); + + // Caret movement (ArrowLeft) inside the textarea must also pass through. + const arrowEvent = new windowRef.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }); + inputRef.dispatchEvent(arrowEvent); + assert.equal(arrowEvent.defaultPrevented, false, "caret keys must reach the input"); + + // Sanity: a non-editable target in the same panel is still handled as a menu + // (printable key is consumed for typeahead). + const menuKey = new windowRef.KeyboardEvent("keydown", { key: "x", bubbles: true, cancelable: true }); + panelRef.dispatchEvent(menuKey); + assert.equal(menuKey.defaultPrevented, true, "menu typeahead still applies to non-editable targets"); + + popover.destroy(); +}); diff --git a/tests/user-blocks-relay-cap.test.mjs b/tests/user-blocks-relay-cap.test.mjs new file mode 100644 index 00000000..1bd001eb --- /dev/null +++ b/tests/user-blocks-relay-cap.test.mjs @@ -0,0 +1,65 @@ +// Regression: the live block-list subscription (ensureBlockListSubscription) used +// the user's full NIP-65 relay set, a primary contributor to the cold-login REQ +// storm. It must be bounded to the §17 read cap (capReadRelays, ≤8). +// +// Scenario (SCN-blocklist-subscription-relay-cap): +// Given a user with 20 read relays, +// When the live block-list subscription is (re)established, +// Then it subscribes over at most the §17-capped relay set, not all 20. + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; + +const { userBlocks } = await import("../js/userBlocks.js"); +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { capReadRelays, MAX_SUBSCRIBE_RELAYS } = await import( + "../js/nostr/toolkit.js" +); + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +test("ensureBlockListSubscription caps the live subscription relay set", () => { + const actor = "f".repeat(64); + const manyRelays = Array.from( + { length: 20 }, + (_, i) => `wss://relay-${i}.example`, + ); + + const originalPool = nostrClient.pool; + const originalGetManager = nostrClient.getSubscriptionManager; + + let captured = null; + try { + nostrClient.pool = { close() {} }; + nostrClient.getSubscriptionManager = () => ({ + subscribe: (args) => { + captured = args; + return { close() {} }; + }, + }); + + userBlocks.ensureBlockListSubscription(actor, manyRelays); + + assert.ok(captured, "subscription manager should have been invoked"); + const expected = capReadRelays(manyRelays); + assert.deepEqual( + captured.relays, + expected, + "live subscription must use the §17-capped relay set", + ); + assert.ok( + captured.relays.length <= MAX_SUBSCRIBE_RELAYS, + `relay set must be capped to ${MAX_SUBSCRIBE_RELAYS}, got ${captured.relays.length}`, + ); + assert.ok( + captured.relays.length < manyRelays.length, + "must not fan out across the full 20-relay NIP-65 set", + ); + } finally { + nostrClient.pool = originalPool; + nostrClient.getSubscriptionManager = originalGetManager; + } +}); diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs index c529d0fd..b1cd121e 100644 --- a/tests/video-modal-zap.test.mjs +++ b/tests/video-modal-zap.test.mjs @@ -773,7 +773,10 @@ await (async () => { })(); await (async () => { - // Test: validation failure surfaces warning and resets form + // Test: a PAID share whose 9735 receipt couldn't be validated is reported as a + // SUCCESS (with a soft "couldn't confirm the receipt" note), NOT an error. The + // payment already returned a preimage — proof the invoice was paid — so the + // missing on-relay receipt must not be surfaced as a failure. const validationReceipts = [ { recipientType: "creator", @@ -824,27 +827,39 @@ await (async () => { ); const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1]; - assert.equal(lastStatus.tone, "warning", "validation failure should emit warning status"); + assert.equal( + lastStatus.tone, + "success", + "a paid-but-unvalidated receipt is a success, not a warning", + ); assert.match( lastStatus.message, - /awaiting validated zap receipt/i, - "status message should highlight missing validated receipt", + /sent 750 sats/i, + "status should confirm the payment amount", + ); + assert.match( + lastStatus.message, + /couldn't confirm the zap receipt/i, + "status should note the receipt couldn't be confirmed (soft, non-failure)", ); assert.equal( modalStub.resetForms.length, initialResetCount + 1, - "zap form should reset after validation warning", + "zap form should reset after a successful send", ); + // A completing send records TWO completion-state changes: a reset to false at + // the start of sendZap, then true at the terminal success path. The meaningful + // invariant is the end state == true, asserted next. assert.equal( modalStub.completedStates.length, - initialCompleted + 1, - "completion state should record the validation attempt", + initialCompleted + 2, + "completion state should reset at start then mark the send completed", ); assert.equal( modalStub.completedStates[modalStub.completedStates.length - 1], true, - "validation warning should mark zap attempt as completed", + "a successful (receipt-unconfirmed) send should be marked completed", ); app.destroy(); @@ -932,7 +947,10 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); (() => { const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop }); - modal.modalZapReceipts = new DummyElement(); + // renderZapReceipts delegates to the inner zapController, which renders into ITS + // own modalZapReceipts element (the VideoModal -> sub-controller refactor); set + // and assert on the controller's element, not the outer modal's. + const receiptsEl = (modal.zapController.modalZapReceipts = new DummyElement()); const receipts = [ { @@ -949,15 +967,15 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); modal.renderZapReceipts(receipts, { partial: false }); - assert.equal(modal.modalZapReceipts.children.length, 1, "validated receipt should render one entry"); - const entry = modal.modalZapReceipts.children[0]; + assert.equal(receiptsEl.children.length, 1, "validated receipt should render one entry"); + const entry = receiptsEl.children[0]; const header = entry.children[0]; assert.equal(header.children[1].textContent, "Validated", "validated receipt should flag status"); })(); (() => { const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop }); - modal.modalZapReceipts = new DummyElement(); + const receiptsEl = (modal.zapController.modalZapReceipts = new DummyElement()); const receipts = [ { @@ -975,11 +993,11 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); modal.renderZapReceipts(receipts, { partial: true }); assert.equal( - modal.modalZapReceipts.children.length, + receiptsEl.children.length, 1, "validation failure should render a single warning entry", ); - const warning = modal.modalZapReceipts.children[0]; + const warning = receiptsEl.children[0]; assert.match( warning.textContent, /no validated zap receipts yet/i, @@ -987,4 +1005,41 @@ const { VideoModal } = await import("../js/ui/components/VideoModal.js"); ); })(); +(() => { + // Regression (#18): the Embed button dispatched "action:embed", but ModalManager + // listens for "video:embed" (which opens EmbedVideoModal) — so embed did nothing. + // handleEmbedRequest must dispatch the name the manager actually listens for. + const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop }); + modal.activeVideo = { id: "vid-embed", pubkey: "p".repeat(64) }; + + let embedDetail = null; + modal.addEventListener("video:embed", (event) => { + embedDetail = event?.detail || null; + }); + let staleFired = false; + modal.addEventListener("action:embed", () => { + staleFired = true; + }); + + modal.handleEmbedRequest(); + + assert.ok(embedDetail, 'embed must dispatch "video:embed" (the listened name)'); + assert.equal(embedDetail.video?.id, "vid-embed", "carries the active video"); + assert.equal(staleFired, false, 'must not use the dead "action:embed" name'); + + // Same bug class on copy-magnet: manager listens for "video:copy-magnet". + let copyDetail = null; + modal.addEventListener("video:copy-magnet", (event) => { + copyDetail = event?.detail || null; + }); + let staleCopy = false; + modal.addEventListener("action:copy", () => { + staleCopy = true; + }); + modal.handleCopyRequest(); + assert.ok(copyDetail, 'copy must dispatch "video:copy-magnet" (the listened name)'); + assert.equal(copyDetail.video?.id, "vid-embed", "carries the active video"); + assert.equal(staleCopy, false, 'must not use the dead "action:copy" name'); +})(); + process.exit(0); diff --git a/tests/view-count-chart.test.mjs b/tests/view-count-chart.test.mjs new file mode 100644 index 00000000..9e01f400 --- /dev/null +++ b/tests/view-count-chart.test.mjs @@ -0,0 +1,87 @@ +// #26: the popularity chart buckets view events into a cumulative time series, +// deduped by (viewer, day) like the counter, and renders a token-colored SVG. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { + buildViewCountTimeSeries, + buildViewCountChartSvg, + VIEW_CHART_WINDOW_SECONDS, +} from "../js/viewCountChart.js"; + +const W = VIEW_CHART_WINDOW_SECONDS; +const ev = (id, pubkey, day, secInDay = 10) => ({ + id, + pubkey, + created_at: day * W + secInDay, +}); + +test("cumulative series: one view per (viewer, day), summed over time", () => { + const events = [ + ev("1", "alice", 100), + ev("2", "alice", 100, 50), // same viewer+day → deduped + ev("3", "bob", 100), + ev("4", "alice", 101), // alice again next day → counts + ev("5", "carol", 102), + ]; + const { series, total } = buildViewCountTimeSeries(events); + + assert.equal(total, 4, "alice(d100)+bob(d100)+alice(d101)+carol(d102)"); + assert.deepEqual( + series.map((s) => [s.bucketStart / W, s.count, s.cumulative]), + [ + [100, 2, 2], + [101, 1, 3], + [102, 1, 4], + ], + ); +}); + +test("ignores events without a valid pubkey or created_at", () => { + const events = [ + ev("1", "alice", 100), + { id: "2", created_at: 100 * W }, // no pubkey + { id: "3", pubkey: "bob" }, // no created_at + { id: "4", pubkey: "carol", created_at: -5 }, + ]; + const { total } = buildViewCountTimeSeries(events); + assert.equal(total, 1); +}); + +test("empty input yields an empty series", () => { + assert.deepEqual(buildViewCountTimeSeries([]), { series: [], total: 0 }); + assert.deepEqual(buildViewCountTimeSeries(null), { series: [], total: 0 }); +}); + +test("SVG renders a line+area for a multi-point series, token-colored", () => { + const dom = new JSDOM(""); + const doc = dom.window.document; + const { series } = buildViewCountTimeSeries([ + ev("1", "a", 100), + ev("2", "b", 101), + ev("3", "c", 102), + ]); + const svg = buildViewCountChartSvg(doc, series, { width: 300, height: 100 }); + + assert.equal(svg.tagName.toLowerCase(), "svg"); + assert.ok(svg.classList.contains("text-accent"), "uses the accent token via currentColor"); + assert.ok(svg.querySelector("polyline"), "has the cumulative line"); + assert.ok(svg.querySelector("polygon"), "has the area fill"); + assert.equal(svg.querySelector("polyline").getAttribute("stroke"), "currentColor"); +}); + +test("SVG handles empty + single-point series without throwing", () => { + const dom = new JSDOM(""); + const doc = dom.window.document; + + const empty = buildViewCountChartSvg(doc, [], { width: 300, height: 100 }); + assert.equal(empty.querySelector("polyline"), null, "no line when there are no views"); + + const single = buildViewCountChartSvg( + doc, + buildViewCountTimeSeries([ev("1", "a", 100)]).series, + { width: 300, height: 100 }, + ); + assert.ok(single.querySelector("polyline"), "single point draws a flat baseline"); +}); diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 53830ead..9b70fded 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -325,6 +325,12 @@ const harness = createMockNostrHarness(); nostrClient.listVideoViewEvents = harness.listVideoViewEvents; nostrClient.countVideoViewEvents = harness.countVideoViewEvents; nostrClient.subscribeVideoViewEvents = harness.subscribeVideoViewEvents; +// Exercise the legacy per-pointer hydrate/subscribe path — the one viewCounter.js +// explicitly designates "for tests / mocks" and the one this harness mocks. In +// production the singleton client has a SubscriptionManager (the batched path); +// without pinning it to null here the real manager silently takes over and the +// mocked legacy APIs never fire. See TEST_INTEGRITY.md (SCN-view-counter-legacy-path). +nostrClient.getSubscriptionManager = () => null; const { initViewCounter, @@ -417,7 +423,13 @@ async function testDedupesWithinWindow() { const pointer = { type: "e", value: "view-counter-dedupe" }; const pointerKey = harness.pointerKeyFromInput(pointer); - const base = Math.floor(Date.now() / 1000); + // Align to a window-bucket boundary so both events are deterministically in the + // SAME fixed bucket (dedupe is floor(created_at / window), matching the + // replaceable view-event d-tag). With an unaligned base the +window/2 offset + // could straddle a boundary and land in different buckets (flaky). + const base = + Math.floor(Math.floor(Date.now() / 1000) / VIEW_COUNT_DEDUPE_WINDOW_SECONDS) * + VIEW_COUNT_DEDUPE_WINDOW_SECONDS; const events = [ { id: "evt-1", pubkey: "pub-dedupe", created_at: base }, { @@ -563,6 +575,66 @@ async function testRelayCountAggregationUsesBestEstimate() { } } +// The exact path (options.exact, watch page / popularity chart) must count the +// cross-relay UNION of view events deduped by (viewer, window) — NOT the per-relay +// NIP-45 COUNT, which returns the single relay with the most events and therefore +// under-counts when views are spread across relays. +async function testExactCountUnionsAcrossRelays() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const previousGetManager = nostrClient.getSubscriptionManager; + // exactCountForPointer only runs when a SubscriptionManager exists. + nostrClient.getSubscriptionManager = () => ({ + list: async () => [], + subscribe: () => ({ close() {} }), + handleReconnect() {}, + }); + + try { + const pointer = { type: "e", value: "view-counter-exact-union" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const dayStart = + Math.floor(Math.floor(Date.now() / 1000) / 86400) * 86400; + // alice has two views in the same day (dedupe to 1) → 3 distinct (viewer,day): + // alice@d0, bob@d0, alice@d1. + harness.setEvents(pointerKey, [ + { id: "e1", pubkey: "alice", created_at: dayStart + 10 }, + { id: "e2", pubkey: "alice", created_at: dayStart + 20 }, + { id: "e3", pubkey: "bob", created_at: dayStart + 30 }, + { id: "e4", pubkey: "alice", created_at: dayStart - 86400 + 10 }, + ]); + // A single relay's COUNT only saw 1 — the union must win. + harness.setCountTotal(pointerKey, { + total: 1, + best: { relay: "wss://relay.one", count: 1 }, + }); + + const updates = []; + const token = subscribeToVideoViewCount( + pointer, + (state) => updates.push({ ...state }), + { exact: true }, + ); + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected an exact-count update"); + assert.equal( + final.total, + 3, + "exact count = cross-relay union deduped by (viewer,day), not the per-relay COUNT (1)", + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } + } finally { + nostrClient.getSubscriptionManager = previousGetManager; + } +} + async function testLocalIngestNotifiesImmediately() { localStorage.clear(); harness.reset(); @@ -1014,16 +1086,27 @@ async function testHydrateHistoryPrefersRootEvent() { rootEvent.created_at, "root event should preserve its original created_at timestamp" ); - assert.equal( - latestVideo.rootCreatedAt, - rootEvent.created_at, - "latest revision should inherit the root created_at timestamp" - ); + // hydrateVideoHistory's contract is to populate the per-root created_at MAP + // (the earliest revision wins). Assert that directly. assert.equal( nostrClient.rootCreatedAtByRoot.get(rootId), rootEvent.created_at, "nostrClient should cache the earliest created_at per root" ); + // The active video's rootCreatedAt FIELD is applied by a separate step + // (syncActiveVideoRootTimestamp) — the unit boundary moved in a refactor, so + // drive the real pipeline rather than expecting hydrateVideoHistory to mutate + // the passed object. See TEST_INTEGRITY.md (SCN-hydrate-root-timestamp). + syncActiveVideoRootTimestamp({ + activeVideo: latestVideo, + rootId, + timestamp: nostrClient.rootCreatedAtByRoot.get(rootId), + }); + assert.equal( + latestVideo.rootCreatedAt, + rootEvent.created_at, + "latest revision inherits the root created_at after the timestamp sync step" + ); } finally { nostrClient.pool = originalPool; nostrClient.relays = originalRelays; @@ -1113,6 +1196,7 @@ await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); +await testExactCountUnionsAcrossRelays(); await testRecordVideoViewEmitsJsonPayload(); await testSignAndPublishFallbackUsesSessionActor(); await testHydrateHistoryPrefersRootEvent(); diff --git a/tests/view-event-dedupe.test.mjs b/tests/view-event-dedupe.test.mjs new file mode 100644 index 00000000..1b5c0acd --- /dev/null +++ b/tests/view-event-dedupe.test.mjs @@ -0,0 +1,74 @@ +// Item #4 (view-count accuracy): a view event's `d` tag must be DETERMINISTIC and +// window-bucketed so kind-30079 view events are parameterized-replaceable — +// relays then collapse a viewer's repeat views in the same window into one event, +// making both the NIP-45 COUNT and a list() accurate and scalable. The old +// entropy+timestamp tag made every view unique and inflated counts. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildViewEvent } from "../js/nostrEventSchemas.js"; +import { __testExports } from "../js/nostr/viewEvents.js"; +import { VIEW_COUNT_DEDUPE_WINDOW_SECONDS } from "../js/config.js"; + +const { generateViewEventDedupeTag } = __testExports; + +const VIEWER = "f".repeat(64); +const OTHER = "0".repeat(64); +const VIDEO = { type: "e", value: "video-abc" }; +const VIDEO2 = { type: "e", value: "video-xyz" }; +const W = VIEW_COUNT_DEDUPE_WINDOW_SECONDS; +// A timestamp comfortably inside one window bucket, and another in the same one. +const T0 = 100 * W + 10; +const T0_SAME = 100 * W + (W - 1); // same bucket as T0 +const T_NEXT = 101 * W + 5; // next bucket + +test("same viewer + video within one window → identical d-tag (replaceable)", () => { + const a = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + const b = generateViewEventDedupeTag(VIEWER, VIDEO, T0_SAME); + assert.equal(a, b, "repeat views in the same window must dedupe to one event"); +}); + +test("d-tag has no entropy: stable across calls", () => { + const a = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + const b = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + assert.equal(a, b); +}); + +test("next window → different d-tag (counts as a new view)", () => { + const a = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + const c = generateViewEventDedupeTag(VIEWER, VIDEO, T_NEXT); + assert.notEqual(a, c); +}); + +test("different viewer or different video → different d-tag", () => { + const base = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + assert.notEqual(base, generateViewEventDedupeTag(OTHER, VIDEO, T0)); + assert.notEqual(base, generateViewEventDedupeTag(VIEWER, VIDEO2, T0)); +}); + +test("buildViewEvent does NOT invent a random d-tag when none is supplied", () => { + const event = buildViewEvent({ + pubkey: VIEWER, + created_at: T0, + pointerValue: VIDEO.value, + pointerTag: ["e", VIDEO.value], + }); + const dTags = event.tags.filter((t) => Array.isArray(t) && t[0] === "d"); + assert.equal(dTags.length, 0, "no d tag should be auto-generated (no entropy)"); +}); + +test("buildViewEvent uses the exact deterministic d-tag it is given", () => { + const dedupeTag = generateViewEventDedupeTag(VIEWER, VIDEO, T0); + const event = buildViewEvent({ + pubkey: VIEWER, + created_at: T0, + pointerValue: VIDEO.value, + pointerTag: ["e", VIDEO.value], + dedupeTag, + }); + assert.ok( + event.tags.some((t) => Array.isArray(t) && t[0] === "d" && t[1] === dedupeTag), + "the supplied deterministic d-tag must be on the event", + ); +}); diff --git a/tests/watch-history-bucketing.test.mjs b/tests/watch-history-bucketing.test.mjs new file mode 100644 index 00000000..9ec08b2e --- /dev/null +++ b/tests/watch-history-bucketing.test.mjs @@ -0,0 +1,70 @@ +// Watch-history month bucketing: legacy/migrated items with no watch time +// (watchedAt 0) used to silo into a literal "1970-01" month that self-perpetuated +// (read back as 0 -> re-bucketed to 1970-01 -> re-published with 0). bitvid +// pointers embed the video's creation time in their d-tag; derive a stable +// fallback from it so those items land in a real month and the value is backfilled. +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { canonicalizeWatchHistoryItems } from "../js/nostr/watchHistory.js"; +import { deriveWatchedAtFromPointer } from "../js/nostr/watchHistoryBucketing.js"; + +const PK = "a".repeat(64); + +test("deriveWatchedAtFromPointer: extracts the ms timestamp from a timestamp-first d-tag", () => { + // 1761508697162 ms = 2025-10-26 ~ in seconds 1761508697 + const seconds = deriveWatchedAtFromPointer({ + value: `30078:${PK}:1761508697162-z7ax37o3nqm`, + }); + assert.equal(seconds, 1761508697); +}); + +test("deriveWatchedAtFromPointer: extracts the ms timestamp from a timestamp-last d-tag", () => { + const seconds = deriveWatchedAtFromPointer({ + value: `30078:${PK}:goblinbox-1782226234472`, + }); + assert.equal(seconds, 1782226234); +}); + +test("deriveWatchedAtFromPointer: returns 0 for a hash-only d-tag (no plausible timestamp)", () => { + const seconds = deriveWatchedAtFromPointer({ + value: `30078:${PK}:fd11d796f68d6cb02ae46bcdf0a1ee2f032dea747652d3e96fd13338c845a9d5`, + }); + assert.equal(seconds, 0); +}); + +test("canonicalize: a watchedAt:0 item buckets into a REAL month (not 1970-01) and backfills", () => { + const item = { + type: "a", + value: `30078:${PK}:1761508697162-z7ax37o3nqm`, + watchedAt: 0, + }; + const buckets = canonicalizeWatchHistoryItems([item]); + const months = Object.keys(buckets); + assert.ok(!months.includes("1970-01"), "must not silo a decodable item into 1970-01"); + assert.deepEqual(months, ["2025-10"], "item buckets by its derived creation month"); + // Backfilled so the published watchedAt map carries a real value (stops the loop). + assert.equal(buckets["2025-10"][0].watchedAt, 1761508697); +}); + +test("canonicalize: a real watchedAt is unaffected", () => { + const item = { + type: "a", + value: `30078:${PK}:goblinbox-1782226234472`, + watchedAt: 1782237450, // a genuine, later watch time + }; + const buckets = canonicalizeWatchHistoryItems([item]); + // 1782237450 s = 2026-06; uses the real watch time, not the derived creation time. + assert.deepEqual(Object.keys(buckets), ["2026-06"]); + assert.equal(buckets["2026-06"][0].watchedAt, 1782237450); +}); + +test("canonicalize: a truly undecodable timestamp-less item still buckets stably (1970-01)", () => { + const item = { + type: "a", + value: `30078:${PK}:fd11d796f68d6cb02ae46bcdf0a1ee2f032dea747652d3e96fd13338c845a9d5`, + watchedAt: 0, + }; + const buckets = canonicalizeWatchHistoryItems([item]); + assert.deepEqual(Object.keys(buckets), ["1970-01"]); +}); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 530d6cc4..211fa806 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1183,142 +1183,98 @@ async function testWatchHistoryPartialRelayRetry() { "write relays should include all configured endpoints", ); - const attemptPlans = [ - { - "wss://relay.one": true, - "wss://relay.two": true, - "wss://relay.three": false, - }, - { - "wss://relay.one": true, - "wss://relay.two": false, - "wss://relay.three": true, - }, - { - "wss://relay.one": true, - "wss://relay.two": true, - "wss://relay.three": true, - }, - ]; + // Partial acceptance: 2 of 3 write relays accept, one rejects — on a single + // attempt (no escalation). The relay set is stable across calls. + const acceptPlan = { + "wss://relay.one": true, + "wss://relay.two": true, + "wss://relay.three": false, + }; poolHarness.setResolver(({ relays }) => { const relayUrl = Array.isArray(relays) && relays.length ? relays[0] : ""; - const index = Math.min( - Math.max(currentAttempt, 1) - 1, - attemptPlans.length - 1, - ); - const plan = attemptPlans[index] || {}; - const accept = plan?.[relayUrl]; - if (accept) { - return { ok: true }; - } - return { ok: false, error: new Error(`reject-${relayUrl || "unknown"}`) }; + return acceptPlan[relayUrl] + ? { ok: true } + : { ok: false, error: new Error(`reject-${relayUrl || "unknown"}`) }; }); - const scheduledRuns = []; - nostrClient.scheduleWatchHistoryRepublish = (snapshotId, operation) => { - const promise = (async () => { - let attempt = 1; - let result = null; - for (; attempt <= attemptPlans.length + 1; attempt += 1) { - result = await operation(attempt); - if (result?.ok || !result?.retryable) { - break; - } - } - return result; - })(); - scheduledRuns.push({ snapshotId, promise }); + let republishScheduled = false; + nostrClient.scheduleWatchHistoryRepublish = () => { + republishScheduled = true; return { attempt: 1, delay: 0 }; }; + // Spec correction (565a9618 / pre-launch TODO #2): a watch-history snapshot is + // durable as soon as ANY write relay accepts it (reads take the newest event + // per d-tag). Partial acceptance must therefore SUCCEED — not throw, not mark + // the result retryable, not schedule a republish, and not leave the pointer + // queued. Previously this asserted the opposite (throw + retry until EVERY + // relay accepts), which was exactly the all-relays false-failure bug. let thrownError = null; + let result = null; try { - await watchHistoryService.snapshot(null, { actor, reason: "partial-test" }); + result = await watchHistoryService.snapshot(null, { + actor, + reason: "partial-test", + }); } catch (error) { thrownError = error; } - assert(thrownError, "snapshot should throw when partial acceptance occurs"); - // In new bucket logic, publishRecords calls publishMonthRecord for each month. - // If one fails, it returns retryable=true. - // The error might not be 'partial-relay-acceptance' if it's a mix of results. - // But let's check what we get. The thrownError.result is the result object. - assert.equal( - thrownError?.result?.retryable, - true, - "partial failures should be marked retryable", + thrownError, + null, + "partial acceptance (>=1 relay) must NOT throw — the snapshot is durable", ); - // Error code might vary or be absent in composite result if not explicitly set. - // In WatchHistoryManager.publishRecords, we don't set a global error code if some succeed and some fail. - // But we set partial=true if any call had partial success? - // Actually publishRecords aggregates results. - - // Let's relax the check for specific error string if it's undefined, but ensure retryable is true. - if (thrownError?.result?.error) { - assert.equal( - thrownError?.result?.error, - "partial-relay-acceptance", - "partial failures should expose the partial acceptance error code", - ); - } - // assert(thrownError?.result?.partial, "result should report partial acceptance"); - // publishRecords doesn't return 'partial' property at top level in current implementation, check results. - - // In publishRecords, results is an array of results for each month. - // We need to look into `thrownError.result.results`. - const results = thrownError?.result?.results || []; - const firstMonthResult = results[0]; - const initialPointerStatus = - firstMonthResult?.publishResults?.relayStatus?.pointer || []; - - assert( - initialPointerStatus.some((entry) => entry && entry.success === false), - "initial relay status should capture pointer rejections", + assert(result?.ok, "snapshot should report success on partial acceptance"); + assert.equal( + result?.retryable, + false, + "a successful partial snapshot is not retryable", + ); + assert.equal( + republishScheduled, + false, + "no republish should be scheduled when the snapshot already succeeded", ); - assert( attemptIndex >= 1, - "initial snapshot attempt should increment the attempt counter", - ); - assert.equal( - scheduledRuns.length, - 1, - "partial failure should schedule a republish operation", + "snapshot should issue exactly the publish attempt (counter incremented)", ); - const finalResult = await scheduledRuns[0].promise; - assert(finalResult?.ok, "republish attempts should converge to success"); - assert( - attemptIndex >= 3, - "republish loop should retry until every relay accepts", - ); - // Again, partial is not on top level. Check results. - const finalResults = finalResult?.results || []; - const finalFirstMonth = finalResults[0]; - + const monthResult = (result?.results || [])[0]; + assert(monthResult, "snapshot result should include the month record result"); assert.equal( - finalFirstMonth?.partial, - false, - "final result should not mark the publish as partial", + monthResult.partial, + true, + "the month result should be flagged partial (one relay rejected)", ); - const finalPointerStatus = - finalFirstMonth?.publishResults?.relayStatus?.pointer || []; - assert.equal( - finalPointerStatus.filter((entry) => entry?.success).length, - relaySet.length, - "final pointer publish should succeed on all relays", + const pointerStatus = + monthResult?.publishResults?.relayStatus?.pointer || []; + const acceptedRelays = pointerStatus + .filter((entry) => entry?.success) + .map((entry) => entry.url) + .sort(); + const rejectedRelays = pointerStatus + .filter((entry) => entry && entry.success === false) + .map((entry) => entry.url); + assert.deepEqual( + acceptedRelays, + ["wss://relay.one", "wss://relay.two"], + "the accepting relays should be captured in the pointer relay status", + ); + assert.deepEqual( + rejectedRelays, + ["wss://relay.three"], + "the rejecting relay should be captured in the pointer relay status", ); - // Chunk status check is legacy, removed or adapted? - // Monthly records don't have separate chunks (except if we split months which we don't do anymore). const remainingQueue = watchHistoryService.getQueuedPointers(actor); assert.equal( remainingQueue.length, 0, - "queue should remain empty after successful retries", + "queue should be emptied — the pointer was durably published", ); } finally { nostrClient.scheduleWatchHistoryRepublish = originalSchedule; @@ -1540,7 +1496,12 @@ async function testWatchHistoryServiceIntegration() { const originalWatchHistoryCacheTtl = nostrClient.watchHistoryCacheTtlMs; try { - nostrClient.pubkey = "npub-logged"; + // Watch history files under the LOGGED-IN pubkey (publishView prefers + // nostrClient.pubkey over the session actor), so the logged-in user and the + // actor whose history we query must be the same — matching the convention used + // by every other test here. (Was "npub-logged" — never asserted — which keyed + // the queue away from the `actor` the assertions query.) + nostrClient.pubkey = actor; nostrClient.sessionActor = { pubkey: actor, privateKey: "service-priv", source: "nsec" }; nostrClient.ensureSessionActor = async () => actor; nostrClient.watchHistoryLastCreatedAt = 0; @@ -1629,10 +1590,14 @@ async function testWatchHistoryServiceIntegration() { // As per new requirement, history only has IDs. Video metadata must be hydrated separately if needed. assert.equal(resolvedVideo, null, "decrypted history should NOT include pointer video (IDs only)"); assert(resolvedItems[0].watchedAt >= resolvedItems[1].watchedAt); - assert.equal( + // A logged-in user's watch is NOT a local-only "session" entry. The session + // flag is set only when nobody is logged in (publishView); previously logged-in + // watches were wrongly flagged session=true by comparing against the view + // actor. This actor is logged in, so the flag must round-trip as falsy. + assert.notEqual( resolvedItems[0].session, true, - "session flag should persist through publish and load", + "a logged-in user's watch must not be flagged session (local-only)", ); const afterFingerprint = await watchHistoryService.getFingerprint(actor); diff --git a/tests/zap-receipt-pool.test.mjs b/tests/zap-receipt-pool.test.mjs index 83444bd4..abc59293 100644 --- a/tests/zap-receipt-pool.test.mjs +++ b/tests/zap-receipt-pool.test.mjs @@ -7,7 +7,10 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import test from "node:test"; -import { resolveReceiptListPool } from "../js/payments/zapReceiptValidator.js"; +import { + resolveReceiptListPool, + buildReceiptFilters, +} from "../js/payments/zapReceiptValidator.js"; // Mimics a nostr-tools 2.x SimplePool: instances have subscribeMany/close but NO // .list() (the method was removed in 2.x). @@ -59,3 +62,42 @@ test("returns null when there is no SimplePool and no override", () => { const pool = resolveReceiptListPool({}, {}); assert.equal(pool, null); }); + +// Receipt discovery: a previous filter queried ONLY by "#bolt11", which most +// relays don't index, so the receipt was never found and a successful zap looked +// unconfirmed. buildReceiptFilters must query by the reliably-indexed #e/#a/#p. +test("buildReceiptFilters: prefers #e (event) + #p (recipient), not #bolt11", () => { + const zapRequest = { + tags: [ + ["p", "a".repeat(64)], + ["e", "c".repeat(64)], + ["amount", "900000"], + ["relays", "wss://relay.example"], + ], + }; + const [filter] = buildReceiptFilters(zapRequest, "lnbc-invoice"); + assert.deepEqual(filter.kinds, [9735]); + assert.deepEqual(filter["#e"], ["c".repeat(64)]); + assert.deepEqual(filter["#p"], ["a".repeat(64)]); + assert.ok(!filter["#bolt11"], "must not depend on the unindexed #bolt11 tag"); +}); + +test("buildReceiptFilters: uses #a (coordinate) when there is no #e", () => { + const zapRequest = { + tags: [ + ["p", "a".repeat(64)], + ["a", "30078:" + "a".repeat(64) + ":video-d-tag"], + ], + }; + const [filter] = buildReceiptFilters(zapRequest, "lnbc-invoice"); + assert.deepEqual(filter["#a"], ["30078:" + "a".repeat(64) + ":video-d-tag"]); + assert.ok(!filter["#e"]); + assert.deepEqual(filter["#p"], ["a".repeat(64)]); +}); + +test("buildReceiptFilters: falls back to #bolt11 only with no e/a/p anchor", () => { + const zapRequest = { tags: [["amount", "1000"]] }; + const [filter] = buildReceiptFilters(zapRequest, "LNBC-Invoice"); + assert.deepEqual(filter["#bolt11"], ["lnbc-invoice"]); + assert.deepEqual(filter.kinds, [9735]); +}); diff --git a/tests/zap-shared-state.test.mjs b/tests/zap-shared-state.test.mjs index f3f11e98..454074b7 100644 --- a/tests/zap-shared-state.test.mjs +++ b/tests/zap-shared-state.test.mjs @@ -4,7 +4,7 @@ const sharedStateModule = await import("../js/payments/zapSharedState.js"); const feeModule = await import("../js/payments/platformFee.js"); const { calculateZapShares } = sharedStateModule; -const { resolvePlatformFeePercent } = feeModule; +const { resolvePlatformFeePercent, getDefaultPlatformFeePercent } = feeModule; (function testCalculateZapSharesSupportsRatioOverride() { const result = calculateZapShares(35, "70/30"); @@ -22,8 +22,18 @@ const { resolvePlatformFeePercent } = feeModule; })(); (function testResolvePlatformFeePercentFallsBackToDefault() { + // An UNPARSEABLE override must fall back to the configured platform default + // (getDefaultPlatformFeePercent), NOT to 0. Falling back to 0 would let a + // garbage override silently DISABLE the platform fee (a bypass). Assert the + // contract against the configured default rather than a hard-coded number so + // the test stays correct if PLATFORM_FEE_PERCENT changes. + const expected = getDefaultPlatformFeePercent(); const percent = resolvePlatformFeePercent("not-a-number"); - assert.equal(percent, 0); + assert.equal(percent, expected); + // Sanity: with the platform fee configured (>0), the fallback must be non-zero + // (i.e. a junk override cannot zero out the fee). + assert.ok(expected > 0, "platform fee default should be configured > 0"); + assert.ok(percent > 0, "junk override must not disable the platform fee"); })(); console.log("zap-shared-state tests passed"); diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 44a44757..dfaca74b 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -712,6 +712,7 @@ async function testValidateZapReceiptSuccess() { created_at: 1_700_000_000, tags: [ ["p", "a".repeat(64)], + ["e", "c".repeat(64)], ["amount", String(900_000)], ["relays", ...relays], ], @@ -735,7 +736,15 @@ async function testValidateZapReceiptSuccess() { SimplePool: class { async list(requestedRelays, filters) { assert.deepEqual(requestedRelays, relays); - assert.equal(filters[0]["#bolt11"][0], bolt11.toLowerCase()); + // Receipts are queried by the reliably-indexed #e (event) + #p (recipient) + // tags, NOT #bolt11 (which most relays don't index — querying by it found + // nothing and made successful zaps look unconfirmed). + assert.deepEqual(filters[0]["#e"], ["c".repeat(64)]); + assert.deepEqual(filters[0]["#p"], ["a".repeat(64)]); + assert.ok( + !filters[0]["#bolt11"], + "must not depend on the unindexed #bolt11 tag to find the receipt", + ); return [receiptEvent]; } close() {} @@ -761,6 +770,133 @@ async function testValidateZapReceiptSuccess() { assert.deepEqual(result.checkedRelays, relays); } +async function testValidateZapReceiptRetriesForLateReceipt() { + // The recipient's service publishes the 9735 receipt a moment AFTER payment, so + // it can be absent on the first lookup (which resolves on EOSE). The validator + // must poll a few times before giving up — here the receipt only appears on the + // 3rd lookup, and validation should still pass. `sleep` is stubbed so the test + // doesn't actually wait. + const relays = ["wss://relay.validation"]; + const zapRequestEvent = { + kind: 9734, + pubkey: "f".repeat(64), + content: "", + created_at: 1_700_000_000, + tags: [ + ["p", "a".repeat(64)], + ["e", "c".repeat(64)], + ["amount", String(900_000)], + ["relays", ...relays], + ], + }; + const zapRequestString = JSON.stringify(zapRequestEvent); + const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash }); + + const receiptEvent = { + kind: 9735, + pubkey: "b".repeat(64), + tags: [ + ["bolt11", bolt11], + ["description", zapRequestString], + ], + }; + + let calls = 0; + let sleeps = 0; + const nostrTools = { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + calls += 1; + // Empty on the first two polls, receipt present on the third. + return calls >= 3 ? [receiptEvent] : []; + } + close() {} + }, + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + amountSats: 900, + metadata: { nostrPubkey: receiptEvent.pubkey }, + invoice: { invoice: bolt11 }, + payment: { invoice: bolt11 }, + }, + { + nostrTools, + getAmountFromBolt11: () => 900, + receiptLookupAttempts: 3, + receiptLookupDelayMs: 5, + sleep: async () => { + sleeps += 1; + }, + } + ); + + assert.equal(result.status, "passed", "a late receipt found on a retry validates"); + assert.equal(calls, 3, "should poll until the receipt appears (3 lookups)"); + assert.equal(sleeps, 2, "should wait between retries (2 delays for 3 attempts)"); +} + +async function testValidateZapReceiptGivesUpAfterRetries() { + // If no receipt ever appears, the validator gives up after its attempts with the + // benign "not published" reason (the caller treats a paid-but-unconfirmed zap as + // a success-with-note, not a failure). + const relays = ["wss://relay.validation"]; + const zapRequestEvent = { + kind: 9734, + pubkey: "f".repeat(64), + content: "", + created_at: 1_700_000_000, + tags: [ + ["p", "a".repeat(64)], + ["e", "c".repeat(64)], + ["amount", String(900_000)], + ["relays", ...relays], + ], + }; + const zapRequestString = JSON.stringify(zapRequestEvent); + const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString); + const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash }); + + let calls = 0; + const nostrTools = { + validateEvent: () => true, + verifyEvent: () => true, + SimplePool: class { + async list() { + calls += 1; + return []; + } + close() {} + }, + }; + + const result = await validateZapReceipt( + { + zapRequest: zapRequestString, + amountSats: 900, + metadata: { nostrPubkey: "b".repeat(64) }, + invoice: { invoice: bolt11 }, + payment: { invoice: bolt11 }, + }, + { + nostrTools, + getAmountFromBolt11: () => 900, + receiptLookupAttempts: 3, + receiptLookupDelayMs: 0, + sleep: async () => {}, + } + ); + + assert.equal(result.status, "failed"); + assert.match(result.reason, /no zap receipt was published/i); + assert.equal(calls, 3, "should exhaust all configured attempts"); +} + async function testValidateZapReceiptRejectsMismatchedDescriptionHash() { const relays = ["wss://relay.validation"]; const zapRequestEvent = { @@ -835,6 +971,8 @@ await testStringFeeOverride(); await testPlatformShareFailure(); await testValidateZapReceiptSuccess(); await testValidateZapReceiptRejectsMismatchedDescriptionHash(); +await testValidateZapReceiptRetriesForLateReceipt(); +await testValidateZapReceiptGivesUpAfterRetries(); testExtractDescriptionHashFromBolt11(); console.log("zap-split tests passed"); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 20f82d3c..266833b8 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -36,17 +36,37 @@ tests → `npm run build` + `npm run test:unit` green → commit + push. the subset kept serving the original → resurrection. Now both delete paths (soft-delete tombstone + NIP-09) use `getDeletePublishRelays()` (write set). Tombstone created_at was already bumped strictly-newer, so this closes the gap. -- [ ] **Verify with the user** that zombies are gone after this fix. If any remain, - remaining suspects to audit: - - Is the local cache / `videosMap` + persisted `bitvid:filtered-videos:v1` - purged on delete, and does the feed filter honor `deleted` + tombstones on - an optimistic (cache-first) reload? - - Are NIP-71 (kind 22) + NIP-94 (kind 1063) mirrors also tombstoned, or can a - lingering mirror resurrect the card? - - getActiveKey mismatch between original (pubkey:dTag) and tombstone (ROOT:…) - for edge-case legacy videos. -- [ ] Add a feed-level regression test (seed event → delete → assert not rendered) - once the cache/mirror angles above are confirmed. +- [x] **getActiveKey mismatch — ROOT-CAUSED + FIXED (2026-06-23).** `js/nostr/client.js` + carried a STALE local duplicate of `getActiveKey` (added before the LEGACY guard + landed in `js/nostr/utils.js` at `1b11cb1b`). The tombstone machinery + (`recordTombstone`/`isOlderThanTombstone`/`applyTombstoneGuard`) used the local + copy, so a deleted legacy video (synthesized `videoRootId = LEGACY::`) + keyed its tombstone as `ROOT:LEGACY:…` while a bare zombie from a relay keyed as + `:` → guard never matched → resurrection. Fix: client.js now imports + the single canonical `getActiveKey` from utils.js (duplicate deleted). Regression + test added in `tests/nostr/client.test.mjs` ("tombstone guard (legacy zombie + suppression)") — in the CI suite. +- [x] **Cache / optimistic-reload angle — audited, OK.** `js/nostr/videoEventBuffer.js` + checks tombstones + `deleted` on every buffered event (cache-first reload honors + deletions; lines ~89, 156–172). +- [x] **Mirror-resurrection angle — audited + GAP FIXED (2026-06-23).** + `js/services/nip71MirrorSync.js` listens on `videos:deleted` → + `nip71MirrorService.remove()` (NIP-09 on BOTH addressable kinds 34235/34236 + + empty-replace tombstone). Legacy auto-21/22 publisher stays dormant. + GAP: teardown was gated on the per-video opt-in flag, which is **browser-local + only** (`bitvid:nip71-mirror:v1`). A video shared on one device and deleted + from another (or after a cache clear) skipped teardown → the NIP-71 mirror + orphaned on other apps. Fixed in `resolveDeleteSync` (`js/services/ + nip71MirrorFlags.js`): delete now ALWAYS attempts teardown when + FEATURE_NIP71_MIRROR is on, ignoring the local flag (remove() is idempotent — + NIP-09 + empty tombstone are no-ops if no mirror exists). The flag still gates + the UI toggle; the feature flag still fully gates publishing. Spec-corrected + unit test + sync-level spy tests (cross-device teardown + best-effort no-throw) + in `tests/nip71-mirror-flags.test.mjs` + `tests/nip71-mirror-sync.test.mjs`. +- [ ] **Verify with the user** that zombies are gone on the live site after these fixes + (the getActiveKey fix needs `npm run build` + redeploy to unstable to take effect). +- [ ] (Nice-to-have) Add a feed/view-level seed→delete→assert-not-rendered test now that + the client-level guard + buffer tombstone paths are covered by unit tests. ### 2. Watch history delete does not work at all - [x] **Root cause found + fixed** (`5f19f536`): the removal path called @@ -93,45 +113,137 @@ tests → `npm run build` + `npm run test:unit` green → commit + push. no-op) — the existing profile-modal-controller test file has an unusual wrapper, so add carefully — plus a feed/view-level seed→delete→assert test. (Local + publish/read layers are already unit+mutation tested.) +- [x] **"1970-01" month bucketing FIXED (2026-06-23).** Legacy/migrated history items + with no real `watchedAt` were siloed into a literal `1970-01` month event + (kind 30079) that self-perpetuated (read back as 0 → re-bucketed → re-published + with 0). `canonicalizeWatchHistoryItems` now derives a STABLE watch time from + the pointer d-tag's embedded ms-timestamp (the video creation time) and + backfills it, so those items land in a real month and the 0→1970 loop stops. + Only truly undecodable pointers still use the epoch bucket. Helper + `deriveWatchedAtFromPointer` + `tests/watch-history-bucketing.test.mjs`. NOTE: + the old `1970-01` event already on relays is left as harmless stale data (not + recreated); actively clearing it was declined as extra surgery. -### 3a. Video-modal popover mis-positioning (zap + embed) — BUG -Confirmed via screenshots: the zap/embed popovers OPEN and are fully functional -(comment box, amount, send all render) but appear anchored to the far LEFT of the -content column instead of `bottom-end` under the button (which is in the right- -side action bar). Both share `js/ui/overlay/popoverEngine.js`. -- Leading cause: the engine only portals a panel into the body-level overlay root - `if (!panel.isConnected)`. The zap/embed panels are pre-existing connected - elements INSIDE the modal, so they're NOT portaled out, and `position:fixed` - resolves against an in-modal containing block (something in the modal subtree - has transform/filter/contain) → horizontal offset. -- Ruled out: positioning CSS present in built css; floating-ui local bundle loads; - panel class correct (`.popover__panel`/`.popover-panel` share the rule). -- Fix (do carefully — shared engine drives every popover): either portal connected - in-modal panels out to `#uiOverlay` on open (and restore/clean on close without - losing the element for re-open), OR remove the containing-block ancestor in the - modal subtree. VERIFY non-modal popovers (card "⋯" menu, feed-settings gear, - FeedInfoPopover) still position correctly after the change. -- To pin exactly: inspect `#modalZapDialog` while open — computed `position`, - `left`, `top`, and its DOM parent (is it under `#uiOverlay` at body level or - still nested in the modal?). +### 3a. Video-modal popover mis-positioning (zap) — FIXED 2026-06-23 +The zap popover (`#modalZapDialog`) opened functional but anchored to the modal +edge instead of `bottom-end` under its trigger. +- [x] **Root cause confirmed + fixed** in the shared engine + (`js/ui/overlay/popoverEngine.js`). The zap render fn returns the PRE-EXISTING + in-modal `#modalZapDialog` verbatim (`() => this.modalZapDialog`, + `zapController.js`). The engine only appended panels to the body-level portal + `if (!panel.isConnected)`, so this already-connected panel was never moved + out; its `position: fixed` then resolved against a transformed/contained modal + ancestor → edge anchoring. Fix: the engine now `relocatePanelToPortal()` on + open (records the panel's origin: parent + nextSibling, moves it into the + `#uiOverlay` portal) and `restorePanelToOrigin()` on close (and via destroy), + so the host DOM stays intact and re-open works. Fresh portal-owned panels + (card "⋯" menu, share/more menus, feed-settings gear) are unaffected — they're + already appended to the portal, so relocate is a no-op for them. + Style-safe: `#modalZapDialog` uses the same `popover__panel card …` classes the + already-portaled menus use; no `.modal`-descendant CSS. Regression test in + `tests/ui/popoverEngine.test.mjs` ("relocates a pre-existing in-host panel …"); + all 9 engine tests + the menu-controller tests pass; build green. +- [ ] **Verify with the user** on the live site after deploy that the zap popover + now opens `bottom-end` under the Zap button (and re-opens correctly). +- NOTE: the "embed" in the original report is a separate full modal + (`components/embed-video-modal.html`, `#embedVideoModal`), NOT an anchored + popover, so it isn't affected by this engine bug. The share/copy menu uses a + fresh portaled panel and already positions correctly. If any other pre-existing + in-modal panel is found mis-positioned, this same engine fix now covers it. ### 3. Zaps system + platform-fee zap split — full plan in docs/zap-audit-plan.md Audit started 2026-06-23. See the doc for architecture map + findings. Summary: - [x] **Send-error softened** (`dd1ce113`): recipient LNURL unreachable/CORS now shows a clear message, not raw "Failed to fetch". -- [ ] **CORS / LNURL proxy decision** (the real reliability fix — static client - can't fetch CORS-less LNURL hosts; many zaps fail). Top of the next session. -- [ ] **Popover positioning (3a)**, **platform-fee split correctness** (incl. the - earlier "fee landed in my own wallet" report + self-zap edge), **receipt - validation (9735)**, **NWC budget/retry UX**, **general clunkiness**. +- [x] **NWC connection bug FIXED (2026-06-23, launch-blocker)** — `parseNwcUri` + passed a hex secret to nostr-tools `getPublicKey` (needs bytes) → NWC connect + threw `expected Uint8Array` and failed entirely. Now hex→bytes. Guarded by + `tests/nwc-parse-uri.test.mjs`. **This likely explains a lot of the "errors on + send".** VERIFY live: connect a wallet + send a zap end-to-end. +- [x] **Popover positioning (3a)** — FIXED (see 3a). +- [x] **In-flight status + form-reset polish (2026-06-23)** — "Sending…" no longer + mis-toned as a warning; success path fully resets the form (`resetZapForm`). +- [x] **"Have to click the Zap button ~3-4 times before it opens" — ROOT-CAUSED + + FIXED (2026-06-23).** Diagnosed with click instrumentation: the button is + intentionally `hidden`+`disabled` until the creator's Lightning address resolves + (a profile fetch, ~2s) — but `.video-modal__action-button` sets + `display: inline-flex`, which OVERRODE the UA `[hidden] { display:none }`, so the + button rendered and looked clickable while still a disabled no-op. Disabled + buttons don't fire `click`, so the first clicks did nothing until it flipped + enabled. Fix: `.video-modal__action-button[hidden] { display:none }` + (`css/tailwind.source.css`) so the zap button only appears once it's + enabled/ready (restores the intended "show zap only when zappable" design). The + earlier in-flight-open toggle tweak was harmless but NOT the cause. VERIFY live: + the zap button appears a moment after the modal loads and opens on first click. + FOLLOW-UP (optional UX): the ~2s appear-delay is the creator-profile fetch; could + show the button immediately and resolve the address lazily if the delay annoys. +- [x] **Receipt success-reporting + discovery FIXED (2026-06-23)** — a paid zap whose + kind-9735 receipt couldn't be validated was shown as a red error; now it's a + success-with-note (the NWC preimage proves payment). Receipt lookup now queries + the reliably-indexed `#e/#a/#p` tags (not the unindexed `#bolt11`) and polls a + few times for a late receipt. See docs/zap-audit-plan.md. +- [x] **Platform-fee fallback verified no-bypass** — junk override falls back to the + configured fee, not 0. +- [ ] **CORS / LNURL proxy decision** (NEEDS A DECISION — the remaining reliability + fix). A static client can't fetch CORS-less LNURL hosts; options: (a) small + Vercel edge proxy that fetches LNURL pay-data + invoice and returns with CORS + [recommended — what web wallets do]; (b) document the limit / CORS-only hosts; + (c) route LN-address resolution via the connected NWC wallet. See + docs/zap-audit-plan.md. NOTE: with NWC now connecting, re-test how many sends + actually fail on CORS vs were just the NWC bug. +- [ ] **Remaining audit items**: platform-fee split correctness (the earlier "fee + landed in my own wallet" report + self-zap / creator==platform edge), receipt + validation (9735), NWC budget/retry UX, general clunkiness. - [x] Comment box confirmed WORKING (the "message doesn't work" was the popover mis-position making it hard to use — see 3a). -### 4. View counter accuracy & reliability -- [ ] Audit the view-counter system (`viewEvents.js`, `reactionCounter.js`) for - accuracy and reliability — dedup per viewer/session, relay aggregation, - double-counting, and resilience to dead relays (now that feed defaults are - reserved, ensure view reads/writes use a sane relay set too). +### 4. View counter accuracy & reliability — DEEP AUDIT +Audit finding (2026-06-24): counts INFLATE. `viewCounter.js` dedupes locally by +(viewer, time-window) correctly, but two things defeated it: (a) +`generateViewEventDedupeTag` built the kind-30079 `d` tag with random entropy + +exact timestamp, so every view was a UNIQUE event and relays could not dedupe the +parameterized-replaceable event; (b) `exactCountForPointer` runs a NIP-45 COUNT +(raw event count, no per-viewer dedupe) and bumped the displayed total UP to it +(`max(deduped, raw)`). Net: reloads / multi-device / re-watches each added a view. +- [x] **Root fix — deterministic, window-bucketed `d` tag** (`viewEvents.js`): + `scope:viewer:bucket`, no entropy. Kind-30079 view events are now genuinely + replaceable, so relays collapse a viewer's repeat views in a window into ONE + event → the NIP-45 COUNT is accurate AND scalable, and `max(deduped, raw)` + stops inflating. `buildViewEvent` no longer invents a random `d` tag + (`nostrEventSchemas.js`). Transition: pre-existing entropy events age out over + `VIEW_COUNT_BACKFILL_MAX_DAYS`. Tests: `tests/view-event-dedupe.test.mjs` + (same-window dedupe, next-window separate, per-viewer/per-video, no auto-d). +- [x] **`tests/view-counter.test.mjs` un-quarantined + green.** Triaged the whole + monolith: pinned the legacy hydrate/subscribe path (the batched + SubscriptionManager added later silently shadowed the mocks), made the + dedupe-window test bucket-aligned (was flaky on time-of-day), and corrected + the `hydrateVideoHistory` root-timestamp assertions to the refactored unit + boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from + the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md + (SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp). +- [x] **Cross-relay UNDER-count fixed (2026-06-24).** The "exact" path (watch page + + popularity chart) relied on the NIP-45 COUNT, but `countEventsAcrossRelays` + returns `bestEstimate` = the SINGLE relay with the most events — it can't + union across relays, so it under-counts when views are spread across relays + (e.g. card showed 19 while the chart's uncapped list union showed ~63). + `exactCountForPointer` now lists view events across ALL relays (uncapped, one + on-demand list — not a storm) and folds the UNION into the deduped + `dedupeBuckets` (one per viewer-window); total = the set size, so it's + accurate AND monotonic (never spuriously decreases). Per-relay COUNT is kept + only as a lower-bound floor when the list truncates (>2000), flagged partial. + The popularity modal now passes `{ exact: true }`, and since the count flows + through the shared pointer state, opening a video corrects its count + everywhere (grid cards converge). Test: `testExactCountUnionsAcrossRelays` in + `tests/view-counter.test.mjs`. NOTE: grid cards (no `exact`) still use the + §17-capped batched backfill (approximate, storm-safe) until the video is + opened — acceptable accuracy/cost tradeoff. +- [x] **`reactionCounter.js` (likes/kind-7) audited — accurate, no fix needed.** + It does NOT use NIP-45 COUNT; it lists kind-7 events and dedupes via + `applyReactionToState` keyed by pubkey (one reaction per user, latest-wins), + with per-content totals. No raw-count inflation. (Kind-7 isn't replaceable, + so listing+dedupe is the only accurate model; the only limit is the + hydration cap for viral content — undercount, not inflate — a future + scalability nicety.) Underpins the popularity chart (#26) / Trending (#27). ### 5. Card-hide / video-liveness check — full plan in docs/video-liveness-plan.md Audited (2026-06-23). Finding: the hide/show **policy is already correct** @@ -146,13 +258,109 @@ un-hides when WebTorrent flips green). The real gaps: `collectVideoSources`); `urlHealthController.probeUrlList` probes them in order — healthy as soon as one plays, offline only if all fail. So a dead primary host no longer hides a video with a working mirror. -- [ ] **Remaining — player fail-over at play time**: the *liveness* now tries all - sources, but actual playback should also fail over to the next source if the - chosen one dies mid-load (playbackService consumes `video.sources`). +- [x] **Player fail-over at play time (2026-06-24).** Playback now tries each + hosted source in turn before dropping to P2P. The `PlaybackSession` builds an + ordered, de-duped `hostedSourceCandidates` (primary URL first, then the + video's imeta `sources`); `attemptHostedPlayback` takes a `candidateUrl` and + the url-first execution loop advances to the next mirror on probe-fail/stall/ + timeout (cleaning up watchdogs + resetting the element between candidates), + only calling WebTorrent after all hosted mirrors fail. `sources` plumbed + coordinator → strategy → session; single-source videos behave exactly as + before. Tests: `tests/services/playbackService_multisource.test.mjs` + (candidate construction; the full async fail-over flow is covered by the + playbackService_order suite in CI). ROLLBACK: revert the loop in + `playbackService.js` to the single `attemptHostedPlayback()` call + drop the + `sources` plumbing. - [ ] **Remaining — hide-until-verified for foreign/ingested** (decision): so unplayable strangers never briefly flash before the probe hides them (bitvid-native stays show-pending). UX policy change in cardSourceVisibility. +### 18. Embed button / modal on the video player does not open — FIXED 2026-06-23 +- [x] **Root cause: event-name mismatch.** `VideoModal.handleEmbedRequest` + dispatched `"action:embed"`, but `ModalManager` listens for `"video:embed"` + (which opens `EmbedVideoModal`), and `dispatch()` does not remap names — so the + embed button fired an event into the void. (Share survives the same mismatch + only because `shareBtn` also toggles a popover directly.) Now dispatches + `"video:embed"`. Regression test in `tests/video-modal-zap.test.mjs`. +- [x] **Sibling bug FIXED (2026-06-23):** `handleCopyRequest` had the SAME mismatch — + dispatched `"action:copy"` with no listener (manager listens for + `"video:copy-magnet"` → `app.handleCopyMagnet`), and `#copyMagnetBtn` has no + popover fallback, so the standalone copy-magnet button was also dead. Now + dispatches `"video:copy-magnet"`. Covered by the same regression test. + +### 19. Direct messages: can read but cannot SEND — FIXED 2026-06-23 +- [x] **Root cause: missing controller delegations.** Clicking Send threw + `this.controller.handleDmAppShellSendMessage is not a function`. The DM + app-shell renderer wires its callbacks on `this.controller` (the + `ProfileDirectMessageController`), but the controller was missing the + delegation methods that forward to `this.actions` — so Send AND mark-read, + mark-all-read, read-receipts/typing toggles, and open-settings all threw and + did nothing. Added the six delegations. The send path itself (encrypt → sign → + publish, NIP-04 default / NIP-17) was fine. Regression test + `tests/dm-controller-delegations.test.mjs`. VERIFY live: typing + Send actually + delivers a message. +- [x] **Follow-up: "Message" button targeted the wrong person.** Clicking Message + on a profile (or anyone you'd never DMed) set the recipient, but + `buildDmConversationData` discarded it — a recipient with no existing thread + wasn't in `conversationMap`, so it fell back to `conversations[0]` (the top + conversation). The inbox silently jumped to the top thread and any message + sent went to THAT person ("says sent but I don't see it"). Fixes: (a) + `setDirectMessageRecipient` now focuses the selected recipient's + conversation; (b) `buildDmConversationData` synthesizes an empty + conversation for a selected-but-threadless recipient and honors it as active + (incl. `activeRemotePubkey` fallback for the composer target); (c) the legacy + `renderProfileMessages` list no longer clobbers a non-null recipient. + Regression: `tests/ui/dm-conversation-focus.test.mjs`. VERIFY live: Message a + brand-new person, confirm the thread opens on THEM and the sent message lands + in that conversation after refresh. + +### 20. Feed / grid loading reliability — some videos missing (BUG) +Two related intermittent symptoms; likely the same root cause (a one-shot, +all-relays-settle fetch that drops slow-relay results — see 17d). +- [x] **Profile / Channel page sometimes doesn't pull in all of a creator's + videos.** Root cause: `loadUserVideos` rendered ONLY the live fetch, which + REPLACES the grid — so when a relay holding a video transiently failed this + load, that video vanished even though it was known in memory/cache. Fixed by + unioning the live events with already-known events + (`mergeChannelVideoSources`, live-wins-on-id) before render, so the grid is a + superset and never shrinks; existing dedupe-by-root + access checks still + handle versioning/deletes. Regression: `tests/channel-video-merge.test.mjs`. + (Streaming/first-relay-wins UX is still the 17d follow-up.) +- [x] **"Paginating between pages" was tab-switching: videos present in one feed + tab missing in another.** Root cause shared with #21: all general tabs read + the SAME active set and only re-ranked, and the rankers never dropped — so + tabs were near-identical and any apparent "missing" was the lack of a + distinct identity. Addressed by giving each tab a real identity (see #21). +- Remaining: streaming/first-relay-wins incremental render (17d UX) is still open + as a latency follow-up, not a correctness bug. + +### 21. "For You" / Explore / Recent: make each a genuinely distinct feed — DONE +Root cause: RECENT, FOR_YOU, EXPLORE all sourced the full active set and only +re-ranked (and the rankers never drop), so with thin signals they all collapsed +toward freshness and looked identical. Gave each a structural identity: +- [x] **For You = "your people first"** (`4873a431`): the scorer tags forYouTier + (2 followed author / 1 interest·watch match / 0 other) and the sorter leads + with tier regardless of raw score, then score within tier; watched already + suppressed. No-signal (logged-out / no follows) falls back to an + author-interleaved discovery order so it never mirrors Recent. + Tests: `tests/for-you-tiering.test.mjs`. +- [x] **Explore = "new-to-you + diverse, less recency"** (`087cf410`): freshness + weight down (0.25→0.12), novelty up (0.30→0.35), a soft followed-author + penalty (reads subscriptionAuthors in the Explore runtime) so non-followed + creators surface, and more variety via the diversity sorter (MMR λ 0.7→0.5). + Test: explore follows-penalty in `tests/feed-engine/explore-scorer.test.mjs`. +- [x] **Recent = chronological** (unchanged). +- [x] **Render bug that masked ALL of the above (2026-06-24):** `VideoListView.render` + re-sorted the grid by date unless `metadata.preserveOrder` was set, and only + Explore set it — so For You's tiering and Kids' scoring were discarded at + render and shown chronologically (looking identical to Recent). Fixed by + setting `preserveOrder` for For You / Trending / Kids in the refreshFeed + cases. This is why the feed-identity work "didn't show" until now. +- [ ] **Follow-up (optional):** Explore per-VIDEO "already watched" suppression + (currently only topic-novelty, not per-video) — needs the watch-history + preload wired into the Explore refresh like For You has. And a Trending tab + (popularity) once view-count/zap data is reliable. + ## Open — medium priority ### 6. Test S3 (generic, non-R2) functions @@ -182,9 +390,13 @@ un-hides when WebTorrent flips green). The real gaps: `loadCommunityBlacklistEntries` (admin editor/whitelist/blacklist lists? a per-author follow-set fetch?). Re-check on a fresh hard-refresh; if still ~32, trace the actual emitter. -- [ ] **`from_hex` source not root-caused.** The sanitizer (`733cb593`) defends + - dev-logs `[toolkit] dropped N invalid hex value(s) from filter ""` — - watch that log to find which subscription ships the odd-length hex, fix at source. +- [x] **`from_hex` root-caused + FIXED (2026-06-23).** The sanitizer + (`HEX64_FILTER_FIELDS`, toolkit.js) covered `ids/authors/#e/#p/#q` but NOT the + NIP-22 UPPERCASE root tags `#E`/`#P`, which relays also hex-decode and which + the kind-1111 comment subscriptions (`commentEvents.js`) query by. An + odd-length root event-id/author there bypassed the sanitizer and got the whole + REQ rejected ("uneven size input to from_hex"). Added `#E`/`#P` to the + sanitized fields; test in `tests/filter-hex-sanitize.test.mjs`. - [x] **Relay `from_hex` REQ rejects fixed** (`733cb593`): one odd-length/non-hex value in `ids/authors/#e/#p/#q` made strict relays reject a whole REQ; now sanitized at the pool choke point (`normalizeFilterList`). @@ -201,6 +413,132 @@ un-hides when WebTorrent flips green). The real gaps: sensitive (trust) → do as a dedicated perf pass, not a rushed change. Source still to pin: the loop issuing 30000/30002/30005/30015 per contact. +### 22. Anti-spam: validate npub on application / submission forms +- [ ] Application/submission forms accept too much spam. Require the submitter's + identifier to be a valid `npub` (NIP-19) — regex/decoder validation client-side + so submission only succeeds for a well-formed npub (and normalize to hex for + storage). Reject/disable submit otherwise with a clear inline error. Pairs with + #23 (the submissions should become structured data, not DMs). + +### 23. Admin submissions tab — structured submissions + approve/deny UI (SCALING) +- [ ] Replace the current DM-based submission flow with **custom structured data** + (a dedicated Nostr event kind / addressable record, or app data) instead of + free-form DMs, so submissions are parseable and actionable. +- [ ] Add an **Admin → Submissions tab** with a nicely formatted UI: each submission + rendered with its fields and an **Approve / Deny** action that applies the + corresponding change (add to whitelist, add to blacklist, etc.) without manual + list-editing. This is foundational for scaling moderation as the platform grows. +- [ ] Define the submission schema + the admin action → list-mutation mapping; reuse + the existing admin list stores (whitelist/blacklist) for the writes. + +### 24. User-level allowlist override for the Web-of-Trust mute list +- [ ] Let a user keep (and import) a personal **allowlist** that the client ALWAYS + shows content from, overriding the inherited WoT/trusted-mute list. So even if a + trusted curator mutes an author, the user can force that author's content to + appear in their own feeds. Persist it (NIP-51 list, encrypted/opt-in), make it + importable, and apply it at the render-time moderation filter as a final + allow-override after the WoT mute/block checks. + +### 25. Per-video / per-event admin block list (granular moderation) +- [ ] Today admin moderation is essentially a "ban hammer" (author/community level). + Add a **per-event (per-video) block list** so an admin can hide a SINGLE + offending video without blocking the whole author. Define the per-event block + record (kind/addressable + the event id / address it targets), wire it into the + render-time filter alongside the existing author/community blacklist, and expose + it in the moderation UI (and eventually the #23 admin tab). + +### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24 +- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time + chart (`js/viewCountChart.js`). `buildViewCountTimeSeries` buckets the raw + kind-30079 view events by the counter's dedupe window (one view per viewer + per day) into a cumulative series; `buildViewCountChartSvg` renders a + token-colored (currentColor/`text-accent`) SVG area+line — no chart lib. The + modal fetches via `listVideoViewEventsWithDefaultClient`, shows the total, + and **updates live** by subscribing to `subscribeVideoViewEventsWithDefaultClient` + (debounced re-render); cleans up on close (Esc/backdrop/✕). Lazy-loaded from + the menu handler. Tests: `tests/view-count-chart.test.mjs`. Built on the + trustworthy #4 counts. +- [ ] FUTURE: feed the same data into a **creator dashboard** showing the creator + their videos' ranking + performance over time (separate, larger effort). + +### 27. "Trending" tab — recently-added sorted by view count — DONE 2026-06-24 +- [x] **Shipped.** New `FEED_TYPES.TRENDING` tab: the active (recently-added) + source ranked by VIEW COUNT (recency tiebreak, trusted-muted last). Gated by + `FEATURE_TRENDING_FEED` (default ON; flag-off hides the sidebar link and skips + registration). Relies on the now-trustworthy view counts (#4). + - `createTrendingSorter` (sorters.js) reads counts from the injected + `runtime.getViewCount`, which resolves each video's pointer (the app's + canonical `deriveVideoPointerInfo`) against the shared viewCounter cache — + the same counts the grid cards already load. + - **Live re-rank:** `viewCounter` now emits a coalesced `onViewCountsChanged` + signal; while the Trending view is active it debounce-re-runs the feed + (cheap re-rank over the in-memory active set, no relay re-fetch), so the + order settles into true trending as counts stream in. Cold cache ⇒ reads as + recency, then improves. + - Pipeline extracted to `js/feedEngine/trendingFeed.js` (feedCoordinator is at + its size cap); thin delegations + a `getVideoViewCountSnapshot` export. + - Wiring: FEED_TYPES + flag, feedCoordinator load/refresh cases, app.js + wrappers, applicationBootstrap registration, viewManager init + + `js/trendingView.js`, `views/trending.html`, sidebar link. + - Tests: `tests/trending-sorter.test.mjs`. +- [ ] FUTURE: benefits from the unified streaming grid (#17d); consider a time- + windowed "trending" (views in the last N days) once view-over-time data (#26) + lands, instead of all-time totals. + +### 31. ⋯ menu "Event Details" button does nothing (BUG) — fix landed, VERIFY +The wiring is structurally intact (menu item `event-details` → handler case → +`handleEventDetailsAction` → `EventDetailsModal.open`), so the failure was a +SILENT one: `handleEventDetailsAction` only opened the modal `if (… && payload +.video)` and never caught the async `open()` rejection, and `open()` ran the +(throwable) `renderVersion` BEFORE actually showing the modal. Hardened: +- [x] `handleEventDetailsAction` no longer fails silently — surfaces "No video + selected" / "unavailable", falls back to `app.eventDetailsModal`, and + `.catch()`es the open promise. +- [x] `EventDetailsModal.open` now calls `openStaticModal` FIRST, then renders in + a try/catch, so a render hiccup can't leave the click looking dead. +- [x] The ⋯ popover is dismissed before opening the modal. +- [x] **Actual root cause (2nd live test):** BOTH Event Details and Popularity + reported "No video selected" from the **video modal's** ⋯ button. The modal + trigger declares its context as `data-more-dropdown="modal"`, but + `attachMoreMenuHandlers` read only `data-context` → defaulted to `"card"` → + skipped the modal-only video resolution → opened the popover with a **null + video**, so the panel's `data-event-id` was empty and every action failed. + The panel is built from `entry.context.video`, so this nulled the whole menu. + Fixes (all in `moreMenuController.js`): + 1. Read context from `data-more-dropdown` as well as `data-context`. + 2. Modal branch falls back to `this.videoModal.activeVideo` when + `getCurrentVideo()` is null (deep-link opens don't set playback's + currentVideo). + 3. Defense-in-depth: `handleEventDetailsAction` resolves the video from + `app.videosMap.get(eventId)`, and `view-stats` uses a new + `getVideoByEventId` callback (dropping a risky `|| currentVideo`). + Regression tests added in `tests/more-menu-controller.test.mjs`. +- [ ] **VERIFY live**: click ⋯ → Event Details AND ⋯ → Popularity in the video + modal AND on a grid/channel card; both should open the correct video. + +### 28. Beacon torrent app stuck — spinner never resolves (BUG) +- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows + a spinner that never goes away. Trace the beacon's init/connect path: what the + spinner is waiting on (tracker/handshake/WebTorrent ready), whether it errors + silently, and whether it's related to the vendored `webtorrent.min.js` + `null.fill` crash (#10). Make it either resolve or fail visibly. + +### 29. Admin-whitelisted users bypass the Web-of-Trust (anti-abuse) +- [ ] When an admin **whitelists** a user, that user's content should **bypass the + WoT mute/flag filtering** entirely — so people can't be silenced by others + maliciously flagging their content as bad. Apply the admin whitelist as an + allow-override at the render-time moderation filter (after WoT mute/block, like + the user-level allowlist in #24, but admin-scoped and authoritative). + +### 30. Blossom storage support (bring to par with R2 / S3) +- [ ] Add **Blossom** (BUD-01/02 blob storage over Nostr) as a storage provider + alongside Cloudflare R2 and generic S3. Bring it to functional parity where + possible: upload, thumbnail, `.torrent`, public URL resolution, and + delete/edit cleanup. Slot it into the existing storage-provider abstraction + (`r2Service.js` / `storageService.js`) and the Storage settings pane; auth is + a signed Nostr event per Blossom rather than S3 keys. Research the BUD spec + coverage needed for bitvid's upload/delete flows. + ## Open — lower priority / infra ### 10. WebTorrent `null.fill` seeding crash @@ -213,6 +551,67 @@ un-hides when WebTorrent flips green). The real gaps: reproduced on pre-refactor `1b11cb1b`). Make it deterministic so it can be a trusted release gate. Audit e2e parallel-load flakiness too. +### 11b. SILENTLY-EXCLUDED unit tests — 20 files never run in CI (found 2026-06-23) +`scripts/run-unit-tests.mjs` only collects `*.test.mjs|*.test.js` files whose +**content contains `node:test`** (line ~54). 20 test files use bare `assert` + +top-level await instead, so the runner skips them WITHOUT WARNING and they are +referenced by no other CI script. They rot invisibly (this is how the delete-flow +test below was failing on `main` unnoticed). +- [x] **`tests/nostr-delete-flow.test.mjs` — FIXED + brought into CI** (converted to + node:test; stale write-relay config + removed-pool.sub subscription harness + corrected; see TEST_INTEGRITY.md 2026-06-23). +- [ ] **Add a guard so this can't recur**: make `run-unit-tests.mjs` emit a visible + WARNING (or fail) when it finds a `*.test.{mjs,js}` lacking a `node:test` import, + listing each skipped file. (Decide warn-vs-fail — fail would immediately red-CI + on the ~11 broken files below until they're triaged.) +- [x] **Runner guard SHIPPED (2026-06-23).** `scripts/run-unit-tests.mjs` now collects + EVERY `*.test.{mjs,js}` (no more node:test content filter) and runs both styles. + Known-broken files are listed in an explicit `QUARANTINE` map — loudly reported + on every run (never silently skipped), and a stale-entry check flags any + quarantine path that no longer exists. The 10 PASSING orphans below now run in + CI automatically (no per-file conversion needed). + - PASS, now in CI: `nostr-view-event-bindings`, `nostr-rebroadcast-guard`, + `watch-history-feed`, `subscriptions-feed`, `discussion-count-service`, + `feed-engine`, `zap-split`, `nostr-view-events`, + `watchHistory/watch-history-telemetry`, `unit/ui/thumbnailBinder`. +- [ ] **Triage + un-quarantine the 10 broken files** (first-error diagnosis 2026-06-23; + each needs the stale-vs-real-bug investigation the delete-flow file got): + - `watch-history` (#2) — DONE + UN-QUARANTINED 2026-06-23. Three stale tests, + all spec-corrected to shipped behavior (see TEST_INTEGRITY.md): + (1) `testWatchHistoryPartialRelayRetry` asserted snapshot THROWS on partial + acceptance (contradicting `565a9618`) → now asserts success / no-throw / + no-republish / queue-emptied; (2) `testWatchHistoryServiceIntegration` set a + logged-in pubkey different from the queried actor (history files under the + logged-in pubkey now) → aligned; (3) same test asserted `session===true` for a + logged-in watch (the pre-fix bug) → corrected to `session!==true`. The full + 2669-line file passes via the CI runner and was removed from QUARANTINE. + - `nostr-boost-actions` — "Missing expected exception" (likely stale: expects a + throw the code no longer makes). + - `view-counter` (#4) — ✅ DONE + UN-QUARANTINED 2026-06-24: the failures were + (a) the deterministic-d-tag accuracy fix (no more random `d` tag, so no + auto-`d` either), (b) the batched SubscriptionManager silently shadowing + the legacy mocks, (c) a time-of-day-flaky dedupe-window setup, and (d) a + moved root-timestamp unit boundary. All fixed; full file green. See + TEST_INTEGRITY.md. + - `zap-shared-state` (#3) — ✅ DONE + UN-QUARANTINED 2026-06-23: stale fee-fallback + test (expected 0; the function correctly falls back to the configured default so + a junk override can't bypass the fee) — corrected. + - `video-modal-zap` (#3) — ✅ DONE + UN-QUARANTINED 2026-06-23: surfaced TWO real + zap bugs (in-flight status mis-toned `warning`→`neutral`; success path now uses + the holistic `resetZapForm`) + spec/harness fixes (completion-count reset+complete; + receipt render asserts on the inner zapController's element after the + VideoModal→sub-controller refactor). + - `nwc-client` (#3) — REAL production bug FIXED 2026-06-23: `parseNwcUri` passed a + hex secret to nostr-tools `getPublicKey` (needs bytes) → NWC connection broken. + Fixed + guarded by the real-tools `tests/nwc-parse-uri.test.mjs`. The mock-based + `nwc-client.test.mjs` STAYS quarantined — its nostr-tools mock is shadowed by the + frozen canonical toolkit the bootstrap installs; needs a mock-injection rework. + - `nostr-count-fallback` — `TypeError: Cannot read 'has' of undefined` — likely + a stale harness mock missing a field. + - `admin-list-store` — community-blacklist merge mismatch. + - `nostr-publish-rejection` — rejection-path assertion. + - `user-blocks` — HANGS (async leak); needs a deterministic rewrite. + ### 12. Promotion: `unstable → beta` - [ ] After this batch soaks and the high-priority items land, promote `unstable → beta` for hosted-domain QA (per the release pipeline in CLAUDE.md). Never push directly @@ -304,17 +703,57 @@ Threat model / cautions (write these into the feature + docs): > Note: live streams and short-form video are almost certainly NOT the same > thing — they're two different NIPs. Confirm the exact kinds/tags before building. -### 16. Nostr live streams — watch-only (zap.stream, shosho.live) +> **Shared requirement for #16 + #16b:** each is a NEW left **sidebar tab**, and each +> must be independently **enable/disable-able from the instance config file** +> (`config/instance-config.js`, like the existing feature flags). When a tab is +> disabled it must be completely absent — no tab, no subscriptions, no trace — exactly +> as if it were never added (the current default state). Build both behind their own +> config flags from the start. + +### 16. Nostr live streams — INGEST / watch-only (zap.stream, shosho.live) +> Ingesting live streams (watch others' streams) and PUBLISHING a stream +> ("go live", #16c) are TWO separate functions and must each have their OWN config +> flag — the maintainer wants to enable/disable ingest and publish independently. - [ ] Likely **NIP-53 Live Activities**: kind **30311** (live event; carries the `streaming` URL — usually HLS .m3u8 — plus `status` live/planned/ended, title, host `p` tags), and kind **1311** (live chat). zap.stream and shosho.live publish these. - [ ] Watch-only scope: discover/list live (status=live) events, render the HLS - stream in the player, optionally show live chat (read). Publish / "go live" - is a later, separate effort. + stream in the player, optionally show live chat (read). +- [ ] New **"Live" sidebar tab**, gated behind its own INGEST config flag (see + shared requirement above) — disabled by default until shipped. - [ ] Research: confirm NIP-53 kinds + tag shapes, how zap.stream vs shosho.live populate `streaming`/`recording`, and HLS playback support in the player. +### 16c. Publish live streams — "Go Live" from bitvid (like zap.stream) +- [ ] Let users **broadcast their own stream** (camera / desktop / arbitrary source) + to Nostr via bitvid, the way zap.stream does: publish a NIP-53 kind-30311 live + event (status live → ended), push the media to a streaming endpoint (HLS), and + keep the event updated. This is the OUTBOUND counterpart to the #16 ingest. +- [ ] **Separate config flag from ingest** — the maintainer wants to enable/disable + "go live" independently of watching live streams. +- [ ] Develop a PLAN first (this is a large effort): the streaming pipeline + (getUserMedia/getDisplayMedia → encoder → HLS/WHIP ingest server), where the + media is hosted (zap.stream uses an external streaming server — bitvid likely + needs the same or a configurable endpoint), event lifecycle, and chat publish + (kind 1311). Note: pure-static bitvid can't host the media itself; identify the + streaming-server dependency early (relates to the CORS/edge constraints). + +### 16b. Nostr short-form video notes — watch-only (new sidebar tab) +- [ ] Short-form (vertical/portrait) video is **NIP-71 kind 22** (the short-form + counterpart to kind 21 normal video) — distinct from live streams. bitvid + already ingests NIP-71 (see #17), so much of the parsing exists; this is about + a dedicated **"Shorts" sidebar tab** with a short-form-appropriate UI (vertical + player, swipe/next feel) that lists kind-22 notes. +- [ ] Gated behind its own config flag (see shared requirement above) — disabled by + default until shipped. +- [ ] Research: confirm kind-22 tag shape vs kind 21, and whether to reuse the + NIP-71 ingest adapter / feed pipeline or a dedicated shorts feed. +- OPEN QUESTIONS for the maintainer (raised 2026-06-23): (1) Should "Shorts" pull + from the SAME whitelisted authors as the main feed, or its own discovery scope? + (2) Does short-form need its own moderation/NSFW handling, or inherit the + existing filters? (3) Same for "Live" — scope of who/what is listed? + ### 17. NIP-71 interop (full plan in `docs/nip71-migration-plan.md`) Research done; decisions locked. **See the plan doc** — it supersedes the rough notes below. Summary: diff --git a/views/trending.html b/views/trending.html new file mode 100644 index 00000000..24b0bdf2 --- /dev/null +++ b/views/trending.html @@ -0,0 +1,40 @@ + +
+
+

Trending

+ +
+ +
+ +
+