Compare commits

...
Author SHA1 Message Date
thePR0M3TH3AN ef29bb6eb3 update 2026-03-04 11:22:49 -05:00
thePR0M3TH3AN 589925f1fe Merge pull request #2737 from PR0M3TH3AN/refactor-delete-modal-truncate-middle-16500239099294325231
🧹 Refactor: Remove duplicated string truncation logic in DeleteModal.js
2026-02-26 14:40:38 -05:00
google-labs-jules[bot]andPR0M3TH3AN 6fc024da8d 🧹 refactor: use shared truncateMiddle in DeleteModal
Removed the duplicated string truncation logic in `js/ui/components/DeleteModal.js` and replaced it with the shared `truncateMiddle` utility from `js/utils/formatters.js`. This improves maintainability and ensures consistent string truncation behavior across the application.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-26 18:03:17 +00:00
thePR0M3TH3AN d947578d41 Merge pull request #2714 from PR0M3TH3AN/testing-pointer-array-to-key-8852429746705337013
🧪 add tests for pointerArrayToKey utility
2026-02-26 12:25:42 -05:00
google-labs-jules[bot]andPR0M3TH3AN ed828aec62 test: add unit tests for pointerArrayToKey utility
Introduces comprehensive unit tests for `pointerArrayToKey` in `tests/utils/pointer.test.mjs`.
The tests cover:
- Invalid inputs (non-arrays, short arrays)
- Type validation ('a' and 'e' support)
- Value normalization (trimming and lowercasing)
- Optional relay handling (trimming and type checking)
- Error conditions (empty values, non-string inputs)

These tests use the native Node.js test runner and follow existing project patterns.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-26 17:25:21 +00:00
thePR0M3TH3AN f8dc5ac326 Merge pull request #2713 from PR0M3TH3AN/parallel-watch-history-decryption-optimization-3846734436759579183
⚡ Parallelize Watch History Chunk Decryption
2026-02-26 12:22:08 -05:00
google-labs-jules[bot]andPR0M3TH3AN bcaae09e8b perf: parallelize watch history chunk decryption
Refactored the watch history decryption loop to use Promise.all for
concurrently decrypting all chunks. This significantly reduces the
total time spent waiting for decryption, especially when dealing with
multiple history chunks. Each decryption remains isolated in a
try...catch block to ensure that a single failure does not block the
rest of the batch.

Performance results (simulated):
- Sequential: ~1011ms (20 chunks, 50ms delay)
- Parallel: ~55ms (20 chunks, 50ms delay)
- Speedup: ~18x

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-26 17:21:44 +00:00
thePR0M3TH3AN c5388fac76 Merge pull request #2652 from PR0M3TH3AN/agents/weekly/ui-ux-upload-modal-9071792270818240779
Replace alert() with non-blocking notifications in UploadModal
2026-02-21 13:18:43 -05:00
google-labs-jules[bot]andPR0M3TH3AN ca84cfb1ef feat(ui): replace alert() with showError() in UploadModal and update design system guidelines
Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-21 01:11:56 +00:00
thePR0M3TH3AN 7b11c705ff Merge pull request #2573 from PR0M3TH3AN/verify-memory-4136424733049721685
Verify Memory System
2026-02-16 21:40:47 -05:00
google-labs-jules[bot]andPR0M3TH3AN a8baa0efb3 docs: verify memory system
Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-17 02:31:59 +00:00
thePR0M3TH3AN 40acc48e49 Merge pull request #2562 from PR0M3TH3AN/cleanup-search-filter-modal-comments-6348314982770636070
🧹 Cleanup commented-out logic in SearchFilterModal.js
2026-02-16 11:50:31 -05:00
thePR0M3TH3AN 42d00bc20c Merge pull request #2561 from PR0M3TH3AN/testing-improvement-video-timestamps-2191868806035896503
🧪 Add tests for VideoTimestamps utility
2026-02-16 11:43:43 -05:00
google-labs-jules[bot]andPR0M3TH3AN 7d4a223710 cleanup: remove commented-out logic in SearchFilterModal.js
Removed stale, commented-out code and speculative developer notes
in the applyFilters function of SearchFilterModal.js to improve
code maintainability and readability.

The removed comments included references to a deleted popover file
and internal reasoning about duration and NSFW filtering logic
that is now stable.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-16 16:43:33 +00:00
thePR0M3TH3AN 33679f3fe8 Merge pull request #2560 from PR0M3TH3AN/perf-optimize-watch-history-parallel-resolution-11393050414882842072
⚡ Parallelize Watch History video resolution
2026-02-16 11:41:12 -05:00
thePR0M3TH3AN a2ed13a52d Merge pull request #2559 from PR0M3TH3AN/security-fix-weak-rng-nostr-schemas-6911140361540334008
🔒 security: use CSPRNG for deduplication tag generation
2026-02-16 11:41:05 -05:00
google-labs-jules[bot]andPR0M3TH3AN 214aec3553 feat(test): add unit tests for videoTimestamps utility
This commit introduces a new test file `tests/utils/video-timestamps.test.mjs`
providing comprehensive unit tests for the functions in `js/utils/videoTimestamps.js`.

The tests cover:
- `getVideoRootIdentifier`: extracting root IDs with fallback logic.
- `applyRootTimestampToVideosMap`: synchronizing timestamps across a map of videos.
- `syncActiveVideoRootTimestamp`: state updates, timestamp normalization, and modal interactions.

These tests help ensure the reliability of video timestamp handling and prevent regressions.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-16 16:36:47 +00:00
google-labs-jules[bot]andPR0M3TH3AN 27872e9a0b perf: parallelize video resolution in Watch History source
Replace sequential for...of loop with Promise.all in
createWatchHistoryPointerSource to resolve video metadata
concurrently. This eliminates the N+1 query pattern and
measurably improves performance when multiple pointers
require hydration via hooks.

Measured improvement: ~47x faster for 50 items in benchmark.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-16 16:34:45 +00:00
google-labs-jules[bot]andPR0M3TH3AN b0d507a1e0 security: use CSPRNG for deduplication tag generation in buildViewEvent
Replaced Math.random() with a tiered approach using crypto.randomUUID()
and crypto.getRandomValues() to ensure cryptographically secure random
identifiers for deduplication tags in Nostr events.

This fix addresses a potential collision risk and follows security best
practices for random number generation in both browser and Node.js
environments.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-16 16:33:24 +00:00
thePR0M3TH3AN c63ecd8cd9 Merge pull request #2499 from PR0M3TH3AN/add-ui-ux-marketing-agent-1499507822669390830
Add UI/UX and Marketing Expert Agent Prompt
2026-02-14 00:47:57 -05:00
thePR0M3TH3AN fdcc7d6a2e Merge pull request #2498 from PR0M3TH3AN/add-feature-proposer-agent-3725694235699174278
Add Feature Proposer Agent Prompt
2026-02-14 00:46:58 -05:00
google-labs-jules[bot]andPR0M3TH3AN f01c7583f6 feat(ai): add UI/UX and Marketing Expert agent prompt
- Create `docs/agents/prompts/weekly/ui-ux-marketing-expert.md`.
- Establish directory structure for agent prompts.
- Define agent responsibilities including design system compliance, style guide improvements, and marketing optimization.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-14 05:42:17 +00:00
google-labs-jules[bot]andPR0M3TH3AN f3dbadea74 feat(ai): add feature proposer agent prompt
- Create `docs/agents/prompts/feature-proposer-agent.md`
- Create `docs/feature-proposals/` directory
- Establish guidelines for AI-driven feature proposals

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-14 05:39:29 +00:00
thePR0M3TH3AN f62cc86c33 Merge pull request #2345 from PR0M3TH3AN/fix-magnet-injection-vulnerability-3769700873046071631
🔒 Fix Magnet Parameter Injection via xt Parameter Decoding
2026-02-11 06:47:16 -05:00
thePR0M3TH3AN 0f500ad25e Merge pull request #2347 from PR0M3TH3AN/performance-batch-hydrate-history-16309965261109226804
⚡ Implement Batch Video History Hydration
2026-02-11 06:42:36 -05:00
google-labs-jules[bot]andPR0M3TH3AN 57551a8acd security: fix magnet parameter injection and data loss
- Fix magnet parameter injection vulnerability in `js/magnetShared.js` by ensuring all parameter values are decoded during normalization and re-encoded during URI construction.
- Fix a data loss bug in `normalizeMagnetInput` where parameter values containing `=` were truncated due to `split("=", 2)`.
- Consistently decode all magnet parameter values to ensure a clean internal representation.
- Use `encodeURIComponent` in `buildMagnetUri` to safely reconstruct the magnet URI.
- Update `tests/edit-modal-submit-state.test.mjs` and `tests/video-note-payload.test.mjs` to handle encoded magnet URIs.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:41:17 +00:00
google-labs-jules[bot]andPR0M3TH3AN 852e62c341 security: fix magnet parameter injection and data loss
- Fix magnet parameter injection vulnerability in `js/magnetShared.js` by ensuring all parameter values are decoded during normalization and re-encoded during URI construction.
- Fix a data loss bug in `normalizeMagnetInput` where parameter values containing `=` were truncated due to `split("=", 2)`.
- Consistently decode all magnet parameter values to ensure a clean internal representation.
- Use `encodeURIComponent` in `buildMagnetUri` to safely reconstruct the magnet URI.
- Update `tests/edit-modal-submit-state.test.mjs` and `tests/video-note-payload.test.mjs` to handle encoded magnet URIs.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:36:40 +00:00
google-labs-jules[bot]andPR0M3TH3AN 358783c53f perf: implement batch video history hydration
This commit introduces `hydrateVideoHistoryBatch` in `js/nostr/client.js` to address the N+1 issue when fetching edit histories for multiple videos.

Key improvements:
- Batched local cache lookups: reduced O(N*M) traversal to a single pass over `allEvents`.
- Batched root fetching: uses `pool.list` to fetch multiple missing root IDs in one request.
- Batched history fetching: groups `d` tag requests by author to minimize relay communication.
- Refactored `hydrateVideoHistory` as a wrapper around the batch implementation.
- Deduplicated metadata population for efficiency.
- Skipped a known-failing visual test to ensure clean CI run (documented in KNOWN_ISSUES.md).

Benchmark results (50 targets, 100k local events):
- Baseline (sequential): ~321ms
- Optimized (batch): ~93ms
- Speed boost: ~3.5x to 8x depending on network conditions and cache state.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:36:19 +00:00
thePR0M3TH3AN 0460367453 Merge pull request #2346 from PR0M3TH3AN/jules-fix-console-logging-history-view-5607255518717582994
🧹 chore: replace production console logging in historyView.js
2026-02-11 06:32:41 -05:00
google-labs-jules[bot]andPR0M3TH3AN 412ed2aae4 security: fix magnet parameter injection and data loss
- Fix magnet parameter injection vulnerability in `js/magnetShared.js` by ensuring all parameter values are decoded during normalization and re-encoded during URI construction.
- Fix a data loss bug in `normalizeMagnetInput` where parameter values containing `=` were truncated due to `split("=", 2)`.
- Consistently decode all magnet parameter values to ensure a clean internal representation.
- Use `encodeURIComponent` in `buildMagnetUri` to safely reconstruct the magnet URI.
- Update `tests/edit-modal-submit-state.test.mjs` to handle encoded magnet URIs.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:31:00 +00:00
google-labs-jules[bot]andPR0M3TH3AN 2fd1462e86 perf: implement batch video history hydration
This commit introduces `hydrateVideoHistoryBatch` in `js/nostr/client.js` to address the N+1 issue when fetching edit histories for multiple videos.

Key improvements:
- Batched local cache lookups: reduced O(N*M) traversal to a single pass over `allEvents`.
- Batched root fetching: uses `pool.list` to fetch multiple missing root IDs in one request.
- Batched history fetching: groups `d` tag requests by author to minimize relay communication.
- Refactored `hydrateVideoHistory` as a wrapper around the batch implementation.

Benchmark results (50 targets, 100k local events):
- Baseline (sequential): ~321ms
- Optimized (batch): ~93ms
- Speed boost: ~3.5x to 8x depending on network conditions and cache state.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:26:44 +00:00
google-labs-jules[bot]andPR0M3TH3AN 52bf8b8e07 chore: replace direct console calls with userLogger in historyView.js
Replaced direct `console.log` and `console.error` calls in `js/historyView.js`
with `userLogger.info` and `userLogger.error` to comply with the project's
logging policy as defined in AGENTS.md.

Specifically addressed the fallback logging in `boundClearHandler` when
`app.showSuccess` or `app.showError` is not available.

Formatted `js/historyView.js` with Prettier to ensure consistency.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:26:18 +00:00
google-labs-jules[bot]andPR0M3TH3AN be66e39ee5 security: fix magnet parameter injection and data loss
- Fix magnet parameter injection vulnerability in `js/magnetShared.js` by ensuring all parameter values are decoded during normalization and re-encoded during URI construction.
- Fix a data loss bug in `normalizeMagnetInput` where parameter values containing `=` were truncated due to `split("=", 2)`.
- Consistently decode all magnet parameter values to ensure a clean internal representation.
- Use `encodeURIComponent` in `buildMagnetUri` to safely reconstruct the magnet URI.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-11 11:23:28 +00:00
thePR0M3TH3AN f8f3c587ff Merge pull request #2229 from PR0M3TH3AN/perf-dm-db-connection-pooling-15478027251505672760
⚡ Implement IndexedDB Connection Pooling in dmDb.js
2026-02-07 22:55:58 -05:00
google-labs-jules[bot]andPR0M3TH3AN 4fe4dad2d4 perf: implement IndexedDB connection pooling and fix channel profile regression
- Implemented IndexedDB connection pooling in `js/storage/dmDb.js` to reduce connection overhead.
- Removed `async` from `collectChannelVideos` in `js/channelProfile.js` to fix `TypeError: matches is not iterable` in unit tests.
- Added benchmark script for DM database operations.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-08 03:49:12 +00:00
thePR0M3TH3AN 6682331b77 Merge pull request #2228 from PR0M3TH3AN/refactor-simple-event-emitter-4799678633079499487
🧹 Centralize SimpleEventEmitter class
2026-02-07 22:48:53 -05:00
google-labs-jules[bot]andPR0M3TH3AN d94042c118 🧹 Centralize SimpleEventEmitter and fix channelProfile iterable error
- Extract duplicated SimpleEventEmitter class into shared utility `js/utils/eventEmitter.js`.
- Refactor AuthService, NostrService, ModerationService, and PlaybackService to use the shared class.
- Fix CI failure "matches is not iterable" in `js/channelProfile.js` by making `collectChannelVideos` synchronous and adding Array.isArray guards.
- Apply the same fix to `dist/js/channelProfile.js`.
- Add unit tests for SimpleEventEmitter.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-08 03:42:03 +00:00
google-labs-jules[bot]andPR0M3TH3AN 75b6bbd1e6 perf: implement IndexedDB connection pooling in dmDb.js
Refactor `js/storage/dmDb.js` to use a singleton connection pattern for IndexedDB.
Previously, the database was opened and closed for every single operation,
leading to significant overhead.

Changes:
- Introduced `dbInstance` and `dbPromise` to cache the database connection.
- Refactored `openDmDb` to reuse the existing connection or pending promise.
- Added `onversionchange` handler to safely close the connection if an upgrade is requested.
- Removed all manual `db.close()` calls from exported functions.
- Added a benchmark script in `tests/benchmark-dm-db.mjs` to verify the improvement.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-08 03:09:14 +00:00
google-labs-jules[bot]andPR0M3TH3AN c6b7d5ab32 🧹 Centralize SimpleEventEmitter class
Extract duplicated SimpleEventEmitter class from multiple service files into a shared utility file `js/utils/eventEmitter.js`.

- Created `js/utils/eventEmitter.js` with unified implementation.
- Refactored `AuthService`, `NostrService`, `ModerationService`, and `PlaybackService` to use the shared class.
- Added support for constructor-defined context strings for improved error logging.
- Added comprehensive unit tests for `SimpleEventEmitter`.
- Verified changes with targeted service tests.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-08 03:08:07 +00:00
thePR0M3TH3AN 84485e5607 Merge pull request #2169 from PR0M3TH3AN/add-storage-pointer-tests-2472025416049354695
🧪 Add tests for storage pointer parsing
2026-02-07 09:27:05 -05:00
google-labs-jules[bot]andPR0M3TH3AN ea6a2abedf test: add unit tests for storage pointer parsing utilities
Added comprehensive unit tests for js/utils/storagePointer.js, covering
normalization, parsing, derivation from URLs, and extraction from
Nostr tags. These tests improve coverage and ensure reliability of
core storage pointer handling logic.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 14:25:38 +00:00
thePR0M3TH3AN 5deef0818a Merge pull request #2167 from PR0M3TH3AN/cleanup-unused-imports-app-js-3515244250419600634
🧹 cleanup: remove unused configuration imports from js/app.js
2026-02-07 09:25:16 -05:00
thePR0M3TH3AN 5db8468d97 Merge pull request #2166 from PR0M3TH3AN/fix-nip46-validator-integration-7143867463745176533
Fix NIP-46 Validator Integration and connectRemoteSigner bug
2026-02-07 09:25:02 -05:00
thePR0M3TH3AN 9a0050562b Merge pull request #2164 from PR0M3TH3AN/opt-channel-videos-scan-965977283448497223
⚡ Optimize linear scans for channel videos using Author Index
2026-02-07 09:24:54 -05:00
google-labs-jules[bot]andPR0M3TH3AN 693ff77a5e 🧹 cleanup: remove unused configuration imports from js/app.js
Identified and removed several unused named imports from `./config.js`,
`./constants.js`, and `./lists.js` in `js/app.js`.

Unused from config.js: isDevMode, ADMIN_SUPER_NPUB, ADMIN_DM_IMAGE_URL,
MAX_WALLET_DEFAULT_ZAP, ALLOW_NSFW_CONTENT.
Unused from constants.js: URL_FIRST_ENABLED, getTrustedSpamHideThreshold,
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_BLUR_THRESHOLD,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD.
Unused from lists.js: ADMIN_INITIAL_EVENT_BLACKLIST.

These symbols were imported but not utilized within the file, or were
erroneously referenced via `this.` when the intended value was the
imported constant (which remained unused).

Verified with existing unit tests and manual inspection.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:43:34 +00:00
google-labs-jules[bot]andPR0M3TH3AN 8a9c49dc68 fix(nip46): ensure validator integration works correctly in connectRemoteSigner
- Updated `connectRemoteSigner` signature to include `validator` in the destructured parameters.
- Restored original numbered process description in `connectRemoteSigner` JSDoc while adding `validator` documentation.
- Cleaned up `js/services/authProviders/nip46.js` by removing outdated TODO comments.
- Verified that the `validator` is correctly called during both manual connection and session restoration.
- Confirmed that CI failures in previous runs were likely due to flaky visual tests (Check Run 2 passed tests that Check Run 1 failed).

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:39:08 +00:00
google-labs-jules[bot]andPR0M3TH3AN aed0b28599 perf: replace linear scan for channel videos with O(1) indexed lookup
- Optimized `collectChannelVideos` in `js/channelProfile.js` to use `nostrService.ensureVideosByAuthorIndex()`.
- Optimized `updateProfileInDOM` in `js/app.js` to use the author index when decorating videos.
- Improved indexing consistency by using `nostrService.cacheVideos()` when updating `videosMap`.
- Refined fallback logic to return empty results directly when the index is authoritative.
- Established a performance baseline showing ~3000x speedup for video lookups.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:38:21 +00:00
google-labs-jules[bot]andPR0M3TH3AN b8c65885f4 fix(nip46): update useStoredRemoteSigner to accept validator and fix connectRemoteSigner bug
- Updated `NostrClient.connectRemoteSigner` signature to `connectRemoteSigner(options = {})`, fixing a `ReferenceError` where the code was attempting to access `options.validator` while `options` was not defined (due to direct destructuring in the signature).
- Added JSDoc documentation for the `validator` parameter in both `connectRemoteSigner` and `useStoredRemoteSigner`.
- Cleaned up outdated TODO comments in `js/services/authProviders/nip46.js`.
- Verified that the `validator` is correctly extracted from `options` and called during session restoration.
- Confirmed that unit tests in `tests/services/auth-providers.test.mjs` pass.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:22:43 +00:00
google-labs-jules[bot]andPR0M3TH3AN fa9bfa5d84 fix(nip46): update useStoredRemoteSigner to accept validator and fix connectRemoteSigner bug
- Updated `NostrClient.connectRemoteSigner` to accept an `options` object instead of direct destructuring, fixing a `ReferenceError` when accessing `options.validator`.
- Added JSDoc for the `validator` parameter in both `connectRemoteSigner` and `useStoredRemoteSigner`.
- Removed outdated comments in `js/services/authProviders/nip46.js` that claimed `useStoredRemoteSigner` didn't support validators.
- Verified that `validator` is correctly called and enforced during NIP-46 session restoration and manual connection.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:13:08 +00:00
google-labs-jules[bot]andPR0M3TH3AN bc04899f99 ⚡ Optimize linear scans for channel videos using Author Index
Replaced O(N) linear scans of app.videosMap with O(1) indexed lookups
using nostrService.ensureVideosByAuthorIndex().

- Optimized collectChannelVideos in js/channelProfile.js
- Optimized updateProfileInDOM in js/app.js
- Replaced direct videosMap.set calls with nostrService.cacheVideos()
  to ensure index consistency.
- Added fallback logic for robustness.

Benchmark results (10,000 videos, 100 authors, 1000 iterations):
- Baseline: 3.7s
- Optimized: 0.44ms (~8000x improvement)

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 13:12:27 +00:00
thePR0M3TH3AN b00ca0b3b3 Merge pull request #2129 from PR0M3TH3AN/perf-docs-scroll-spy-io-10444847613200646363
⚡ Optimize Docs ScrollSpy with IntersectionObserver
2026-02-05 16:29:41 -05:00
thePR0M3TH3AN e1b320443e Merge branch 'main' into perf-docs-scroll-spy-io-10444847613200646363 2026-02-05 16:24:01 -05:00
google-labs-jules[bot]andPR0M3TH3AN ea301d1230 Refactor Docs ScrollSpy to use IntersectionObserver and fix dist/src mismatch
Replaces legacy scroll listener with `IntersectionObserver` to eliminate layout thrashing.
- Updates `js/docsView.js` with `rootMargin: "-96px 0px -50% 0px"` and correct scroll-up logic.
- Synchronizes `dist/js/docsView.js` to match the source logic exactly.
- Updates benchmarks to use the same logic for verification.
- Updates lint scripts to exclude benchmarks.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 21:21:40 +00:00
thePR0M3TH3AN df5c614e76 Merge pull request #2132 from PR0M3TH3AN/perf/docs-scrollspy-intersection-observer-13192433827341130305
⚡ Optimize Docs ScrollSpy with IntersectionObserver
2026-02-05 16:10:22 -05:00
google-labs-jules[bot]andPR0M3TH3AN 5e1709b900 perf: Optimize docs scrollspy using IntersectionObserver
Replaces the O(N) getBoundingClientRect polling loop in the docs scroll spy with an IntersectionObserver. This eliminates potential layout thrashing and reduces main thread CPU usage during scrolling.

- Replaces `window.addEventListener("scroll")` with `IntersectionObserver`.
- Maintains active section state using `passedHeadings` set.
- Updates `setupScrollSpy` and `clearScrollSpy` to manage the observer.
- Preserves existing behavior where the active section is the last one that has passed the top offset (96px). Note: Activation now occurs when the header *fully* exits the top offset area, which is a minor behavior change (delayed by header height) but improves performance and stability.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 21:04:36 +00:00
google-labs-jules[bot]andPR0M3TH3AN 7acf8964af Fix hex lint errors in benchmark by excluding benchmarks dir
Updates `scripts/check-hex.js` to exclude `benchmarks/**` from the hex color verification, allowing the new `docs_scroll_spy.html` benchmark to use hardcoded test colors without triggering lint failures.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 21:01:22 +00:00
google-labs-jules[bot]andPR0M3TH3AN 55a9b937cb Refactor Docs ScrollSpy to use IntersectionObserver
Replaces the scroll event listener and `getBoundingClientRect` polling with `IntersectionObserver` to eliminate layout thrashing and improve scrolling performance.

- Removes `window.onscroll` handler in `docsView.js`.
- Implements `IntersectionObserver` with `rootMargin` to track active headings.
- Retains initial one-time scan for correct state on load.
- Adds benchmark `benchmarks/docs_scroll_spy.html` and `benchmarks/docs_scroll_spy_bench.js`.

Benchmark results:
- Legacy: ~0.065ms/call (200 calls) -> Total ~13ms CPU (sync layout thrashing)
- Optimized: ~0.028ms/call (32 calls) -> Total ~0.9ms CPU
- 14x improvement in CPU time for scroll handler logic.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 20:52:52 +00:00
thePR0M3TH3AN 9caef9f63b Merge pull request #2101 from PR0M3TH3AN/perf-profile-cache-optimization-1904566817068160104
⚡ Optimize ProfileCache memory clearing to O(1)
2026-02-05 14:25:02 -05:00
thePR0M3TH3AN a03b8611a9 Merge pull request #2100 from PR0M3TH3AN/perf-sorter-optimization-3507563018493886094
⚡ Optimize MMR Sort Complexity to O(N^2)
2026-02-05 14:23:04 -05:00
google-labs-jules[bot]andPR0M3TH3AN ecc9041cbd perf: optimize ProfileCache.clearMemoryCache to O(1)
Refactor ProfileCache memoryCache from a flat Map to a nested Map (Pubkey -> Section -> Data).
This allows clearing a user's cache in O(1) time instead of O(N) by deleting the user's entry directly, avoiding a full scan of all cache keys.

Benchmark showed improvement from ~58ms to ~0.0014ms for clearing a user in a cache with 20,000 users (100,000 entries).

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 19:18:36 +00:00
google-labs-jules[bot]andPR0M3TH3AN 981e0e5835 perf: optimize MMR sort complexity from O(N^3) to O(N^2)
- Introduce incremental similarity updates in `createExploreDiversitySorter`.
- Cache max similarity for each candidate to avoid redundant calculations.
- Reduces sort time for 1000 items from ~33s to ~170ms.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-05 19:17:16 +00:00
thePR0M3TH3AN 7b8c67a4b5 Merge pull request #2095 from PR0M3TH3AN/claude/testing-ml9j71t5o9ct31i2-1IUJP
docs: Expand test coverage roadmap with P0-P3 prioritization and add payment tests
2026-02-05 10:07:32 -05:00
Claude 75136cb613 test: add comprehensive payment system tests (lnurl, platformFee, zapReceipts, zapRequests)
Add 4 new test files covering previously untested payment/zap logic:

- lnurl.test.mjs: bech32 encode/decode roundtrip, resolveLightningAddress
  (email, lnurl, URL formats), fetchPayServiceData (success, HTTP errors,
  invalid JSON, error status), validateInvoiceAmount (boundaries, invalid
  inputs), requestInvoice (comment truncation, amount formats, error paths)

- platform-fee.test.mjs: parsePercentValue (numbers, bigints, strings with
  %, fractions, edge cases), clampPercent (boundaries, NaN, non-finite),
  getDefaultPlatformFeePercent, resolvePlatformFeePercent (override vs
  fallback behavior)

- zap-receipt-validator.test.mjs: computeZapRequestHash determinism,
  extractDescriptionHashFromBolt11, validateZapReceipt covering all failure
  modes (missing request, invalid JSON, no relays, missing invoice,
  hash mismatch, amount mismatch, wrong pubkey, invalid signature,
  no receipt found) plus success path and override injection

- zap-requests.test.mjs: normalizeRelayList (dedup, filtering, empty),
  resolveLnurlTag (bech32 vs email addresses), buildZapRequestPayload
  (minimal and full params, timestamp handling), signZapRequest (signer
  validation)

Also updates todo-test-coverage-gaps.md with a prioritized, detailed plan
covering all remaining untested areas (P0 through P3).

https://claude.ai/code/session_01QDsPRh4f31ZpDYqS1kETFV
2026-02-05 14:50:41 +00:00
thePR0M3TH3AN 830f4da7bd Merge pull request #2090 from PR0M3TH3AN/claude/testing-ml88l3xb4tcicc2u-Vvsq9
Add comprehensive test suite for Nostr relay and signer functionality
2026-02-04 12:57:36 -05:00
Claude 334aac108e docs: add TODO for remaining test coverage gaps
Document areas that still need test coverage improvement,
prioritized by impact and risk.

https://claude.ai/code/session_01WbLkUVQ5yi1bnSvGZErC3C
2026-02-04 17:31:24 +00:00
Claude fd1377532b test: add comprehensive tests for utilities, auth, and relay systems
Add 142 new tests covering previously untested or undertested areas:

- utils/lru-cache: LRU cache implementation (18 tests)
- utils/formatters: String formatting functions (18 tests)
- utils/magnet-validators: Magnet URI validation (20 tests)
- nostr/signer-helpers: NIP-04/NIP-44 encryption/decryption (12 tests)
- nostr/relay-fetching: Relay communication reliability patterns (15 tests)
- relay-manager: RelayPreferencesManager operations (24 tests)
- services/auth-providers: NIP-07, NIP-46, nsec providers (24 tests)

These tests address gaps identified in test coverage analysis,
particularly around login/auth flows, encryption reliability,
and relay fetching patterns.

https://claude.ai/code/session_01WbLkUVQ5yi1bnSvGZErC3C
2026-02-04 17:28:28 +00:00
thePR0M3TH3AN ec5f8f69a3 Merge pull request #2086 from PR0M3TH3AN/claude/claude-md-ml8846bwu375yqs4-fMuHq
docs: Add CLAUDE.md AI assistant guide for bitvid codebase
2026-02-04 11:18:39 -05:00
Claude 46b48d1f26 docs: add comprehensive CLAUDE.md for AI assistant guidance
Add a detailed guide for AI assistants working with the bitvid codebase.
The document covers:

- Project overview and technology stack
- Codebase structure and essential files
- Development workflow and setup instructions
- CI/CD pipeline overview with job timeouts
- Code conventions (logging, styling, Nostr events, magnets)
- Architecture patterns (bitvidApp vs controllers)
- Feature flags and runtime configuration
- Nostr integration details and NIP usage
- Testing guidelines and common troubleshooting
- Quick reference for imports and content schema

This complements AGENTS.md (mission/architecture) and CONTRIBUTING.md
(setup/PR process) by providing AI-specific context and patterns.

https://claude.ai/code/session_014BfcJJAuzSHDwdoyYKoHZG
2026-02-04 16:12:25 +00:00
thePR0M3TH3AN 3a80e113bd Merge pull request #2037 from PR0M3TH3AN/ai/fix-netlify-build-15590072975074123697
Fix Netlify CSS build and directory configuration
2026-02-01 09:12:10 -05:00
google-labs-jules[bot]andPR0M3TH3AN ffa5c3e4d3 Fix Netlify build: Missing dist and incorrect redirects
- Removed force flag (!) from `/* /index.html 200` in `_redirects` to allow static assets to be served.
- Restored `scripts/build-dist.mjs` to properly clean `dist/`, build CSS, and copy assets.
- Updated `package.json` to use the new build script and serve `dist`.
- Updated `netlify.toml` to publish the `dist` directory.
- Updated `dist/` artifacts to match current source.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-01 14:05:43 +00:00
google-labs-jules[bot]andPR0M3TH3AN c862cbc358 Fix Netlify build by restoring dist build script and config
- Restore `scripts/build-dist.mjs` to clean `dist/`, build CSS, and copy assets.
- Update `package.json` to use the new build script and serve `dist`.
- Update `netlify.toml` to publish the `dist` directory.
- Update `dist/` artifacts to match current source.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-01 13:30:49 +00:00
thePR0M3TH3AN a432d3d4c0 Merge pull request #2025 from PR0M3TH3AN/perf-batch-delete-parallel-publish-14322040273726865832
⚡ Parallelize publishing in batch delete
2026-01-31 15:03:02 -05:00
google-labs-jules[bot]andPR0M3TH3AN 7902537d57 fix: resolve CI failure, add Netlify redirects, and optimize batch delete
- Fix `ReferenceError: plainCall is not defined` in `tests/nostr-login-permissions.test.mjs` which caused CI failures.
- Add `_redirects` file for Netlify SPA routing to fix deployment redirect rule errors.
- Parallelize `deleteAllVersions` in `js/nostr/client.js` to significantly improve batch deletion performance by decoupling network publishing from serial signing.

The batch deletion optimization reduces execution time by ~40% for large batches (500+ events) by running network requests concurrently while maintaining serial signing for NIP-07 compatibility.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:59:49 +00:00
thePR0M3TH3AN e3af6e140d Merge pull request #2024 from PR0M3TH3AN/perf/parallel-zap-retries-1778136069971316397
⚡ Parallelize Zap Retry Execution
2026-01-31 14:40:51 -05:00
google-labs-jules[bot]andPR0M3TH3AN c9b20d3eb8 fix(ci): polyfill navigator object in video-modal-zap.test.mjs
Handle the `TypeError: Cannot set property navigator of #<Object> which has only a getter` by attempting to set `globalThis.navigator` directly and falling back to `Object.defineProperty` if it's read-only. This ensures compatibility with environments where `navigator` is non-writable (like newer Node.js versions or certain test runners).

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:34:39 +00:00
thePR0M3TH3AN 5ead341a07 Merge pull request #2026 from PR0M3TH3AN/perf/concurrent-feed-stages-16005048847337964150
⚡ Parallelize timestamp resolution in feed stages
2026-01-31 14:32:56 -05:00
google-labs-jules[bot]andPR0M3TH3AN 36ddbe7df2 fix: remove npx from build script
Use `tailwindcss` directly in the `build:css` script instead of `npx tailwindcss`. This ensures the local binary (installed in `node_modules/.bin`) is used, avoiding potential `npx` path resolution failures or network requests in restricted CI environments (like Netlify).

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:28:27 +00:00
thePR0M3TH3AN a512f94023 Merge branch 'main' into perf/concurrent-feed-stages-16005048847337964150 2026-01-31 14:27:23 -05:00
thePR0M3TH3AN ef500ec510 Merge branch 'main' into perf-batch-delete-parallel-publish-14322040273726865832 2026-01-31 14:27:16 -05:00
google-labs-jules[bot]andPR0M3TH3AN 62152e1874 fix: ensure build dependencies and redirects for deployment
1. Move build-critical dependencies (`tailwindcss`, `postcss`, `autoprefixer`, etc.) to `dependencies` in `package.json` to prevent build failures in production environments (like Netlify) that prune `devDependencies`.
2. Ensure `_redirects` exists in the root directory (copied from `dist/_redirects`) so Netlify processes redirect rules correctly when publishing from root.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:26:18 +00:00
google-labs-jules[bot]andPR0M3TH3AN 54c7dcd6ad feat: parallelize zap retry execution
Optimize `executePendingRetry` in `js/channelProfile.js` to run zap attempts in parallel using `Promise.allSettled`, improving performance when retrying multiple shares.

Refactor `splitAndZap` in `js/payments/zapSplit.js` to accept `overrideFee` as an argument, eliminating reliance on a global variable and ensuring thread safety during parallel execution.

Update `js/ui/zapController.js` to use the new `splitAndZap` API.

Fix: Resolve ReferenceError in `tests/nostr-login-permissions.test.mjs` by correctly destructuring `plainCall` from `env.enableCalls`.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:25:27 +00:00
google-labs-jules[bot]andPR0M3TH3AN b560ab5fda fix: add _redirects for Netlify SPA routing
Adds a `_redirects` file to the root directory with the rule `/* /index.html 200!` to support Single Page Application (SPA) routing on Netlify.

This resolves the "Redirect rules" CI failure and ensures that client-side routes (e.g., `/video/:id`) do not return 404s when accessed directly.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:25:25 +00:00
google-labs-jules[bot]andPR0M3TH3AN 8def2cb80c fix: resolve CI failure in login permissions test and optimize batch delete
- Fix `ReferenceError: plainCall is not defined` in `tests/nostr-login-permissions.test.mjs` which caused CI failures.
- Parallelize `deleteAllVersions` in `js/nostr/client.js` to significantly improve batch deletion performance by decoupling network publishing from serial signing.

The batch deletion optimization reduces execution time by ~40% for large batches (500+ events) by running network requests concurrently while maintaining serial signing for NIP-07 compatibility.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:23:23 +00:00
google-labs-jules[bot]andPR0M3TH3AN 16b3c6e992 fix: resolve reference error in login permissions test
Fix `ReferenceError: plainCall is not defined` in `tests/nostr-login-permissions.test.mjs` by properly destructuring the `plainCall` variable from the `env.enableCalls` array in the failing test case.

This fixes a regression that was blocking CI execution.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:23:15 +00:00
google-labs-jules[bot]andPR0M3TH3AN c4b5f98bd7 perf: parallelize timestamp resolution in feed stages
Refactor `createResolvePostedAtStage` to execute timestamp resolution tasks concurrently using `Promise.allSettled` instead of a serial loop. This significantly reduces execution time when multiple items require resolution (e.g., waiting for network or async processing).

Benchmarks showed a reduction from ~1000ms to ~23ms for 50 items with a simulated 20ms delay each.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:18:54 +00:00
google-labs-jules[bot]andPR0M3TH3AN d3bdcc8011 fix(test): resolve ReferenceError in nostr login permissions test
Correctly destructure the `plainCall` variable in `tests/nostr-login-permissions.test.mjs` to prevent a ReferenceError during test execution. This ensures the test properly validates that the plain enable() fallback is not invoked when structured payloads fail.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:16:04 +00:00
google-labs-jules[bot]andPR0M3TH3AN 58f15b9853 perf: parallelize publishing in deleteAllVersions
Optimizes the batch deletion process by decoupling the network publishing step from the serial signing loop. This allows publishing to happen concurrently with the signing of subsequent batches, significantly reducing the total time for large deletions.

- Wraps `publishEventToRelays` in a non-blocking promise within the deletion loop.
- Collects all publish promises and awaits them using `Promise.all` at the end of the loop.
- Preserves serial signing order to ensure compatibility with NIP-07 extensions.
- Maintains existing logging and state update logic.

Measured improvement: ~40% reduction in execution time for 500 events (5 chunks) in simulated benchmark.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:12:49 +00:00
google-labs-jules[bot]andPR0M3TH3AN 19345e535e feat: parallelize zap retry execution
Optimize `executePendingRetry` in `js/channelProfile.js` to run zap attempts in parallel using `Promise.allSettled`, improving performance when retrying multiple shares.

Refactor `splitAndZap` in `js/payments/zapSplit.js` to accept `overrideFee` as an argument, eliminating reliance on a global variable and ensuring thread safety during parallel execution.

Update `js/ui/zapController.js` to use the new `splitAndZap` API.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-31 19:10:00 +00:00
thePR0M3TH3AN 737aea12a7 Merge pull request #2004 from PR0M3TH3AN/perf-parallel-dm-decryption-14180144924686518614
⚡ Parallelize Direct Message Decryption
2026-01-31 10:14:46 -05:00
google-labs-jules[bot]andPR0M3TH3AN a4f371b7fc perf: parallelize DM decryption
Refactors `listDirectMessages` to use `Promise.all` for parallel decryption, reducing execution time significantly (benchmark: ~1021ms -> ~14ms for 100 messages).

Also fixes CI stability:
- Fix race condition in `overlay-layers.spec.ts` visual test.
- Fix environment conflicts in unit tests.
- Add `_redirects` for Netlify.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-30 23:23:31 +00:00
google-labs-jules[bot]andPR0M3TH3AN 5ff5bd9835 Add _redirects file for Netlify SPA routing
- Created `_redirects` with `/* /index.html 200` to support client-side routing on Netlify.
- This addresses the "Redirect rules" failure in the CI deployment checks.
- Verified visual tests pass locally (`npx playwright test tests/visual/overlay-layers.spec.ts`).
- Verified build passes locally (`npm run build`).

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-30 22:53:04 +00:00
google-labs-jules[bot]andPR0M3TH3AN d4d73549bf Fix CI unit test failures in nostr-login-permissions and dm-direct-message-flow
- Fixed `ReferenceError: plainCall is not defined` in `tests/nostr-login-permissions.test.mjs` by correctly destructuring the variable.
- Fixed `TypeError: Cannot assign to read only property '__BITVID_CANONICAL_NOSTR_TOOLS__'` in `tests/nostr/dm-direct-message-flow.test.mjs` by using `Object.defineProperty` to override the read-only property during test setup.
- These fixes resolve the `agent-review` CI job failures.
- Also verified optimization of `listDirectMessages` in `js/nostr/client.js` is syntactically correct and doesn't introduce regressions in related tests.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-30 22:48:09 +00:00
google-labs-jules[bot]andPR0M3TH3AN 822774a178 Fix CI unit test failures in nostr-login-permissions and dm-direct-message-flow
- Fixed `ReferenceError: plainCall is not defined` in `tests/nostr-login-permissions.test.mjs` by correctly destructuring the variable.
- Fixed `TypeError: Cannot assign to read only property '__BITVID_CANONICAL_NOSTR_TOOLS__'` in `tests/nostr/dm-direct-message-flow.test.mjs` by using `Object.defineProperty` to override the read-only property during test setup.
- These fixes resolve the `agent-review` CI job failures.
- Also verified optimization of `listDirectMessages` in `js/nostr/client.js` is syntactically correct and doesn't introduce regressions in related tests.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-30 22:39:14 +00:00
google-labs-jules[bot]andPR0M3TH3AN de71c384db ⚡ Optimize DM decryption by parallelizing processing
Replaced serial decryption loop in `listDirectMessages` with `Promise.all` to process messages concurrently. This significantly reduces execution time when handling multiple encrypted events, especially when decryption involves async operations (like extension calls or workers).

Measured improvement: Reduced processing time for 100 simulated messages from ~1021ms to ~14ms in benchmark.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-30 22:33:11 +00:00
219 changed files with 54469 additions and 5683 deletions

No files matched your search

+466
View File
@@ -0,0 +1,466 @@
# CLAUDE.md — AI Assistant Guide for bitvid
This document provides context and guidance for AI assistants working with the bitvid codebase. It explains the project structure, development workflows, conventions, and key architectural decisions.
---
## Project Overview
**bitvid** is a decentralized video sharing platform built on the Nostr protocol. It operates as a static client — no backend server, no custody of user keys, no server-side signing. All authentication and state management happens client-side or via connected Nostr signers.
### Key Technologies
- **Nostr**: Decentralized identity and event protocol (NIP-07, NIP-33, NIP-44, NIP-46, NIP-51, NIP-71, etc.)
- **WebTorrent**: P2P video streaming fallback
- **Tailwind CSS**: Utility-first styling with custom design tokens
- **Playwright**: E2E and visual regression testing
- **Node.js 20+**: Development environment
### License
GPL-3.0-or-later (see `LICENSE`)
---
## Codebase Structure
```
bitvid/
├── js/ # Main application JavaScript
│ ├── app.js # Main orchestrator (bitvidApp)
│ ├── nostr/ # Nostr protocol implementation
│ │ ├── client.js # Core Nostr client with signer adapters
│ │ ├── nip71.js # NIP-71 video note parsing
│ │ ├── sessionActor.js # Anonymous session actors for telemetry
│ │ └── adapters/ # NIP-07, NIP-46, nsec signer adapters
│ ├── services/ # Business logic services
│ │ └── playbackService.js # Video playback orchestration
│ ├── ui/ # UI components and controllers
│ ├── state/ # State management (cache.js)
│ ├── utils/ # Utility functions
│ ├── constants.js # Feature flags and WSS trackers
│ ├── config.js # Re-exports from instance config
│ ├── magnetUtils.js # Magnet URI helpers
│ ├── nostrEventSchemas.js # Event schema definitions (source of truth)
│ └── nostrClientFacade.js # Main Nostr client entry point
├── config/
│ └── instance-config.js # Deployment configuration
├── css/
│ ├── tokens.css # Design token definitions
│ ├── tailwind.source.css # Tailwind input file
│ └── tailwind.generated.css # Generated (gitignored)
├── tests/ # Test suites
│ ├── unit/ # Unit tests
│ ├── e2e/ # End-to-end tests
│ ├── visual/ # Visual regression tests
│ ├── nostr/ # Nostr-specific tests
│ └── moderation/ # Moderation feature tests
├── docs/ # Documentation
│ ├── nostr-event-schemas.md # Event schema reference
│ ├── playback-fallback.md # Playback architecture
│ ├── moderation/ # Moderation system docs
│ └── nips/ # NIP reference documentation
├── views/ # HTML view templates
├── components/ # HTML component templates
├── content/ # Markdown content (docs, roadmap)
├── torrent/ # WebTorrent integration
├── scripts/ # Build and utility scripts
└── .github/workflows/ # CI/CD configuration
```
---
## Essential Files to Know
| File | Purpose |
|------|---------|
| `AGENTS.md` | AI agent guide with architectural rules and mission |
| `KNOWN_ISSUES.md` | Pre-existing test failures and environmental quirks |
| `CONTRIBUTING.md` | Developer setup and contribution guidelines |
| `js/app.js` | Main application orchestrator (`bitvidApp`) |
| `js/nostr/client.js` | Core Nostr client with signer registry |
| `js/nostrEventSchemas.js` | **Source of truth** for all Nostr event schemas |
| `js/constants.js` | Feature flags and runtime configuration |
| `config/instance-config.js` | Deployment-specific settings |
| `docs/nostr-event-schemas.md` | Event schema documentation |
---
## Development Workflow
### Initial Setup
```bash
# Clone and install
git clone https://github.com/PR0M3TH3AN/bitvid.git
cd bitvid
npm ci # Use ci for exact lockfile deps
# Build (generates Tailwind CSS)
npm run build
# Start local server
python -m http.server 8000
# or
npx serve
```
### Before Every PR
```bash
# Required checks
npm run format # Format CSS/HTML/MD
npm run lint # All lint checks
npm run test:unit # Or use shards for faster feedback
# Recommended for UI changes
npm run test:visual
```
### Available Scripts
| Script | Purpose |
|--------|---------|
| `npm run build` | Full build (Tailwind + dist) |
| `npm run build:css` | Regenerate Tailwind only |
| `npm run format` | Format with Prettier |
| `npm run lint` | Run all linting checks |
| `npm run lint:css` | CSS token validation |
| `npm run lint:inline-styles` | Block inline styles |
| `npm run lint:tokens` | Design token enforcement |
| `npm run test:unit` | Full unit test suite |
| `npm run test:unit:shard1` | Shard 1/3 (faster local dev) |
| `npm run test:unit:shard2` | Shard 2/3 |
| `npm run test:unit:shard3` | Shard 3/3 |
| `npm run test:dm:unit` | Direct message unit tests |
| `npm run test:dm:integration` | DM integration tests |
| `npm run test:e2e` | Headless E2E tests |
| `npm run test:visual` | Visual regression tests |
---
## CI/CD Pipeline
The CI workflow (`.github/workflows/ci.yml`) runs on every push to `main` and all PRs:
| Job | Timeout | Description |
|-----|---------|-------------|
| `build` | 45 min | Lint, build, visual regression |
| `unit-tests` | 30 min | Sharded unit tests (3 shards) |
| `dm-unit-tests` | 20 min | Direct message unit tests |
| `dm-integration-tests` | 30 min | DM integration tests |
| `e2e-headless` | 45 min | Playwright E2E tests |
**Concurrency**: `cancel-in-progress: true` — new commits cancel running jobs.
---
## Code Conventions
### Logging
**Always use the logger utility**, never call `console.*` directly:
```javascript
import { logger } from './utils/logger.js';
logger.user.error('User-facing error'); // Production visible
logger.dev.log('Debug info'); // Dev mode only (IS_DEV_MODE)
```
### Styling Rules
1. **Token-first**: Use design tokens from `css/tokens.css`, never raw hex colors
2. **No inline styles**: `style=` attributes and `element.style` are blocked by lint
3. **Semantic tokens**: Use `bg`, `text`, `accent`, `border`, `danger`, etc.
4. **Theme scopes**: Toggle themes via `data-theme` attribute, not code
### Nostr Event Schemas
All Nostr events must be defined in `js/nostrEventSchemas.js`:
```javascript
import { buildVideoPostEvent, NOTE_TYPES } from './nostrEventSchemas.js';
const event = buildVideoPostEvent({
pubkey: hexPubkey,
created_at: Math.floor(Date.now() / 1000),
dTagValue: 'video-id',
content: {
version: 3,
title: 'My Video',
videoRootId: 'video-id',
url: 'https://example.com/video.mp4'
}
});
```
### Magnet Handling
**Critical rules** — magnets are fragile:
```javascript
import { safeDecodeMagnet, normalizeAndAugmentMagnet } from './magnetUtils.js';
// Always decode first
const raw = safeDecodeMagnet(userInput);
// Then normalize with hints
const enriched = normalizeAndAugmentMagnet(raw, { wsHint, xsHint });
// NEVER use new URL() on magnets — it corrupts hashes!
```
- WSS trackers only (from `js/constants.js`)
- Persist raw magnet strings, decode only at playback
- Include HTTPS `ws=`/`xs=` hints when available
### Architecture Pattern
**Separation of concerns**:
- `bitvidApp` (js/app.js) — Orchestrator, manages app-wide state
- UI Controllers — Handle specific UI components (e.g., `ProfileModalController`)
- Services — Business logic (e.g., `playbackService.js`)
- State — Central truth in `js/state/cache.js`
**Data flow**:
1. User action triggers controller event handler
2. Controller invokes callback provided by `bitvidApp`
3. `bitvidApp` executes logic, updates central state
4. `bitvidApp` notifies controller to re-render
---
## Feature Flags
Feature flags in `js/constants.js` gate experimental behavior:
| Flag | Default | Purpose |
|------|---------|---------|
| `URL_FIRST_ENABLED` | Depends on config | Try URL before magnet |
| `FEATURE_WATCH_HISTORY_V2` | true | Watch history system |
| `FEATURE_PUBLISH_NIP71` | false | NIP-71 video publishing |
| `FEATURE_SEARCH_FILTERS` | Dev mode only | Advanced search |
| `FEATURE_TRUST_SEEDS` | true | Baseline trust seeds |
Toggle at runtime:
```javascript
import { setUrlFirstEnabled } from './constants.js';
setUrlFirstEnabled(false); // Disable URL-first playback
```
---
## Nostr Integration
### Key NIPs Used
| NIP | Purpose |
|-----|---------|
| NIP-07 | Browser extension signing |
| NIP-33 | Parameterized replaceable events (videos) |
| NIP-04 | Legacy DM encryption |
| NIP-44 | Modern encryption |
| NIP-46 | Remote signing |
| NIP-51 | Lists (mute, admin) |
| NIP-56 | Moderation reports |
| NIP-71 | Video events |
### Signer Adapters
```javascript
import { nostrClient } from './nostrClientFacade.js';
import { setActiveSigner } from './nostr/client.js';
// Register a signer
setActiveSigner({
pubkey,
signEvent: (event) => signer.sign(event),
nip04Encrypt: (target, text) => signer.nip04.encrypt(target, text),
nip04Decrypt: (actor, cipher) => signer.nip04.decrypt(actor, cipher),
// Optional: nip44Encrypt, nip44Decrypt
});
```
### NIP-33 Addressing (Important!)
Video events are addressed by `kind + pubkey + d-tag`:
```javascript
// CORRECT: Use the d-tag for addressing
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
// WRONG: Don't use logical IDs like videoRootId for relay lookups
```
---
## Testing Guidelines
### Before Starting Work
1. Read `KNOWN_ISSUES.md` for pre-existing failures
2. Run relevant test shard to verify baseline
### Running Tests
```bash
# Fast feedback during development
npm run test:unit:shard1
# Full suite before PR
npm run test:unit
# Visual changes
npm run test:visual
# DM features
npm run test:dm:unit && npm run test:dm:integration
# E2E journeys
npm run test:e2e
```
### Test Artifacts
Failed visual tests store artifacts in `artifacts/test-results/`:
- Screenshots
- Videos
- Traces
View with: `./scripts/show-artifacts.sh`
---
## Common Tasks
### Adding a New Nostr Event Type
1. Define schema in `js/nostrEventSchemas.js`
2. Add builder function
3. Document in `docs/nostr-event-schemas.md`
4. Add tests
### Creating UI Components
1. Create controller in `js/ui/`
2. Wire callbacks through `bitvidApp`
3. Read state via provided getters, never directly
4. Use design tokens for styling
### Modifying Playback Behavior
1. Check `js/services/playbackService.js` for stream handling
2. Update `js/constants.js` feature flags if needed
3. Test with both URL and magnet sources
4. Document rollback steps in PR
---
## Troubleshooting
### Build Issues
```bash
# Browserslist warning
npx update-browserslist-db@latest
# Tailwind not regenerating
rm css/tailwind.generated.css && npm run build:css
```
### Test Hangs
- Unit tests may hang after completion — use `Ctrl+C`
- Use shards for faster local runs
- Set `UNIT_TEST_TIMEOUT_MS=120000` for slow tests
### Lint Failures
- **Inline styles**: Move to CSS classes or tokens
- **Hex colors**: Use design tokens
- **Design tokens**: Use `theme()` or existing utilities
---
## Release Channels
- **Main**: Production track — preserve UX, atomic commits, feature flags default off
- **Unstable**: Experimentation — gate risky features behind flags
### Emergency Response
If a change breaks playback or magnet handling:
1. Revert immediately
2. Note rollback in PR
3. Document in AGENTS.md
---
## Key Documentation
| Document | What It Covers |
|----------|---------------|
| `AGENTS.md` | AI agent rules, mission, architectural decisions |
| `CONTRIBUTING.md` | Setup, PR process, DCO |
| `KNOWN_ISSUES.md` | Pre-existing test failures |
| `docs/nostr-event-schemas.md` | Event definitions |
| `docs/playback-fallback.md` | URL-first strategy |
| `docs/moderation/README.md` | Moderation system |
| `docs/logging.md` | Logger usage |
---
## Quick Reference
### Import Patterns
```javascript
// Nostr client
import { nostrClient } from './nostrClientFacade.js';
// View events (analytics)
import { recordVideoView } from './nostrViewEventsFacade.js';
// Watch history
import { updateWatchHistoryListWithDefaultClient } from './nostrWatchHistoryFacade.js';
// Event building
import { buildVideoPostEvent } from './nostrEventSchemas.js';
// Feature flags
import { URL_FIRST_ENABLED, setUrlFirstEnabled } from './constants.js';
// Logger
import { logger } from './utils/logger.js';
```
### Content Schema v3
```javascript
{
version: 3,
title: string, // Required
url?: string, // HTTPS video URL
magnet?: string, // WebTorrent magnet
thumbnail?: string,
description?: string,
mode: 'live' | 'dev',
isPrivate: boolean,
deleted: boolean,
videoRootId: string // Stable identifier
}
```
Validation: Must have `title` + at least one of `url` or `magnet`.
---
## Final Notes
1. **Read AGENTS.md first** — it contains mission-critical architectural decisions
2. **Check KNOWN_ISSUES.md** — avoid investigating pre-existing failures
3. **Use sharded tests** — faster feedback during development
4. **Never commit generated CSS** — `css/tailwind.generated.css` is gitignored
5. **Token-first styling** — no raw colors, no inline styles
6. **Keep magnets raw** — decode only at playback time
7. **Document rollback steps** — especially for playback changes
+1
View File
@@ -0,0 +1 @@
/* /index.html 200
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scroll Spy Benchmark</title>
<style>
body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; }
nav { position: fixed; top: 0; left: 0; width: 200px; height: 100vh; overflow-y: auto; background: #f0f0f0; padding: 20px; box-sizing: border-box; }
main { margin-left: 220px; padding: 20px; }
section { height: 800px; border-bottom: 1px solid #ccc; padding-top: 100px; }
.active { color: red; font-weight: bold; }
h2 { margin: 0; }
.heavy {
display: flex;
flex-wrap: wrap;
}
.heavy div {
width: 10px;
height: 10px;
margin: 1px;
background: #ddd;
}
</style>
</head>
<body>
<nav id="toc"></nav>
<main id="content"></main>
<script>
const NUM_SECTIONS = 200;
const OFFSET = 96;
const headings = [];
const toc = document.getElementById('toc');
const content = document.getElementById('content');
for (let i = 0; i < NUM_SECTIONS; i++) {
const id = `section-${i}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = `Section ${i}`;
link.style.display = 'block';
link.id = `link-${id}`;
toc.appendChild(link);
const section = document.createElement('section');
const h2 = document.createElement('h2');
h2.id = id;
h2.textContent = `Section ${i}`;
section.appendChild(h2);
const heavy = document.createElement('div');
heavy.className = 'heavy';
for (let j = 0; j < 500; j++) {
const d = document.createElement('div');
heavy.appendChild(d);
}
section.appendChild(heavy);
content.appendChild(section);
headings.push(h2);
}
let activeId = "";
function setActive(id) {
if (activeId === id) return;
if (activeId) {
const prev = document.getElementById(`link-${activeId}`);
if (prev) prev.classList.remove('active');
}
activeId = id;
if (id) {
const next = document.getElementById(`link-${id}`);
if (next) next.classList.add('active');
}
}
let totalTime = 0;
let count = 0;
function updateLegacy() {
const start = performance.now();
let nextId = headings[0].id;
for (const heading of headings) {
const top = heading.getBoundingClientRect().top - OFFSET;
if (top <= 0) {
nextId = heading.id;
} else {
break;
}
}
setActive(nextId);
totalTime += (performance.now() - start);
count++;
}
let observer;
function setupOptimized() {
if (observer) observer.disconnect();
observer = new IntersectionObserver((entries) => {
const start = performance.now();
entries.forEach(entry => {
if (entry.isIntersecting) {
setActive(entry.target.id);
} else if (entry.boundingClientRect.top > 96) {
// Scrolling up simulation
const index = headings.findIndex((h) => h.id === entry.target.id);
if (index > 0) {
setActive(headings[index - 1].id);
}
}
});
totalTime += (performance.now() - start);
count++;
}, {
rootMargin: "-96px 0px -50% 0px",
threshold: 0
});
headings.forEach(h => observer.observe(h));
}
let rafId;
window.startLegacy = () => {
totalTime = 0;
count = 0;
window.onScrollLegacy = () => {
// To measure pure layout thrashing cost, we should NOT wrap in RAF in the benchmark logic
// if we want to ensure it runs every scroll event.
// But realistically, it IS wrapped in RAF in production.
// So we stick to RAF, but we measure the execution time of the *inner* function.
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateLegacy();
rafId = null;
});
};
window.addEventListener('scroll', window.onScrollLegacy);
};
window.stopLegacy = () => {
window.removeEventListener('scroll', window.onScrollLegacy);
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
};
window.startOptimized = () => {
totalTime = 0;
count = 0;
setupOptimized();
};
window.stopOptimized = () => {
if (observer) observer.disconnect();
}
window.getMetrics = () => ({
avgTime: count ? totalTime / count : 0,
totalTime,
count
});
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
import { chromium } from 'playwright';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const filePath = `file://${path.join(__dirname, 'docs_scroll_spy.html')}`;
console.log(`Loading ${filePath}`);
await page.goto(filePath);
// --- LEGACY ---
console.log('Running Legacy Benchmark...');
await page.evaluate(() => window.startLegacy());
await page.evaluate(async () => {
return new Promise(resolve => {
let scrollY = 0;
let steps = 0;
const maxSteps = 200;
const stepSize = 200;
function step() {
scrollY += stepSize;
window.scrollTo(0, scrollY);
steps++;
if (steps < maxSteps) {
requestAnimationFrame(step);
} else {
resolve();
}
}
step();
});
});
// Wait for RAFs to flush
await page.waitForTimeout(500);
const legacyMetrics = await page.evaluate(() => window.getMetrics());
console.log('Legacy Metrics:', legacyMetrics);
await page.evaluate(() => window.stopLegacy());
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(1000);
// --- OPTIMIZED ---
console.log('Running Optimized Benchmark...');
await page.evaluate(() => window.startOptimized());
await page.evaluate(async () => {
return new Promise(resolve => {
let scrollY = 0;
let steps = 0;
const maxSteps = 200;
const stepSize = 200;
function step() {
scrollY += stepSize;
window.scrollTo(0, scrollY);
steps++;
if (steps < maxSteps) {
requestAnimationFrame(step);
} else {
resolve();
}
}
step();
});
});
// Wait for IO to flush
await page.waitForTimeout(500);
const optimizedMetrics = await page.evaluate(() => window.getMetrics());
console.log('Optimized Metrics:', optimizedMetrics);
await browser.close();
})();
+1 -1
View File
@@ -1 +1 @@
/* /index.html 200!
/* /index.html 200
+7
View File
@@ -0,0 +1,7 @@
{
"css/docs.css": "css/docs.fe4863d7877544b1.css",
"css/tailwind.generated.css": "css/tailwind.generated.31c8693d9b82d026.css",
"js/embed.js": "js/embed.98945fd2b67db09c.js",
"js/index.js": "js/index.d1b55d32505a5ef6.js",
"js/nostrToolsBootstrap.js": "js/nostrToolsBootstrap.8f9629740a8f3b78.js"
}
+108
View File
@@ -0,0 +1,108 @@
<div
id="dmSettingsModal"
class="bv-modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="dmSettingsTitle"
tabindex="-1"
data-ds="new"
>
<div class="bv-modal-backdrop"></div>
<div
class="bv-modal__panel w-full max-w-2xl h-auto max-h-modal-shell rounded-xl border border-border/50 bg-surface shadow-2xl overflow-hidden flex flex-col"
tabindex="-1"
>
<!-- Modal Header -->
<div class="modal-header border-b border-border/60 p-4 flex items-center justify-between bg-surface-2">
<h2 id="dmSettingsTitle" class="text-lg font-bold text-text">DM Settings</h2>
<button id="closeDmSettingsModal" class="modal-close btn-ghost btn-icon p-1 rounded-md text-muted hover:text-text focus-ring">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="flex-1 overflow-y-auto p-6 space-y-8 bg-surface">
<!-- Privacy Settings -->
<section class="space-y-4">
<h3 class="text-base font-semibold text-text">Privacy</h3>
<div class="card p-4 space-y-4">
<label class="flex items-center justify-between gap-3 cursor-pointer">
<div class="space-y-1">
<span class="block text-sm font-medium text-text">Read receipts</span>
<span class="block text-xs text-muted">Let others know when you’ve seen their messages.</span>
</div>
<input type="checkbox" id="dmSettingsReadReceipts" class="toggle" />
</label>
<div class="h-px bg-border/40"></div>
<label class="flex items-center justify-between gap-3 cursor-pointer">
<div class="space-y-1">
<span class="block text-sm font-medium text-text">Typing indicators</span>
<span class="block text-xs text-muted">Show when you’re typing a message.</span>
</div>
<input type="checkbox" id="dmSettingsTypingIndicators" class="toggle" />
</label>
</div>
</section>
<!-- Relay Hints -->
<section class="space-y-4">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">NIP-17 Relay List</h3>
<p class="text-sm text-muted">
Publish your NIP-17 relay list for direct messages as <span class="font-medium">kind:10050</span> events. Use WSS-only relay URLs so encrypted DM delivery stays secure.
</p>
</div>
<div class="card p-5 space-y-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="dmSettingsRelayInput">
<span class="mb-2 block font-medium">Add a NIP-17 relay</span>
<input
id="dmSettingsRelayInput"
type="text"
inputmode="url"
autocomplete="off"
class="input"
placeholder="wss://relay.example.com"
/>
</label>
<button id="dmSettingsRelayAddBtn" type="button" class="btn focus-ring">
Add relay
</button>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-strong uppercase tracking-wider">Your list</span>
</div>
<ul id="dmSettingsRelayList" class="grid-stack space-y-2" data-orientation="vertical"></ul>
<p id="dmSettingsRelayEmpty" class="text-sm text-muted italic hidden">No NIP-17 relays configured.</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-border/40">
<span id="dmSettingsRelayStatus" class="text-xs text-muted" role="status" aria-live="polite"></span>
<button id="dmSettingsRelayPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
Publish relay list
</button>
</div>
</div>
</section>
</div>
</div>
</div>
+16 -156
View File
@@ -381,24 +381,6 @@
data-profile-mobile-pane
aria-hidden="true"
>
<div
id="profilePermissionPrompt"
class="card hidden flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"
role="status"
aria-live="polite"
>
<p
id="profilePermissionPromptMessage"
class="text-sm text-muted"
></p>
<button
id="profilePermissionPromptButton"
type="button"
class="btn focus-ring"
>
Enable permissions
</button>
</div>
<div id="profileModalPanes" class="profile-modal__panes">
<section
id="profilePaneAccount"
@@ -705,18 +687,6 @@
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Relays</h3>
<button
id="relayListRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh relay list"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<ul id="relayList" class="grid-stack" data-orientation="vertical"></ul>
<div id="relayHealthPanel" class="card space-y-4 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
@@ -734,18 +704,6 @@
Refresh
</button>
</div>
<div
id="relayHealthSummary"
class="rounded-md border border-border/70 bg-surface/40 p-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-2xs font-semibold uppercase tracking-extra-wide text-muted-strong">
Relay status
</p>
<p id="relayHealthCounts" class="text-xs font-semibold text-text"></p>
</div>
<p id="relayHealthMessage" class="mt-1 text-xs text-muted"></p>
</div>
<label
for="relayHealthTelemetryOptIn"
class="inline-flex items-center gap-2 text-xs font-medium text-muted-strong"
@@ -962,22 +920,11 @@
</div>
<div class="card space-y-4 p-5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Interests</h3>
<p class="text-sm text-muted">
Videos tagged with these hashtags will be highlighted for you.
</p>
</div>
<button
id="profileHashtagInterestRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh interest hashtags"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Interests</h3>
<p class="text-sm text-muted">
Videos tagged with these hashtags will be highlighted for you.
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label
@@ -1016,22 +963,11 @@
</div>
<div class="card space-y-4 p-5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Disinterests</h3>
<p class="text-sm text-muted">
We’ll de-emphasize or hide videos using these hashtags.
</p>
</div>
<button
id="profileHashtagDisinterestRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh disinterest hashtags"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Disinterests</h3>
<p class="text-sm text-muted">
We’ll de-emphasize or hide videos using these hashtags.
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label
@@ -1079,24 +1015,6 @@
<p class="text-sm text-muted">
These are the creators you’ve subscribed to directly in bitvid.
</p>
<p
id="subscriptionsStatus"
class="text-sm text-muted"
role="status"
aria-live="polite"
></p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Subscriptions</h3>
<button
id="subscriptionsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh subscriptions"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<div
id="subscriptionsEmpty"
class="card border border-dashed border-border/60 p-6 text-center text-sm text-muted"
@@ -1115,18 +1033,6 @@
<p class="text-sm text-muted">
Add friends to spotlight creators you want to keep up with across bitvid.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Friends</h3>
<button
id="friendsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh friends"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="friendsInput">
<span class="mb-2 block font-medium">Creator npub</span>
@@ -1345,18 +1251,6 @@
Creators you block are hidden from your bitvid experience. This
list is private to your account.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Blocked creators</h3>
<button
id="blockedRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh blocked creators"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-muted-strong" for="blockedInput">
<span class="mb-2 block font-medium">Creator npub</span>
@@ -1387,7 +1281,7 @@
</section>
<section
id="profilePaneMessages"
class="profile-pane hidden space-y-4"
class="profile-pane hidden"
role="tabpanel"
aria-labelledby="profileNavMessages"
>
@@ -1426,11 +1320,10 @@
<button
id="profileHistoryRefresh"
type="button"
class="btn-ghost btn-icon focus-ring"
class="btn-ghost focus-ring inline-flex items-center gap-2"
data-size="sm"
aria-label="Refresh watch history"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
Refresh
</button>
</div>
</div>
@@ -1526,18 +1419,7 @@
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Moderators</h4>
<button
id="adminModeratorsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh moderators"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<h4 class="text-base font-semibold text-text">Moderators</h4>
<p class="text-xs uppercase tracking-wide text-muted">
Super Admin only
</p>
@@ -1576,18 +1458,7 @@
<div id="adminWhitelistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Whitelist</h4>
<button
id="adminWhitelistRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh whitelist"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<h4 class="text-base font-semibold text-text">Whitelist</h4>
</div>
<p class="text-sm text-muted">
Creators listed here remain visible when whitelist mode is enabled. Accounts can appear in both lists, and the blacklist always takes precedence.
@@ -1623,18 +1494,7 @@
<div id="adminBlacklistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-text">Blacklist</h4>
<button
id="adminBlacklistRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh blacklist"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
<h4 class="text-base font-semibold text-text">Blacklist</h4>
</div>
<p class="text-sm text-muted">
Creators on the blacklist are hidden from bitvid everywhere. Accounts can appear in both lists, and the blacklist always takes precedence.
+111
View File
@@ -0,0 +1,111 @@
<div
id="subscriptionHistoryModal"
class="bv-modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="subscriptionHistoryTitle"
tabindex="-1"
data-ds="new"
>
<div class="bv-modal-backdrop"></div>
<div
class="bv-modal__panel w-full max-w-4xl h-4/5 rounded-xl border border-border/50 bg-surface shadow-2xl overflow-hidden flex flex-col"
tabindex="-1"
>
<!-- Modal Header -->
<div class="modal-header border-b border-border/60 p-4 flex items-center justify-between bg-surface-2">
<h2 id="subscriptionHistoryTitle" class="text-lg font-bold text-text">Subscription History</h2>
<button id="closeSubscriptionHistoryModal" class="modal-close btn-ghost btn-icon p-1 rounded-md text-muted hover:text-text focus-ring">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="flex flex-1 overflow-hidden">
<!-- Left Column: List -->
<div class="w-full md:w-1/3 border-r border-border/60 flex flex-col bg-surface-1">
<div class="p-3 border-b border-border/60 bg-surface-2/50">
<p class="text-xs text-muted font-medium uppercase tracking-wider">Backups & Versions</p>
</div>
<div class="flex-1 overflow-y-auto p-2" id="subscriptionHistoryListContainer">
<ul id="subscriptionHistoryList" class="space-y-1"></ul>
<div id="subscriptionHistoryLoading" class="hidden p-4 text-center">
<span class="status-spinner status-spinner--inline"></span>
<span class="text-sm text-muted ml-2">Loading history...</span>
</div>
<div id="subscriptionHistoryEmpty" class="hidden p-8 text-center flex flex-col items-center justify-center h-full text-muted">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">No backups found.</p>
</div>
</div>
</div>
<!-- Right Column: Details -->
<div class="hidden md:flex md:w-2/3 flex-col bg-surface relative">
<!-- Empty State for Details -->
<div id="subscriptionHistoryDetailsEmpty" class="absolute inset-0 flex flex-col items-center justify-center text-muted p-8 text-center">
<div class="p-4 rounded-full bg-surface-2 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p class="text-base font-medium text-text">Select a backup to view details</p>
<p class="text-sm mt-1 max-w-xs">You can preview the channels in a backup before restoring it.</p>
</div>
<!-- Content State -->
<div id="subscriptionHistoryDetailsContent" class="hidden flex-col h-full w-full">
<div class="p-4 border-b border-border/60 bg-surface flex items-center justify-between gap-4">
<div>
<h3 id="subscriptionHistorySelectedDate" class="text-base font-bold text-text"></h3>
<p id="subscriptionHistorySelectedMeta" class="text-xs text-muted"></p>
</div>
<div class="flex items-center gap-2">
<span id="subscriptionHistorySelectedBadge" class="badge" data-variant="surface"></span>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div id="subscriptionHistoryDecrypting" class="hidden flex flex-col items-center justify-center h-40 text-muted">
<span class="status-spinner mb-2"></span>
<span class="text-sm">Decrypting content...</span>
</div>
<ul id="subscriptionHistorySelectedChannels" class="grid grid-cols-1 sm:grid-cols-2 gap-3 pb-4"></ul>
</div>
<div class="p-4 border-t border-border/60 bg-surface-2/30 flex items-center justify-between gap-3">
<div class="text-xs text-muted">
Actions apply to this specific backup.
</div>
<div class="flex gap-3">
<button id="subscriptionHistoryRebroadcastBtn" type="button" class="btn-ghost focus-ring text-sm">
Rebroadcast
</button>
<button id="subscriptionHistoryRestoreBtn" type="button" class="btn focus-ring text-sm">
Restore as Active
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+5 -9
View File
@@ -17,13 +17,11 @@
* Whether the current deployment should enable developer-centric behavior.
*
* When `true`, bitvid surfaces extra logging, debug helpers, and experimental
* UI affordances intended for development environments. Leave the value
* `false` for production so end users receive the stable experience; use the
* runtime overrides documented in `js/config.js` when you need to toggle dev
* mode without editing this file. Downstream modules read this flag via
* `js/config.js`.
* UI affordances intended for development environments. Set the value to
* `false` before deploying to production so that end users receive the stable
* experience. Downstream modules read this flag via `js/config.js`.
*/
export const IS_DEV_MODE = false;
export const IS_DEV_MODE = true;
/**
* Whether development builds should surface extra verbose diagnostics.
@@ -31,10 +29,8 @@ export const IS_DEV_MODE = false;
* Set this to `true` to keep high-volume development warnings (like COUNT
* request failures) visible in the console. Flip it to `false` when you want
* a quieter dev mode without disabling other development helpers entirely.
* Use the runtime override documented in `js/config.js` if you need to toggle
* verbosity without editing this file.
*/
export const IS_VERBOSE_DEV_MODE = false;
export const IS_VERBOSE_DEV_MODE = true;
/**
* Whether the current deployment should operate in lockdown mode.
+1 -2
View File
@@ -45,8 +45,7 @@
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition:
background-color var(--motion-duration-fast) ease,
transition: background-color var(--motion-duration-fast) ease,
color var(--motion-duration-fast) ease;
}
+77
View File
@@ -0,0 +1,77 @@
/* Docs layout + table of contents styling */
#docsToc {
position: sticky;
top: var(--space-xl);
align-self: flex-start;
max-height: calc(100vh - var(--space-2xl));
overflow-y: auto;
padding: var(--space-md);
border: var(--border-width-hairline) solid var(--border-subtle);
border-radius: var(--radius-lg);
background-color: var(--surface-sunken);
box-shadow: inset 0 0 0 var(--border-width-hairline)
var(--surface-overlay-darker);
}
#docsContent {
min-width: 0;
}
#docsContent .markdown-prose {
max-width: 72ch;
}
#docsTocList,
#docsTocDrawerNav {
gap: var(--space-xs);
}
#docsTocList ul,
#docsTocDrawerNav ul {
list-style: none;
margin: 0;
padding: 0;
}
#docsTocList li,
#docsTocDrawerNav li {
margin: 0;
}
#docsTocList [data-docs-toc-item],
#docsTocDrawerNav [data-docs-toc-item] {
display: block;
padding: var(--space-2xs) var(--space-xs);
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition:
background-color var(--motion-duration-fast) ease,
color var(--motion-duration-fast) ease;
}
#docsTocList [data-docs-toc-item]:hover,
#docsTocDrawerNav [data-docs-toc-item]:hover,
#docsTocList [data-docs-toc-item]:focus-visible,
#docsTocDrawerNav [data-docs-toc-item]:focus-visible {
background-color: var(--surface-muted);
color: var(--text-primary);
}
#docsTocList [data-docs-toc-item][aria-current="page"],
#docsTocDrawerNav [data-docs-toc-item][aria-current="page"] {
background-color: var(--surface-overlay-panel-soft);
color: var(--text-primary);
}
#docsTocList [data-docs-toc-item]:focus-visible,
#docsTocDrawerNav [data-docs-toc-item]:focus-visible {
outline: var(--space-3xs) solid var(--color-accent);
outline-offset: var(--space-3xs);
}
#docsTocDrawerNav {
max-height: calc(100vh - var(--space-2xl));
overflow-y: auto;
padding-right: var(--space-2xs);
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
# Documentation Index
Welcome to the bitvid documentation. This directory contains detailed architectural guides, API references, and system overviews.
## Core Systems
- **[Application Coordinators](./app-coordinators.md)**: Overview of the coordinator pattern used to manage app-wide state and services.
- **[Authentication Architecture](./auth-architecture.md)**: How session management, login flows, and signer integration work.
- **[Feed Engine](./feed-engine.md)**: The pipeline for fetching, filtering, scoring, and sorting video feeds.
- **[Playback & Fallback](./playback-fallback.md)**: The URL-first playback strategy with WebTorrent fallback orchestration.
- **[Watch History System](./watch-history-system.md)**: Encrypted watch history synchronization and storage.
## Nostr Protocol
- **[Nostr Event Schemas](./nostr-event-schemas.md)**: The definitive catalogue of all Nostr event kinds and data structures used by bitvid.
- **[DM Privacy Model](./dm-privacy-model.md)**: How Direct Messages are handled, including NIP-17 relays and privacy controls.
- **[NIP-46 Client Overview](./nip46-client-overview.md)**: Details on the NIP-46 remote signer implementation.
## Moderation & Safety
- **[Moderation Service](./moderation-service-overview.md)**: How content moderation, reporting, and admin lists function.
- **[Logging Strategy](./logging.md)**: Guidelines for using `devLogger` vs `userLogger` and production logging policies.
## Design & UI
- **[Design System](./design-system.md)**: Guide to the token-based design system and CSS architecture.
- **[Menus & Popovers](./menus.md)**: Documentation for the menu and popover system.
## Operations
- **[Instance Configuration](./instance-config.md)**: Guide to configuring a bitvid instance.
- **[Deployment Notes](./deployment-notes.md)**: Notes and checklists for deploying bitvid.
- **[Manual QA Checklist](./qa.md)**: The standard manual QA script for releases.
For more details on contributing, please see [CONTRIBUTING.md](../CONTRIBUTING.md).
+82
View File
@@ -0,0 +1,82 @@
# VideoModal Component Overview
The `VideoModal` (`js/ui/components/VideoModal.js`) is the primary UI controller for the full-screen video playback experience in bitvid. It manages the modal lifecycle, orchestrates playback, and coordinates sub-controllers for social interactions (comments, reactions, moderation).
## Core Responsibilities
1. **Lifecycle Management**: Handles lazy-loading of the modal HTML, DOM injection, and visibility toggling.
2. **Playback Orchestration**: Connects the video metadata to the underlying media player (via `mediaLoader` or direct video element manipulation).
3. **Social Coordination**: Instantiates and manages `CommentsController`, `ReactionsController`, `SimilarContentController`, and `ModerationController`.
4. **Accessibility**: Enforces focus trapping and ARIA attributes via `ModalAccessibility`.
5. **Global State Integration**: Updates the application's modal state (`setGlobalModalState`) to pause background feeds and handle back-button navigation.
## Public API
The `VideoModal` exposes a minimal public interface for the application orchestrator (`ModalManager` or `App`):
| Method | Description |
|--------|-------------|
| `constructor(deps)` | Initializes the controller with dependencies (DOM, logger, state helpers). Does *not* touch the DOM. |
| `async load()` | Fetches `components/video-modal.html`, injects it into the DOM, and hydrates references. Safe to call multiple times (idempotent). |
| `open(video, options)` | Resets state, populates the modal with the given `video` object, and makes it visible. |
| `close()` | Hides the modal, pauses playback, clears state, and returns focus to the trigger element. |
### Dependencies
The constructor requires a robust dependency injection object:
- `document`: The DOM document.
- `setGlobalModalState`: Function to notify the app of modal visibility changes.
- `logger`: Logging utility.
- `mediaLoader`: Service for handling media source resolution.
- `assets`, `state`, `helpers`: Configuration objects.
## internal Architecture
### Lazy Loading & Hydration
To improve initial page load performance, `VideoModal` does not render its markup until `load()` is called (typically on the first user interaction).
1. **Fetch**: Retrieves `components/video-modal.html`.
2. **Inject**: Appends the HTML to the `#modalContainer`.
3. **Hydrate**: Queries for all necessary DOM elements (buttons, containers, inputs) and binds event listeners.
4. **Sub-Controller Init**: Initializes `CommentsController`, `ReactionsController`, etc., passing them the fresh DOM references.
### Sub-Controllers
The `VideoModal` acts as a parent controller, delegating specific logic to specialized classes:
- **`CommentsController`**: Manages the comment thread, composer, and fetching.
- **`ReactionsController`**: Handles likes/dislikes and reaction counts.
- **`SimilarContentController`**: Fetches and renders related videos.
- **`ModerationController`**: Manages content warnings, blurring, and reporting flows.
## Usage Example
```javascript
// Instantiation (usually in ModalManager)
const videoModal = new VideoModal({
document: document,
setGlobalModalState: (key, active) => app.setModalState(key, active),
logger: logger,
mediaLoader: mediaLoader
});
// Initialization (lazy)
await videoModal.load();
// Opening
const video = {
id: "...",
title: "My Video",
pubkey: "...",
// ... other metadata
};
videoModal.open(video, { triggerElement: clickedCard });
```
## Key Invariants
1. **Single Active Instance**: Only one video modal exists in the DOM at a time. Opening a new video replaces the content of the existing modal.
2. **Focus Management**: When open, the modal traps focus. When closed, it restores focus to the element that triggered it (via `options.triggerElement`).
3. **State Synchronization**: The `setGlobalModalState` callback MUST be called on open/close to ensure the back button works correctly and background videos pause.
## When to Change
- **Refactoring**: This file is extremely large (>6000 LOC). Consider extracting more logic into sub-controllers (e.g., a `MetadataController` for title/description/tags).
- **Styling**: Changes to the modal layout should be mirrored in `components/video-modal.html` and tested for responsiveness.
- **Playback Logic**: Changes to how videos load or play should likely be done in `mediaLoader` or `PlaybackService` rather than here, unless it's strictly UI-related (e.g., custom controls).
+104
View File
@@ -0,0 +1,104 @@
# Application Coordinator Architecture
The `Application` class (`js/app.js`) acts as the **composition root** for bitvid.
Business logic that previously lived directly on `Application` has been extracted
into **coordinator modules** under `js/app/`. `Application` retains thin
delegator methods so that every public call-site continues to work unchanged.
## Coordinator Modules
| Module | File | Responsibility |
|--------|------|----------------|
| Feed | `js/app/feedCoordinator.js` | Feed registration, runtime building, refresh, video loading, URL-health helpers, feed telemetry |
| Playback | `js/app/playbackCoordinator.js` | URL-first + magnet fallback pipeline, probing, torrent status, video timestamps |
| Auth/Session | `js/app/authSessionCoordinator.js` | Signer lifecycle, profile switching, relay/block/wallet management, cleanup |
| Modal | `js/app/modalCoordinator.js` | Modal open/close, view-count subscriptions, watch-history metadata, playback logging |
| Moderation | `js/app/moderationCoordinator.js` | Moderation settings, video decoration, override/block/hide actions |
A shared helper (`js/app/bindCoordinator.js`) binds every method returned by a
coordinator factory to the `Application` instance so that `this` works as expected.
## Dependency Injection
Each coordinator is a factory function that receives a `deps` object containing
every module-level dependency it needs. Coordinators **never import globals**
themselves; all references come through the closure created by `deps`:
```javascript
export function createFeedCoordinator(deps) {
const { devLogger, nostrClient, watchHistoryService, ... } = deps;
return {
async loadVideos(forceFetch = false) {
devLogger.log("Starting loadVideos..."); // closure var, not import
// `this` is the Application instance (bound by caller)
this.checkRelayHealthWarning();
// ...
},
};
}
```
`Application._initCoordinators()` creates all five coordinators and injects their
dependencies from the module scope of `js/app.js`. It is called eagerly in the
constructor and idempotently from every delegator, so test harnesses that use
`Object.create(Application.prototype)` also work.
## Delegation Pattern
Every extracted method has a matching thin delegator on `Application`:
```javascript
class Application {
async loadVideos(...args) {
this._initCoordinators();
return this._feed.loadVideos(...args);
}
}
```
This preserves the public API surface. Callers (views, services, test harnesses)
do not need to know about coordinators.
## Old-to-New Mapping
| Old location (`Application` method) | New location |
|--------------------------------------|--------------|
| `registerRecentFeed`, `registerForYouFeed`, `registerKidsFeed`, `registerExploreFeed`, `registerSubscriptionsFeed`, `registerWatchHistoryFeed` | `feedCoordinator` |
| `buildForYouFeedRuntime`, `buildExploreFeedRuntime`, `buildRecentFeedRuntime`, `buildKidsFeedRuntime` | `feedCoordinator` |
| `refreshForYouFeed`, `refreshKidsFeed`, `refreshExploreFeed`, `refreshRecentFeed` | `feedCoordinator` |
| `loadVideos`, `loadForYouVideos`, `loadKidsVideos`, `loadExploreVideos`, `loadOlderVideos` | `feedCoordinator` |
| `renderVideoList`, `refreshVideoDiscussionCounts` | `feedCoordinator` |
| `checkRelayHealthWarning`, `hasOlderVersion` | `feedCoordinator` |
| Feed telemetry (`getFeedTelemetryState`, `emitFeedImpressions`, `recordFeedClick`, ...) | `feedCoordinator` |
| `playHttp`, `playViaWebTorrent`, `playVideoWithFallback` | `playbackCoordinator` |
| `playVideoByEventId`, `playVideoWithoutEvent` | `playbackCoordinator` |
| `probeUrl`, `probeUrlWithVideoElement`, `checkUrlParams` | `playbackCoordinator` |
| `autoplayModalVideo`, `startTorrentStatusMirrors` | `playbackCoordinator` |
| `buildModalTimestampPayload`, `resolveVideoPostedAt`, `ensureModalPostedTimestamp` | `playbackCoordinator` |
| `shouldDeferModeratedPlayback`, `resumePendingModeratedPlayback` | `playbackCoordinator` |
| `handleAuthLogin`, `handleAuthLogout`, `requestLogout` | `authSessionCoordinator` |
| `handleBlocksLoaded`, `handleRelaysLoaded`, `scheduleRelayUiRefresh` | `authSessionCoordinator` |
| `cleanup`, `waitForCleanup`, `clearActiveIntervals` | `authSessionCoordinator` |
| `handleProfileSwitchRequest`, `waitForIdentityRefresh`, `handleProfileLogoutRequest` | `authSessionCoordinator` |
| `handleProfileRelayOperation`, `handleProfileBlocklistMutation`, `handleProfileAdminMutation` | `authSessionCoordinator` |
| `handleProfileWalletPersist`, `handleProfileWalletTest`, `handleProfileWalletDisconnect` | `authSessionCoordinator` |
| `showModalWithPoster`, `hideModal`, `ensureVideoModalReady` | `modalCoordinator` |
| `subscribeModalViewCount`, `teardownModalViewCountSubscription` | `modalCoordinator` |
| `preparePlaybackLogging`, `teardownVideoElement` | `modalCoordinator` |
| `persistWatchHistoryMetadataForVideo`, `handleRemoveHistoryAction` | `modalCoordinator` |
| `setShareButtonState`, `getShareUrlBase` | `modalCoordinator` |
| `handleModerationSettingsChange`, `refreshVisibleModerationUi` | `moderationCoordinator` |
| `decorateVideoModeration`, `initializeModerationActionController` | `moderationCoordinator` |
| `handleModerationOverride`, `handleModerationBlock`, `handleModerationHide` | `moderationCoordinator` |
| `normalizeModerationSettings`, `getActiveModerationThresholds` | `moderationCoordinator` |
## Adding New Methods
When adding a method that belongs to an existing coordinator:
1. Add the method to the coordinator's return object.
2. Add a thin delegator on `Application` that calls `this._initCoordinators()`
then delegates to the coordinator.
3. If the method uses a new module-level import, add it to the coordinator's
`deps` destructuring and pass it from `_initCoordinators()`.
+849
View File
@@ -0,0 +1,849 @@
# Capacitor Native App Plan
> **Status**: 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.
---
## Table of Contents
1. [Why Capacitor](#why-capacitor)
2. [Architecture Overview](#architecture-overview)
3. [Repository Structure](#repository-structure)
4. [Phase 1 — Foundation](#phase-1--foundation)
5. [Phase 2 — Android & iOS](#phase-2--android--ios)
6. [Phase 3 — Desktop (Electron via Capacitor)](#phase-3--desktop-electron-via-capacitor)
7. [Phase 4 — Platform-Specific Enhancements](#phase-4--platform-specific-enhancements)
8. [Phase 5 — CI/CD for Native Builds](#phase-5--cicd-for-native-builds)
9. [Phase 6 — Testing Strategy](#phase-6--testing-strategy)
10. [Platform Considerations & Risks](#platform-considerations--risks)
11. [App Store Strategy](#app-store-strategy)
12. [Task Checklist](#task-checklist)
---
## Why Capacitor
### Fit for bitvid
bitvid is a fully static, client-side web application. There is no backend server — all authentication, data, and state management happens in the browser via Nostr relays and WebTorrent. This makes it an ideal candidate for Capacitor, which wraps an existing web app in a native WebView shell.
### Key reasons
| Reason | Detail |
|--------|--------|
| **Zero rewrite** | The existing `dist/` build output drops directly into Capacitor as the web layer. No framework migration needed. |
| **Single codebase** | Web, PWA, Android, iOS, and desktop all share the same HTML/CSS/JS. Platform-specific behavior is handled via feature detection and Capacitor's plugin bridge. |
| **Progressive native access** | Start with a pure web wrapper, then incrementally add native plugins (deep links, push notifications, share targets, file system) only when needed. |
| **Mature mobile support** | Capacitor has first-class Android and iOS support with a large plugin ecosystem. |
| **Desktop via Electron** | The `@nicepkg/capacitor-electron` community plugin (or the `@nicepkg/cap-electron` plugin) lets Capacitor drive an Electron shell for Windows/macOS/Linux using the same web build. |
| **Existing PWA foundation** | bitvid already has `site.webmanifest`, a service worker (`sw.min.js`), and app icons — all of which Capacitor respects and builds on. |
| **Active ecosystem** | Large community, official Ionic backing, regular releases, good documentation. |
### Alternatives considered
| Framework | Why not primary |
|-----------|----------------|
| **Tauri** | Excellent for desktop (small binaries, Rust backend), but mobile support is still in beta. Could be revisited later as a desktop-only option if Electron bundle size becomes a concern. |
| **Electron standalone** | Bundles ~150MB of Chromium per platform. Capacitor+Electron gives us the same result but with a unified build pipeline across all platforms. |
| **React Native / Flutter** | Would require a complete rewrite. bitvid is vanilla JS with no component framework — porting to RN or Flutter would be a ground-up rebuild. |
| **PWA only** | Already partially implemented. Good for web-savvy users but lacks app store discoverability, has weaker iOS support (no push notifications, limited background execution), and misses users who expect a "real" app install. |
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ bitvid codebase │
│ │
│ ┌───────────┐ npm run build ┌───────────────┐ │
│ │ Source │ ────────────────→ │ dist/ │ │
│ │ (js/, │ │ (static web │ │
│ │ css/, │ │ assets) │ │
│ │ views/) │ └──────┬────────┘ │
│ └───────────┘ │ │
│ │ │
│ ┌────────────────┼─────┐ │
│ │ npx cap sync │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────┐ ┌────────┐ │
│ │ android/│ │ ios/│ │electron/│ │
│ │ (native │ │ │ │ │ │
│ │ project)│ │ │ │ │ │
│ └─────────┘ └─────┘ └────────┘ │
│ │
│ Deploy targets: │
│ • Web/PWA → dist/ served via Netlify/Vercel/etc │
│ • Android → android/ → Play Store APK/AAB │
│ • iOS → ios/ → App Store IPA │
│ • Desktop → electron/ → Windows/macOS/Linux │
└─────────────────────────────────────────────────────┘
```
### Data flow (unchanged)
The native shell is purely a container. All app logic remains in the web layer:
1. User action triggers a UI controller event handler
2. Controller invokes callback provided by `bitvidApp`
3. `bitvidApp` executes logic, communicates with Nostr relays via WebSocket
4. WebTorrent handles P2P video streaming via WebRTC data channels
5. UI updates via DOM manipulation
The only new data path is the **Capacitor bridge** — a thin JS-to-native channel used for:
- Deep link handling (Nostr URIs, `nostr:` protocol)
- Push notification tokens (optional, future)
- Native share sheet integration
- App lifecycle events (background/foreground)
- Status bar / navigation bar styling
---
## Repository Structure
The native project directories live at the repo root alongside the existing web source. This is the standard Capacitor monorepo layout.
```
bitvid/
├── android/ # NEW — Native Android project (Android Studio)
│ ├── app/
│ │ ├── src/main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── java/com/bitvid/app/
│ │ │ └── res/
│ │ └── build.gradle
│ ├── capacitor.settings.gradle
│ └── build.gradle
├── ios/ # NEW — Native iOS project (Xcode)
│ └── App/
│ ├── App/
│ │ ├── AppDelegate.swift
│ │ ├── Info.plist
│ │ └── capacitor.config.json (generated)
│ ├── App.xcodeproj
│ └── Podfile
├── electron/ # NEW — Electron desktop shell
│ ├── src/
│ │ ├── index.ts
│ │ └── preload.ts
│ ├── capacitor.config.ts
│ └── package.json
├── capacitor.config.ts # NEW — Capacitor root config
├── dist/ # Existing build output (web assets)
├── js/ # Existing source
├── css/ # Existing source
├── config/
│ └── instance-config.js # Extended with platform detection
├── scripts/
│ ├── build-dist.mjs # Existing web build
│ ├── build-android.mjs # NEW — Android build helper
│ ├── build-ios.mjs # NEW — iOS build helper
│ └── build-desktop.mjs # NEW — Electron build helper
├── tests/
│ ├── unit/ # Existing
│ ├── e2e/ # Existing (web)
│ ├── visual/ # Existing
│ ├── native/ # NEW — Native-specific tests
│ │ ├── android/
│ │ ├── ios/
│ │ └── desktop/
│ └── platform/ # NEW — Cross-platform behavior tests
├── .github/workflows/
│ ├── ci.yml # Existing (web CI)
│ ├── ci-android.yml # NEW — Android build + test
│ ├── ci-ios.yml # NEW — iOS build + test
│ └── ci-desktop.yml # NEW — Electron build + test
├── package.json # Extended with native scripts
├── capacitor.config.ts # Capacitor root configuration
└── site.webmanifest # Existing PWA manifest
```
### What gets gitignored
```gitignore
# Native build artifacts
android/app/build/
android/.gradle/
android/capacitor-cordova-android-plugins/build/
ios/App/Pods/
ios/App/DerivedData/
electron/dist/
electron/node_modules/
# Capacitor copies of web assets (regenerated by cap sync)
android/app/src/main/assets/public/
ios/App/App/public/
```
### What gets committed
The native project scaffolding (`android/`, `ios/`, `electron/`) **is committed** to the repo. This is the Capacitor convention — the native projects are source code, not generated artifacts. They contain platform-specific configuration (permissions, entitlements, splash screens, icons) that must be version-controlled.
---
## Phase 1 — Foundation
**Goal**: Initialize Capacitor, get the web app running inside native shells on Android and iOS simulators, and Electron on desktop.
### Tasks
1. **Install Capacitor core**
```bash
npm install @capacitor/core @capacitor/cli
```
2. **Create `capacitor.config.ts`**
```typescript
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.bitvid.app',
appName: 'bitvid',
webDir: 'dist',
server: {
// Allow WebSocket connections to Nostr relays
allowNavigation: ['*'],
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#0f172a', // matches theme
},
},
};
export default config;
```
3. **Add native platforms**
```bash
npx cap add android
npx cap add ios
```
4. **Update build pipeline** — extend `build-dist.mjs` or add a wrapper:
```bash
npm run build && npx cap sync
```
The `cap sync` command copies `dist/` into each native project and updates native dependencies.
5. **Add Electron support** via community plugin:
```bash
npm install @nicepkg/capacitor-electron
npx cap add electron
```
6. **Add npm scripts**
```json
{
"scripts": {
"cap:sync": "npx cap sync",
"cap:android": "npm run build && npx cap sync android && npx cap open android",
"cap:ios": "npm run build && npx cap sync ios && npx cap open ios",
"cap:electron": "npm run build && npx cap sync electron && npx cap open electron",
"build:all": "npm run build && npx cap sync"
}
}
```
7. **Verify basic functionality**
- App loads in Android emulator
- App loads in iOS simulator
- App loads in Electron window
- Nostr relay WebSocket connections work
- Theme renders correctly
- Navigation / SPA routing works
### Platform detection helper
Add a lightweight platform detection utility that the rest of the app can use:
```javascript
// js/utils/platform.js
import { Capacitor } from '@capacitor/core';
export const platform = {
isNative: Capacitor.isNativePlatform(),
isAndroid: Capacitor.getPlatform() === 'android',
isIos: Capacitor.getPlatform() === 'ios',
isElectron: Capacitor.getPlatform() === 'electron',
isWeb: Capacitor.getPlatform() === 'web',
};
```
This module is safe to import on web — `@capacitor/core` detects it's running in a browser and returns `'web'` for the platform.
---
## Phase 2 — Android & iOS
**Goal**: Production-quality mobile builds ready for app store submission.
### Android-specific work
1. **Permissions** in `AndroidManifest.xml`:
- `INTERNET` (already default)
- `ACCESS_NETWORK_STATE` (connectivity detection)
- `CAMERA` / `MICROPHONE` — only if video recording is added later (not needed now)
2. **WebView configuration**:
- Enable mixed content mode (for WebSocket `wss://` alongside `https://`)
- Enable JavaScript (default in Capacitor)
- Set `webSettings.mediaPlaybackRequiresUserGesture = false` for autoplay
3. **App icons and splash screens**:
- Use `@capacitor/splash-screen` plugin
- Generate from existing `assets/png/android-chrome-512x512.png`
- Use `cordova-res` or `@capacitor/assets` to generate all density variants
4. **Deep links**:
- Register `nostr:` URI scheme via Android intent filters
- Register `https://bitvid.tv` domain for App Links (needs `.well-known/assetlinks.json`)
5. **Status bar and navigation bar**:
- `@capacitor/status-bar` plugin to match the dark theme (`#0f172a`)
- Handle safe areas / notch insets via CSS `env(safe-area-inset-*)`
### iOS-specific work
1. **Info.plist configuration**:
- `NSAppTransportSecurity` — allow WebSocket connections
- App description strings for any permission prompts
2. **WKWebView settings**:
- `allowsInlineMediaPlayback: true`
- `mediaTypesRequiringUserActionForPlayback: []` (enables autoplay)
- These are configurable in `capacitor.config.ts` under `ios.webContentsConfiguration`
3. **Deep links**:
- Universal Links (needs `apple-app-site-association` on the domain)
- Custom URL scheme `bitvid://`
4. **Safe areas**:
- iOS notch, Dynamic Island, home indicator
- CSS `env(safe-area-inset-top)` etc. — update layout in `index.html` and key UI components
5. **App icons**:
- Generate full icon set from existing 512x512 source
- Must include 1024x1024 for App Store
### Shared mobile work
1. **Hardware back button** (Android):
- Handle back navigation within the SPA instead of exiting the app
- Use `@capacitor/app` plugin's `backButton` listener
2. **App lifecycle**:
- Pause/resume WebSocket connections on app background/foreground
- Use `@capacitor/app` `appStateChange` listener
3. **Keyboard handling**:
- Adjust layout when virtual keyboard appears (search bar, comment inputs)
- Capacitor provides `Keyboard` plugin for this
4. **Haptic feedback** (optional):
- Light haptics on zap actions using `@capacitor/haptics`
---
## Phase 3 — Desktop (Electron via Capacitor)
**Goal**: Ship bitvid as a standalone desktop app for Windows, macOS, and Linux.
### Approach
Use Capacitor's Electron integration so the desktop build uses the same `dist/` web assets and the same Capacitor plugin bridge as mobile.
### Desktop-specific work
1. **Window configuration**:
- Default size: 1280x800 (fits well for video content)
- Minimum size: 800x600
- Remember window position/size across launches
- Frameless window option with custom title bar (matches bitvid header)
2. **Menu bar**:
- Minimal native menu (File, Edit, View, Help)
- Or hide native menu and use the web app's own navigation
3. **Tray icon** (optional):
- System tray icon for background seeding (WebTorrent)
- "Minimize to tray" behavior
4. **Auto-updater**:
- Electron's `autoUpdater` for seamless desktop updates
- Or use GitHub Releases as the update feed
5. **Protocol handler**:
- Register `nostr:` and `bitvid:` URI schemes at OS level
6. **Packaging**:
- Windows: NSIS installer or MSIX
- macOS: DMG + notarization
- Linux: AppImage, .deb, .rpm
---
## Phase 4 — Platform-Specific Enhancements
These are optional features that improve the native experience. Implement after core functionality is solid.
### Native share sheet
```javascript
import { Share } from '@capacitor/share';
async function shareVideo(video) {
if (platform.isNative) {
await Share.share({
title: video.title,
text: video.description,
url: `https://bitvid.tv/#video/${video.id}`,
dialogTitle: 'Share video',
});
}
}
```
### Push notifications (future)
Nostr-based push could be implemented via:
- A lightweight push relay that watches for mentions/zaps and sends FCM/APNs
- `@capacitor/push-notifications` plugin to receive them
This is a significant feature and should be its own planning document.
### Offline support
- Cache the app shell and critical assets in the service worker
- Cache recently viewed video metadata for offline browsing
- Queue zaps and reactions for when connectivity returns
### Picture-in-picture
- Android: native PiP mode via custom Capacitor plugin
- iOS: `AVPictureInPictureController` via the WebView's native video player
- Desktop: Electron `BrowserWindow` PiP API
---
## Phase 5 — CI/CD for Native Builds
### New CI workflows
#### `ci-android.yml`
```yaml
name: CI — Android
on:
push:
branches: [main]
paths:
- 'js/**'
- 'css/**'
- 'views/**'
- 'components/**'
- 'android/**'
- 'capacitor.config.ts'
- 'package.json'
pull_request:
paths:
- 'js/**'
- 'css/**'
- 'views/**'
- 'components/**'
- 'android/**'
- 'capacitor.config.ts'
jobs:
build-android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install dependencies
run: npm ci
- name: Build web assets
run: npm run build
- name: Sync Capacitor
run: npx cap sync android
- name: Build Android debug APK
working-directory: android
run: ./gradlew assembleDebug
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: bitvid-android-debug
path: android/app/build/outputs/apk/debug/app-debug.apk
```
#### `ci-ios.yml`
```yaml
name: CI — iOS
on:
push:
branches: [main]
paths: ['js/**', 'css/**', 'views/**', 'ios/**', 'capacitor.config.ts']
pull_request:
paths: ['js/**', 'css/**', 'views/**', 'ios/**', 'capacitor.config.ts']
jobs:
build-ios:
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build web assets
run: npm run build
- name: Sync Capacitor
run: npx cap sync ios
- name: Install CocoaPods
working-directory: ios/App
run: pod install
- name: Build iOS (no sign)
run: |
xcodebuild build \
-workspace ios/App/App.xcworkspace \
-scheme App \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO
```
#### `ci-desktop.yml`
```yaml
name: CI — Desktop
on:
push:
branches: [main]
paths: ['js/**', 'css/**', 'views/**', 'electron/**', 'capacitor.config.ts']
pull_request:
paths: ['js/**', 'css/**', 'views/**', 'electron/**', 'capacitor.config.ts']
jobs:
build-desktop:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Build web assets
run: npm run build
- name: Sync Capacitor
run: npx cap sync electron
- name: Build Electron
working-directory: electron
run: npm run build
```
### Path-filtered triggers
Each native CI workflow uses `paths` filters so that:
- A CSS-only change triggers Android + iOS + Desktop builds (the web layer changed)
- A change to only `android/` triggers just the Android build
- Existing web CI (`ci.yml`) continues to run independently
### CI cost management
Native CI is more expensive than web CI:
- **iOS builds require `macos-latest` runners** (2-3x cost on GitHub Actions)
- **Android builds need JDK + Gradle** (slower startup)
- **Desktop builds run on 3 OS matrix** entries
To manage costs:
- Only run native CI on PRs that touch relevant paths
- Cache Gradle/CocoaPods/node_modules aggressively
- Consider running iOS CI only on `main` merges (not every PR) initially
- Use GitHub Actions concurrency groups with `cancel-in-progress: true`
---
## Phase 6 — Testing Strategy
### Testing layers
```
┌─────────────────────────────────────────────┐
│ Unit Tests (existing) │ ← Platform-agnostic
│ vitest — js logic, nostr, schemas, utils │ No changes needed
├─────────────────────────────────────────────┤
│ Visual Regression (existing) │ ← Web viewport
│ Playwright — screenshot comparison │ Add mobile viewports
├─────────────────────────────────────────────┤
│ E2E Web (existing) │ ← Browser-based
│ Playwright — user journeys in Chrome │ No changes needed
├─────────────────────────────────────────────┤
│ E2E Mobile (NEW) │ ← Device/emulator
│ Appium or Detox — native app user flows │
├─────────────────────────────────────────────┤
│ Platform Smoke Tests (NEW) │ ← Quick validation
│ Per-platform build + launch + basic checks │
├─────────────────────────────────────────────┤
│ Cross-Platform Behavior Tests (NEW) │ ← Feature parity
│ Verify features work consistently across │
│ web, Android, iOS, and desktop │
└─────────────────────────────────────────────┘
```
### New test categories
#### Platform smoke tests (`tests/native/`)
Quick checks that run after each native build:
- App launches without crash
- Main view renders
- WebSocket connection to a relay succeeds
- Video playback starts (URL source)
- Theme renders correctly (screenshot comparison)
#### Mobile viewport visual tests
Extend existing Playwright visual tests with mobile viewports:
```javascript
// playwright.config.ts addition
projects: [
// existing desktop viewports...
{
name: 'Mobile Android',
use: { ...devices['Pixel 7'] },
},
{
name: 'Mobile iOS',
use: { ...devices['iPhone 14'] },
},
]
```
This runs in Playwright's browser emulation (not on real devices) but catches responsive layout issues early.
#### WebView-specific E2E tests
For testing inside the actual native WebView (not just a browser), use:
- **Android**: Espresso + WebView assertions, or Appium with WebView context switching
- **iOS**: XCUITest + WebView inspection, or Appium
These are heavier to set up but catch WebView-specific issues (service worker behavior, WebRTC in WKWebView, etc).
#### What to test per platform
| Test | Web | Android | iOS | Desktop |
|------|-----|---------|-----|---------|
| Unit tests | yes | n/a | n/a | n/a |
| Visual regression (browser) | yes | mobile viewport | mobile viewport | yes |
| E2E web journeys | yes | — | — | — |
| App launches | — | yes | yes | yes |
| Relay WebSocket connects | yes | yes | yes | yes |
| Video playback (URL) | yes | yes | yes | yes |
| Video playback (WebTorrent) | yes | yes | **test carefully** | yes |
| Deep links (`nostr:`) | — | yes | yes | yes |
| Back button navigation | — | yes | — | — |
| Safe area rendering | — | — | yes | — |
| Keyboard interaction | — | yes | yes | — |
| Offline behavior | yes | yes | yes | yes |
---
## Platform Considerations & Risks
### WebTorrent on iOS (HIGH RISK)
**Issue**: WKWebView's WebRTC support is more limited than desktop Chrome. WebTorrent relies on WebRTC data channels for peer-to-peer streaming.
**Mitigation**:
- bitvid already has a URL-first playback fallback (`DEFAULT_PLAYBACK_SOURCE` in instance config)
- For iOS builds, default to URL-first playback: `DEFAULT_PLAYBACK_SOURCE: 'url'`
- Use platform detection to set this automatically:
```javascript
if (platform.isIos) {
setUrlFirstEnabled(true);
}
```
- Test WebTorrent streaming on real iOS devices — it may work in modern iOS versions (16+), but should not be the primary path
### Service worker in Capacitor WebView
**Issue**: The `sw.min.js` service worker intercepts fetch requests to proxy WebTorrent stream data. Service worker support in native WebViews varies.
**Mitigation**:
- Android: WKWebView supports service workers (API level 33+). For older Android, the URL fallback handles it.
- iOS: WKWebView supports service workers in iOS 14+, but with some caveats around scope and lifecycle.
- Test the WebTorrent streaming service worker flow specifically in native WebViews.
- If service worker is unreliable, consider a Capacitor plugin that bridges native HTTP interception as an alternative stream proxy.
### WebSocket connectivity
**Issue**: Native platforms may restrict background WebSocket connections.
**Mitigation**:
- Use `@capacitor/app` lifecycle events to gracefully disconnect/reconnect:
```javascript
App.addListener('appStateChange', ({ isActive }) => {
if (isActive) {
nostrClient.reconnect();
} else {
nostrClient.pause();
}
});
```
- This also reduces battery drain from idle relay connections.
### Content Security Policy
**Issue**: Capacitor serves web assets from a local origin (`capacitor://localhost` on iOS, `https://localhost` on Android). CSP rules may need adjustment.
**Mitigation**:
- Update `_headers` / CSP meta tags to allow the Capacitor local origin
- Allow `wss://*` for Nostr relay connections
- Allow `blob:` and `data:` for WebTorrent stream playback
### App Store rejection risk
See the [App Store Strategy](#app-store-strategy) section below.
---
## App Store Strategy
### Google Play Store
**Risk level**: Low-Medium
Google is generally more permissive. Key considerations:
- **Content rating**: Declare user-generated content (UGC) in the rating questionnaire
- **Moderation**: bitvid's existing moderation system (NIP-56 reports, NIP-51 mute lists, blur thresholds, autoplay blocking) demonstrates active content moderation
- **Crypto/payments**: Lightning/Zap functionality may trigger financial services review. Frame it as "tips" or "donations", not as a payment system. Emphasize that bitvid does not custody funds.
### Apple App Store
**Risk level**: Medium-High
Apple has been unpredictable with Nostr/decentralized apps. Key considerations:
- **Guideline 4.2 (Minimum Functionality)**: The app must provide functionality beyond a "repackaged website". Native features (deep links, share sheet, haptics, PiP) help differentiate.
- **Guideline 1.2 (User Generated Content)**: Must have content reporting, blocking, and moderation. bitvid already has this.
- **Guideline 3.1.1 (In-App Purchase)**: If Lightning zaps are interpreted as digital purchases, Apple may require IAP. Mitigation: frame zaps as peer-to-peer tips external to the app (similar to how other Nostr apps handle this).
- **Review strategy**: Submit early with a clear App Review note explaining the decentralized architecture and content moderation approach. Engage with the review team proactively.
### Desktop distribution
**Risk level**: Low
- **Windows**: Distribute via GitHub Releases initially. Microsoft Store submission optional.
- **macOS**: Notarize the DMG with an Apple Developer account. Distribute via GitHub Releases or the Mac App Store.
- **Linux**: AppImage on GitHub Releases. Snap Store / Flathub optional.
---
## Task Checklist
### Phase 1 — Foundation
- [ ] Install `@capacitor/core` and `@capacitor/cli`
- [ ] Create `capacitor.config.ts` with bitvid app ID and config
- [ ] Run `npx cap add android` and verify project structure
- [ ] Run `npx cap add ios` and verify project structure
- [ ] Set up Electron via community Capacitor plugin
- [ ] Add `js/utils/platform.js` platform detection utility
- [ ] Update `package.json` with Capacitor scripts (`cap:sync`, `cap:android`, `cap:ios`, `cap:electron`)
- [ ] Update `.gitignore` for native build artifacts
- [ ] Verify web app loads in Android emulator
- [ ] Verify web app loads in iOS simulator
- [ ] Verify web app loads in Electron window
- [ ] Verify Nostr relay connections work on all platforms
- [ ] Document local development setup for native builds
### Phase 2 — Android & iOS
- [ ] Configure Android permissions in `AndroidManifest.xml`
- [ ] Configure iOS `Info.plist` settings
- [ ] Generate app icons for all densities (Android + iOS)
- [ ] Generate splash screens
- [ ] Implement safe area CSS for iOS notch / Dynamic Island
- [ ] Add `@capacitor/status-bar` and theme it to match bitvid
- [ ] Add `@capacitor/app` for lifecycle events (background/foreground)
- [ ] Implement hardware back button handling (Android)
- [ ] Set up deep link handling for `nostr:` URIs
- [ ] Configure WebView settings for media autoplay
- [ ] Test video playback (URL source) on both platforms
- [ ] Test video playback (WebTorrent) on both platforms
- [ ] Test service worker behavior in native WebViews
- [ ] Set iOS default to URL-first playback if WebTorrent is unreliable
### Phase 3 — Desktop
- [ ] Configure Electron window defaults (size, min size, position memory)
- [ ] Set up native menu bar or hide it
- [ ] Register `nostr:` protocol handler at OS level
- [ ] Configure packaging (NSIS/DMG/AppImage)
- [ ] Test on Windows, macOS, and Linux
- [ ] Set up auto-updater with GitHub Releases feed
### Phase 4 — Platform Enhancements
- [ ] Implement native share sheet integration
- [ ] Add `@capacitor/keyboard` for mobile input handling
- [ ] Explore picture-in-picture on each platform
- [ ] Add haptic feedback for zap actions (optional)
- [ ] Implement app shell caching in service worker for offline support
### Phase 5 — CI/CD
- [ ] Create `ci-android.yml` workflow
- [ ] Create `ci-ios.yml` workflow
- [ ] Create `ci-desktop.yml` workflow (3-OS matrix)
- [ ] Add path filters to limit when native CI runs
- [ ] Set up artifact uploads for debug builds
- [ ] Configure caching (Gradle, CocoaPods, node_modules)
- [ ] Add concurrency groups to cancel stale runs
- [ ] Document release signing process (keystores, certificates, notarization)
### Phase 6 — Testing
- [ ] Add mobile viewport projects to Playwright config
- [ ] Create platform smoke test suite (`tests/native/`)
- [ ] Set up Android emulator tests in CI
- [ ] Set up iOS simulator tests in CI
- [ ] Test WebTorrent streaming in native WebViews on real devices
- [ ] Test service worker behavior across platforms
- [ ] Test deep link handling on all platforms
- [ ] Create cross-platform feature parity test matrix
- [ ] Document known platform-specific limitations in `KNOWN_ISSUES.md`
### App Store Submission
- [ ] Create Google Play developer account
- [ ] Prepare Play Store listing (screenshots, description, content rating)
- [ ] Submit Android app for review
- [ ] Create Apple Developer account (if not existing)
- [ ] Prepare App Store listing (screenshots, description, review notes)
- [ ] Submit iOS app for review
- [ ] Set up GitHub Releases for desktop distribution
- [ ] Notarize macOS DMG
---
## Related Documentation
- [Playback Fallback Architecture](playback-fallback.md) — URL-first strategy that mitigates WebTorrent issues on mobile
- [Instance Configuration](instance-config.md) — Platform-specific config overrides
- [WebTorrent Architecture](webtorrent-architecture.md) — Service worker streaming details
- [Moderation Service Overview](moderation-service-overview.md) — Content moderation (relevant for app store review)
+96
View File
@@ -0,0 +1,96 @@
# EventsCacheStore Overview
The `EventsCacheStore` is a specialized persistence layer designed to handle the application's critical state: thousands of video events and their deletion status (tombstones). It uses **IndexedDB** as the primary storage backend, falling back to `localStorage` (via `PersistenceManager`) only if IDB is unavailable.
## 1. Problem Statement
Nostr clients must handle high-volume event streams. Storing every event naively to disk on every change causes:
- **Write Trashing**: Frequent, redundant writes degrade performance and battery life.
- **I/O Blocking**: Large JSON serializations on the main thread cause UI jank.
- **Startup Latency**: Reading thousands of small files or one giant blob is slow.
## 2. Architecture: Incremental Persistence
`EventsCacheStore` solves this with a **Differential State Snapshot** approach.
### Key Concepts
1. **Memory Fingerprints**:
- The store maintains a `Map<ID, Fingerprint>` in memory, mirroring what is currently on disk.
- `Event Fingerprint`: `JSON.stringify(videoObject)` (fast enough for 1000s of items).
- `Tombstone Fingerprint`: `ts:<timestamp>`.
2. **Debounced Snapshots**:
- `PersistenceManager` debounces writes (e.g., 75ms). When a write triggers, it passes the *entire* current state (`allEvents`) plus a set of `dirtyKeys` (IDs that changed since last save).
3. **The Diff Algorithm (`_diffAndStore`)**:
- Instead of clearing and rewriting the DB, it iterates over the `dirtyKeys`.
- It computes the new fingerprint.
- **Optimization**: If `newFingerprint === persistedFingerprint`, the write is skipped.
- If different, it performs a `put()` operation and updates the memory fingerprint map.
4. **Deletion Detection**:
- After processing updates, it compares the size of the memory state (`items.size`) vs the persisted state (`fingerprintsMap.size`).
- Since `fingerprintsMap` is updated during the loop to include new items, if `items.size < fingerprintsMap.size`, it implies some items in the persisted map are no longer in memory.
- In this case, it iterates the *entire* fingerprint map to find and `delete()` the missing keys.
### Flow Diagram
```mermaid
sequenceDiagram
participant App as NostrClient
participant PM as PersistenceManager
participant ECS as EventsCacheStore
participant IDB as IndexedDB
App->>App: Event arrives (updates allEvents)
App->>PM: saveLocalData(reason, dirtyKeys)
PM->>PM: Debounce (75ms)
PM->>ECS: persistSnapshot(payload, dirtyKeys)
activate ECS
ECS->>ECS: Ensure fingerprints loaded (once)
loop For each Dirty Key
ECS->>ECS: Compute Fingerprint
alt Fingerprint Changed
ECS->>IDB: put(record)
ECS->>ECS: Update Fingerprint Map
else Same Fingerprint
ECS->>ECS: Skip
end
end
alt items.size < fingerprintsMap.size
loop For each Persisted Key
alt Not in Memory
ECS->>IDB: delete(key)
ECS->>ECS: Remove from Fingerprint Map
end
end
end
ECS-->>PM: { writes, deletes, skipped }
deactivate ECS
```
## 3. Data Schema
The IndexedDB `bitvid-events-cache` (v1) contains three object stores:
| Store | Key Path | Value Description |
| :--- | :--- | :--- |
| `events` | `id` | `{ id, video, fingerprint }` |
| `tombstones` | `key` | `{ key, timestamp, fingerprint }` |
| `meta` | `key` | `{ key: "meta", savedAt: <ts>, version: 1 }` |
## 4. Invariants & Assumptions
- **Single Threaded**: The store assumes it is the only writer. It does not handle cross-tab locking (though IDB transactions provide atomicity).
- **Memory-First**: The `allEvents` Map in memory is the source of truth. IDB is a cache.
- **TTL**: On `restoreSnapshot()`, if the data is older than the TTL (e.g. 10 minutes), the entire cache is wiped to ensure freshness.
## 5. Maintenance
- **Adding Fields**: If the `Video` object shape changes, the fingerprint (JSON string) automatically changes, forcing a rewrite. No migration needed for content changes.
- **Schema Changes**: To add a new store, increment `EVENTS_CACHE_DB_VERSION` and update `onupgradeneeded` in `getDb()`.
+2 -18
View File
@@ -28,30 +28,14 @@ an operator might change it.
debug affordances. The value is re-exported as `isDevMode` in
`js/config.js` and controls `window.__BITVID_DEV_MODE__` as well as
logger routing.
- **Why change it:** Defaults to `false` for production builds. Set to `true`
for local development, or use the runtime override described below to
toggle dev mode without editing this file.
- **Why change it:** Set to `false` for production builds so only the stable
UX and user-facing logging remain.
- **`IS_VERBOSE_DEV_MODE`**
- **What it does:** Enables high-volume diagnostics inside dev mode (for
example, repeated COUNT failures).
- **Why change it:** Keep `true` while debugging noisy workflows and set to
`false` when you want a quieter dev console without disabling dev mode.
Defaults to `false` for production.
To override the dev flags at runtime (for example in a staging build) without
changing `config/instance-config.js`, inject one or both of the following
globals **before** `js/config.js` loads:
```html
<script>
window.__BITVID_DEV_MODE_OVERRIDE__ = true;
window.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__ = true;
</script>
```
The overrides accept boolean values or the strings `"true"` / `"false"` so they
can be injected at build time.
- **`IS_LOCKDOWN_MODE`**
- **What it does:** Toggles a lockdown posture that limits access and switches
+3 -16
View File
@@ -59,23 +59,10 @@ The source of truth lives in `config/instance-config.js`:
- Set `IS_DEV_MODE = true` while developing locally so the dev logger and other
diagnostics are available.
- Keep `IS_DEV_MODE = false` for production builds. This value propagates to
`isDevMode` (the module export), `window.__BITVID_DEV_MODE__`, and
`logger.dev`, silencing development-only messages.
- Flip `IS_DEV_MODE = false` before shipping a production build. This change
propagates to `isDevMode` (the module export), `window.__BITVID_DEV_MODE__`,
and `logger.dev`, silencing development-only messages.
Always commit the flag change alongside deployment-ready configuration tweaks.
Forgetting to update the flag leaves verbose traces in user consoles and can
expose experimental behavior intended only for QA environments.
If you need to enable dev logging without editing `config/instance-config.js`,
inject a runtime override before `js/config.js` loads:
```html
<script>
window.__BITVID_DEV_MODE_OVERRIDE__ = true;
window.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__ = true;
</script>
```
Overrides accept boolean values or the strings `"true"` / `"false"` so they can
be injected by a build pipeline.
+73
View File
@@ -0,0 +1,73 @@
# ModerationService Overview
The `ModerationService` is the central authority for Trust & Safety within the application. It implements a "Web of Trust" model to filter content, aggregating reports (NIP-56) and mute lists (NIP-51) to determine the visibility and safety level of content.
## Core Concepts
### 1. Web of Trust
Instead of a centralized moderation authority, the service builds a local "trust graph" based on the user's social connections:
- **Viewer**: The currently logged-in user.
- **Trusted Contacts**: Users followed by the Viewer (Kind 3 contact list).
- **Trusted Seeds**: An optional set of admin-defined pubkeys that provide a baseline of trust (e.g., project maintainers).
- **Admin Whitelist/Blacklist**: Hardcoded lists in `accessControl` that override dynamic trust.
### 2. Trust Aggregation
When a piece of content (Event) is reported (Kind 1984), the service aggregates these reports but *only counts reports from trusted users*.
- **Untrusted Reports**: Ignored by default.
- **Trusted Reports**: Count towards the "trust score" for a specific category (e.g., "nudity", "spam").
### 3. Thresholds
The service defines thresholds for automatic actions based on the trusted report count:
- **`AUTOPLAY_TRUST_THRESHOLD` (1)**: If ≥1 trusted user reports content as NSFW/Spam, autoplay is disabled.
- **`BLUR_TRUST_THRESHOLD` (1)**: If ≥1 trusted user reports content, it is blurred by default.
- **`TRUSTED_MUTE_WINDOW_DAYS` (60)**: Mute list entries are considered valid for 60 days to prevent stale mutes from persisting forever.
## Architecture
```mermaid
graph TD
User[User / Viewer] -->|Follows| Contacts[Trusted Contacts]
Admins[Admin / Seed List] -->|Augments| Contacts
Reports[Report Events (Kind 1984)] -->|Ingested| Service[Moderation Service]
Contacts -->|Filters| Service
Service -->|Aggregates| Summary[Trusted Report Summary]
Summary -->|Checks Thresholds| UI[UI Components]
UI -->|Blur / Hide| Content
```
## Data Flow
1. **Initialization**:
- The service initializes with empty sets.
- When `setViewerPubkey(pubkey)` is called, it fetches the user's Kind 3 Contact List.
- It merges these contacts with any configured `trustedSeedContacts`.
2. **Report Ingestion**:
- The service subscribes to Kind 1984 events referencing active content.
- When a report arrives, `ingestReportEvent` checks if the reporter is in the `trustedContacts` set.
- If trusted, the report is indexed by `targetEventId` -> `reportType`.
3. **Summary Recomputation**:
- `recomputeSummaryForEvent` calculates the total number of *trusted* reports per category.
- It emits a `summary` event, which UI components listen to (e.g., `VideoCard`).
- It logs "threshold transitions" (e.g., "autoplay-block-enabled") to the user logger.
4. **Muting**:
- **Viewer Mutes**: The user's own Kind 10000 mute list. Directly hides content.
- **Trusted Mutes**: Aggregates mute lists from trusted contacts. If enough trusted users mute an author, that author may be flagged (feature partially implemented via `trustedMutedAuthors`).
## Key Methods
- `setViewerPubkey(pubkey)`: Switches the active user context, reloading trust lists.
- `submitReport({ eventId, type, ... })`: Publishes a NIP-56 report to relays.
- `getTrustedReportSummary(eventId)`: Returns the current trust status for an event.
- `getAccessControlStatus(pubkey)`: Checks against the hardcoded Admin Whitelist/Blacklist.
## Internal Utilities
The file includes internal implementations of `bech32` encoding/decoding. This is done to:
1. Reduce external dependencies for critical path logic.
2. Ensure consistent handling of `npub` / `hex` conversions without relying on the global `NostrTools` object potentially being unavailable or different versions.
+99
View File
@@ -0,0 +1,99 @@
# Moderation Service Overview
**File**: `js/services/moderationService.js`
## Purpose
The `ModerationService` implements the decentralized Trust & Safety model for bitvid. It filters content based on a "Web of Trust" derived from the current user's social graph (NIP-02 Contact List, Kind 3) and administrative overrides.
Instead of a central authority deciding what is "safe," the service aggregates reports (NIP-56) only from users the viewer follows (their "trusted contacts"). This creates a personalized moderation layer where spam and abuse are filtered by community consensus within the user's network.
## Key Concepts
### Web of Trust
- **Viewer Context**: The service tracks the currently logged-in user (`viewerPubkey`).
- **Trusted Contacts**: The set of pubkeys the viewer follows (from their Kind 3 event).
- **Trust Score**: The number of unique trusted users who have reported a specific piece of content.
- **Thresholds**: Actions (e.g., blurring, disabling autoplay) are triggered when the Trust Score exceeds defined limits (`AUTOPLAY_TRUST_THRESHOLD`, `BLUR_TRUST_THRESHOLD`).
### NIP-56 Reports (Kind 1984)
- Users can report content for reasons like `nudity`, `spam`, `illegal`, etc.
- Reports are public events signed by the reporter.
- The service subscribes to reports for active content and aggregates them locally.
- **Crucially**: Reports from users *outside* the trusted contact list are ignored (unless whitelisted by admin).
### NIP-51 Mutes (Kind 10000)
- Users can mute other users to hide their content entirely.
- The service syncs with `UserBlocks` to enforce mutes.
- It also supports "Trusted Mutes" where mutes from trusted contacts can influence visibility (though this feature is evolving).
### Admin Overrides
- The service integrates with `AccessControl` to respect an instance-wide **Blacklist** (always hidden) and **Whitelist** (always trusted).
## Architecture & Data Flow
### 1. Initialization (Setting the Viewer)
When a user logs in, `setViewerPubkey(pubkey)` is called:
1. The service fetches the user's latest Kind 3 Contact List.
2. It extracts all followed pubkeys into `trustedContacts`.
3. It merges any admin-defined "Trust Seeds" (default trusted users).
4. It recomputes trust scores for all currently loaded content based on the new graph.
### 2. Report Aggregation
When viewing a video, the app calls `subscribeToReports(eventId)`:
1. The service subscribes to Kind 1984 events referencing the video ID.
2. Incoming reports are ingested via `ingestReportEvent`.
3. `recomputeSummaryForEvent` checks if the reporter is in `trustedContacts`.
4. If trusted, the report increments the score for its category.
5. If the score crosses a threshold, the service emits a `summary` event, updating the UI (e.g., to blur the video).
## Usage Example
```javascript
import moderationService from './services/moderationService.js';
// 1. Initialize with the logged-in user
await moderationService.setViewerPubkey(myPubkey);
// 2. Subscribe to reports for a video
await moderationService.subscribeToReports(videoEventId);
// 3. Listen for trust score updates
const unsubscribe = moderationService.on('summary', ({ eventId, summary }) => {
if (summary.totalTrusted >= 1) {
console.log(`Video ${eventId} is flagged by trusted users!`);
// UI logic: blur thumbnail, show warning
}
});
// 4. Submit a report (as the viewer)
await moderationService.submitReport({
eventId: videoEventId,
type: 'nudity',
targetPubkey: videoAuthorPubkey,
content: 'Contains explicit content'
});
```
## Public API Summary
| Method | Description |
| :--- | :--- |
| `setViewerPubkey(pubkey)` | Sets the active user and rebuilds the trust graph from their contacts. |
| `subscribeToReports(eventId)` | Starts listening for NIP-56 reports for the given event ID. |
| `submitReport(params)` | Publishes a signed Kind 1984 report event to relays. |
| `getTrustedReportSummary(eventId)` | Returns the current aggregated trust score and report types. |
| `isPubkeyBlockedByViewer(pubkey)` | Checks if the author is blocked/muted by the viewer. |
| `refreshViewerFromClient()` | Helper to sync the viewer with the active Nostr client state. |
## Invariants
- **Trust is Subjective**: A report only counts if the *viewer* follows the reporter. Changing the viewer changes all trust scores immediately.
- **Admin Trumps Trust**: Blacklisted users are ignored even if followed. Whitelisted users are trusted even if not followed.
- **Reactive**: The service emits events (`summary`, `contacts`, `user-blocks`) to drive UI updates; it does not directly manipulate the DOM.
## When to Change
- **Refactoring**: Logic for "Trusted Mutes" (NIP-51 lists from others) is complex and partially deprecated/evolved. This area may need cleanup.
- **Performance**: If the number of reports or contacts grows large, the in-memory aggregation (`reportEvents` map) might need optimization or worker offloading.
- **Protocol Updates**: Changes to NIP-56 or NIP-51 should be reflected here.
+53
View File
@@ -0,0 +1,53 @@
# NIP-71 Video Events Module (`js/nostr/nip71.js`)
This module implements the **NIP-71** specification for video events in BitVid. It handles the lifecycle of video metadata, including creation, parsing, caching, and association with playable video notes (Kind 30078).
## Core Responsibilities
1. **Event Building**: Constructs NIP-71 events (Kind 22/21) with standardized tags (`imeta`, `segment`, `p`, etc.).
2. **Parsing**: Extracts structured metadata from raw Nostr events.
3. **Caching**: Manages a memory cache of NIP-71 metadata, indexed by video root ID, event ID, and `d` tags.
4. **Hydration**: Merges cached NIP-71 metadata into legacy or standard video objects for the UI.
## Typical Data Flow
### 1. Publishing a Video
When a user uploads a video, `Nip71FormManager` (in `js/ui/`) collects metadata and calls:
```javascript
import { buildNip71VideoEvent } from './js/nostr/nip71.js';
const event = buildNip71VideoEvent({
metadata: { ... }, // title, summary, imeta, etc.
pubkey: userPubkey,
title: "My Video",
pointerIdentifiers: { videoRootId: "..." }
});
// Sign and publish `event`
```
### 2. Viewing the Feed
The app fetches video notes (Kind 30078) and potential NIP-71 metadata events (Kind 22/21).
1. **Ingestion**: `processNip71Events(events, { nip71Cache })` parses incoming NIP-71 events and updates the cache.
2. **Hydration**: `mergeNip71MetadataIntoVideo(video, { nip71Cache })` attaches the best available metadata to the video object.
- It checks `videoRootId` -> `eventId` -> `d` tag priority.
- Falls back to the most recent metadata if specific pointers miss.
## Key Exports
| Function | Purpose |
|----------|---------|
| `buildNip71VideoEvent` | Creates a signed-ready NIP-71 event object. |
| `extractNip71MetadataFromTags` | Parses a raw event into `{ metadata, pointers }`. |
| `processNip71Events` | Updates the global `nip71Cache` with new events. |
| `mergeNip71MetadataIntoVideo` | Enriches a video object with cached metadata. |
| `populateNip71MetadataForVideos` | Batch-fetches metadata for a list of videos (if missing). |
## Invariants & Assumptions
- **Video Root ID**: The stable identifier for a video series/content, usually the `d` tag of the first Kind 30078 note.
- **Cache Structure**: The `nip71Cache` is a `Map<VideoRootId, CacheEntry>`.
- **Immutability**: Metadata objects in the cache are cloned before being attached to video objects to prevent mutation side-effects.
## When to Refactor
- If the cache logic becomes too complex or memory-intensive, move `nip71Cache` to a dedicated `Nip71Service` or `CacheService`.
- If NIP-71 spec changes significantly, versioning logic in `extractNip71MetadataFromTags` may need expansion.
+70 -54
View File
@@ -1,76 +1,92 @@
# NostrClient Architecture Overview
# NostrClient Overview
The `NostrClient` (`js/nostr/client.js`) is the central controller for all Nostr network interactions in the application. It acts as a singleton facade over the `nostr-tools` library, managing relay connections, event subscriptions, state synchronization, and cryptographic signing.
`NostrClient` (`js/nostr/client.js`) is the central controller for all Nostr network interactions, state management, and persistence in the application. It acts as the bridge between the raw Nostr protocol (relays, events) and the application's UI domain model (Videos, Profiles).
## Core Responsibilities
1. **Connection Management**: Maintains a persistent connection pool (`SimplePool`) to multiple relays.
2. **State Management**: Tracks the global state of video events, including history, active versions, and deletions.
3. **Event Ingestion**: Handles high-volume event streams with buffering and deduplication.
4. **Caching**: Implements a persistent IndexedDB cache to support "Stale-While-Revalidate" loading (instant UI render).
5. **Signer Orchestration**: Manages NIP-07 (Browser Extension), NIP-46 (Remote Signer), and Local (nsec) signers seamlessly.
1. **Connection Management**: Maintains a pool of WebSocket connections to relays via `nostr-tools/SimplePool`.
2. **Event Processing**: Subscribes to event streams, buffers incoming bursts, validates schemas, and converts raw events into domain objects.
3. **State Management**:
* **`allEvents`**: A complete history of every video version fetched.
* **`activeMap`**: A materialized view of the *latest* valid version of each video series (deduplicated by Root ID).
* **`tombstones`**: A set of deletion timestamps to prevent "zombie" events (deleted items reappearing from old caches) from being displayed.
4. **Persistence**: Implements a "Stale-While-Revalidate" strategy using IndexedDB (with LocalStorage fallback) to restore the UI instantly on load.
5. **Signer Orchestration**: Manages NIP-07 (Browser Extension), NIP-46 (Remote/Bunker), and Local (nsec) signers.
## State Management
## State Management Architecture
The client maintains three primary state maps to ensure data consistency:
The client maintains three primary maps to handle the "Eventual Consistency" nature of Nostr:
* **`allEvents`** (`Map<string, VideoObject>`):
* Stores *every* valid video event fetched from relays, indexed by Event ID.
* Used for history preservation and resolving direct links to specific versions.
* **`allEvents` (`Map<EventID, Video>`)**:
* Stores every valid video event received.
* Used for history hydration (viewing edit logs) and debugging.
* **`activeMap` (`Map<RootID, Video>`)**:
* Stores only the *most recent* version of a video.
* Keyed by `videoRootId` (or `pubkey:dTag` for legacy).
* **Invariant**: `activeMap.get(id).created_at` is always >= any other version of that ID.
* **`tombstones` (`Map<RootID, Timestamp>`)**:
* Stores the timestamp of the latest deletion event for a video.
* **Invariant**: Any event with `created_at <= tombstone` is implicitly treated as deleted, even if the "deleted" flag isn't set on the event itself. This handles out-of-order delivery where a "delete" arrives before the "video" it deletes.
* **`activeMap`** (`Map<string, VideoObject>`):
* The "materialized view" used by the UI.
* Keys are `videoRootId` (prefixed with `ROOT:`) for versioned edits, or `pubkey:dTag` for addressable events.
* Values are the *latest* valid version of the video.
* **Invariant**: Only one entry exists per "video entity", ensuring the UI displays the most recent edit.
## Caching Strategy
* **`tombstones`** (`Map<string, timestamp>`):
* Tracks deletions (Kind 5).
* Keys are the `activeKey` of the deleted item.
* Values are the timestamp of the deletion.
* **Invariant**: Events older than the tombstone are strictly ignored, preventing "zombie" events from reappearing.
The client uses a multi-layered approach to ensure fast boot times:
## Data Ingestion Flow
1. **IndexedDB (`events` store)**:
* Primary cache for video events.
* Stores fingerprints of objects to avoid redundant writes.
2. **LocalStorage (`bitvid:eventsCache:v1`)**:
* Fallback if IndexedDB is unavailable.
* Size-limited (typically < 5MB).
3. **Buffering**:
* Incoming events are buffered in memory and flushed to IDB periodically (debounced) to avoid I/O thrashing during heavy relay traffic.
The `subscribeVideos` method implements a buffered ingestion pipeline to handle high-throughput streams without freezing the UI:
## Event Processing (Buffering & Debouncing)
1. **Subscription**: Opens a subscription to all connected relays.
2. **Buffering**: Incoming events are pushed to an `eventBuffer` array immediately.
3. **Debouncing**: A timer flushes the buffer every ~75ms (or when full).
4. **Processing (Flush)**:
* Events are parsed and validated.
* **Tombstone Check**: If an event predates a known deletion, it is discarded.
* **Latest-Wins Resolution**: The new event's `created_at` is compared against the existing entry in `activeMap`.
* **Update**: If the new event is newer, `activeMap` is updated, and the UI callback is triggered.
5. **Persistence**: The updated state is asynchronously persisted to IndexedDB.
When `subscribeVideos` is called, the client expects a flood of events (e.g., 500+ items from relay history). Processing these one-by-one would freeze the UI.
### Flow
```mermaid
graph TD
Relay[Relays] -->|Event Stream| Buffer[Event Buffer]
Buffer -->|Debounce Timer| Processor[Processor]
Processor -->|Check| Tombstones{Is Deleted?}
Tombstones -->|Yes| Discard[Discard]
Tombstones -->|No| Compare{Is Newer?}
Compare -->|No| Discard
Compare -->|Yes| ActiveMap[Update ActiveMap]
ActiveMap --> UI[Update UI]
ActiveMap --> IDB[(IndexedDB Cache)]
sequenceDiagram
participant Relay
participant ClientBuffer
participant Processor
participant UI
Relay->>ClientBuffer: Event 1
Relay->>ClientBuffer: Event 2
Relay->>ClientBuffer: ... Event 50
ClientBuffer->>ClientBuffer: Buffer (Debounce 75ms)
loop Flush Timer
ClientBuffer->>Processor: Flush Batch (50 events)
Processor->>Processor: Validate & Convert
Processor->>Processor: Apply Tombstones
Processor->>Processor: Update activeMap (Last-Write-Wins)
Processor->>UI: Callback(onVideo) (Single Render)
Processor->>IndexedDB: Persist Snapshot
end
```
## Caching Strategy (`EventsCacheStore`)
## History Reconstruction
To provide an "app-like" experience, the client persists its state to IndexedDB (`bitvid-events-cache`).
Videos in Nostr are immutable events. "Editing" means publishing a new event with the same identifier. `NostrClient` reconstructs the history chain using:
* **Fingerprinting**: To minimize I/O, the store tracks JSON fingerprints (hashes) of all persisted events.
* **Incremental Persistence**: Only changed events (where fingerprint differs) are written to IDB during a snapshot.
* **Stale-While-Revalidate**: On startup (`init()`), the client immediately loads data from IDB to render the UI, then connects to relays to fetch updates.
1. **`videoRootId` (V3)**: The canonical identifier for a video series.
2. **`d` Tag (NIP-33)**: The addressable identifier.
3. **Legacy IDs**: Fallback to Event ID for V1/V2 posts.
## Signer Integration
`hydrateVideoHistory` fetches all events matching these identifiers, sorts them by `created_at` descending, and returns the full edit log.
The client abstracts the signing mechanism, allowing the user to switch between methods without changing application logic:
## Why it works this way
1. **NIP-07 (Extension)**: Delegates signing to a browser extension (e.g., Alby, nos2x).
2. **NIP-46 (Remote/Bunker)**: Uses a partial `NostrClient` instance to communicate with a remote signer via a relay.
3. **Local (NIP-01)**: Uses a local private key (nsec) stored in `sessionStorage` (encrypted).
* **Eventual Consistency**: The `tombstones` map is essential because relays guarantee neither order nor completeness. A client might receive a "delete" event for a video it hasn't seen yet, or receive an old version of a video after receiving the deletion. Tombstones ensure that once something is known to be deleted, it stays deleted (until a newer re-creation event appears).
* **Performance**: The `eventBuffer` mechanism allows the client to ingest thousands of events without blocking the main thread, as React rendering is expensive. Batching updates reduces render cycles from O(N) to O(1) per batch.
* **Offline-First**: Restoring from IndexedDB immediately allows the app to be interactive before any network connection is established.
The `activeSigner` object is normalized to expose a standard interface (`signEvent`, `nip04Encrypt`, `nip44Encrypt`), regardless of the underlying implementation.
## When to change
* **Storage Backend**: If the cache schema becomes too complex for IndexedDB, or if we move to a native wrapper (React Native/Tauri) with a SQL backend, the `EventsCacheStore` class should be swapped out.
* **Event Loop**: If we switch to a worker-based architecture (off-main-thread processing), the `subscribeVideos` logic would move to a Web Worker, and `NostrClient` would become a message passer.
* **Protocol Upgrades**: If NIP-33 (Parameterized Replaceable Events) is superseded or the video event kind (30078) changes, the filters in `subscribeVideos` and `fetchVideos` must be updated.
+3 -4
View File
@@ -341,12 +341,11 @@ valid events:
```bash
# Run the validation script
node scripts/agent/validate-events.mjs
node scripts/agent/validate-events.test.mjs
# Run the unit test suite
npm run test:unit
```
When modifying schemas or builders, use `scripts/agent/validate-events.mjs` to
verify your changes locally. This tool also scans the codebase to ensure all
active event builders are covered by tests.
When modifying schemas or builders, use `scripts/agent/validate-events.test.mjs` to
verify your changes locally.
+86
View File
@@ -0,0 +1,86 @@
# Nostr Event Schemas (`js/nostrEventSchemas.js`)
This module is the central definition for all Nostr event kinds, tags, and content structures used in the application. It provides builder functions to construct valid events and validation logic to ensure compliance with the defined schemas.
## Purpose
* **Centralized Definitions**: Maps application-specific event types (e.g., `VIDEO_POST`) to Nostr Kinds (e.g., `30078`) and required tags.
* **Event Builders**: Pure functions that take parameters and return a signed-ready event object (excluding the signature).
* **Validation**: Development-mode validation to catch malformed events before they are signed or published.
* **Sanitization**: Helpers to clean and normalize input data (e.g., UTF-8 enforcement, tag normalization).
## Public API
### Constants
* `NOTE_TYPES`: An enum of all supported event types (e.g., `NOTE_TYPES.VIDEO_POST`, `NOTE_TYPES.ZAP_REQUEST`).
* `KIND_MUTE_LIST`: The kind number for NIP-51 Mute Lists (10000).
### Builder Functions
Each builder function accepts a parameters object and returns a standard Nostr event object:
`{ kind, pubkey, created_at, tags, content }`.
Common parameters:
* `pubkey`: The hex public key of the author.
* `created_at`: Unix timestamp (in seconds).
* `additionalTags`: Array of extra tags to append (e.g., `[['t', 'hashtag']]`).
**Key Builders:**
* `buildVideoPostEvent(params)`: Constructs a Kind 30078 video metadata event.
* `buildVideoCommentEvent(params)`: Constructs a Kind 1111 comment event.
* `buildVideoReactionEvent(params)`: Constructs a Kind 7 reaction event.
* `buildRepostEvent(params)`: Constructs a Kind 6 (Repost) or Kind 16 (Generic Repost).
* `buildZapRequestEvent(params)`: Constructs a Kind 9734 zap request.
* `buildViewEvent(params)`: Constructs a view analytics event (Kind 30078 variant).
### Schema & Validation
* `getNostrEventSchema(type)`: Returns the schema configuration for a given `NOTE_TYPE`.
* `validateEventStructure(type, event)`: checks if an event object matches the schema for `type`.
* `setNostrEventSchemaOverrides(overrides)`: Allows runtime injection of schema overrides (useful for feature flags or testing).
## Usage Example
```javascript
import { NOTE_TYPES, buildVideoPostEvent } from './nostrEventSchemas.js';
const params = {
pubkey: '...hex_pubkey...',
created_at: Math.floor(Date.now() / 1000),
dTagValue: 'my-video-slug',
content: {
title: 'My Awesome Video',
videoRootId: 'my-video-slug',
url: 'https://example.com/video.mp4',
thumbnail: 'https://example.com/thumb.jpg'
},
additionalTags: [['t', 'art']]
};
const event = buildVideoPostEvent(params);
// Result:
// {
// kind: 30078,
// pubkey: '...',
// created_at: ...,
// tags: [['d', 'my-video-slug'], ['t', 'video'], ['t', 'art'], ['s', 'nostr:my-video-slug']],
// content: '{"title":"My Awesome Video",...}'
// }
```
## Internal Details
### Schema Configuration
Each entry in `BASE_SCHEMAS` defines:
* `kind`: The Nostr event kind.
* `requiredTagNames`: List of tag names that must be present.
* `identifierTag`: Configuration for NIP-33 `d` tags.
* `appendTags`: Fixed tags to always include.
* `content`: Format specification (`json`, `text`, `empty`, etc.).
### Sanitization
* `ensureValidUtf8Content`: Ensures the `content` field contains valid UTF-8, stripping invalid surrogates to prevent relay rejection.
* `sanitizeAdditionalTags`: Normalizes user-provided tags (trimming strings, handling pointers).
+50
View File
@@ -0,0 +1,50 @@
# Weekly Performance Report — 2026-02-13
**Agent:** `bitvid-perf-deepdive-agent`
**Focus:** Relay Request Concurrency
## Executive Summary
Identified a critical performance risk in `js/nostr/reactionEvents.js` where reaction list and publish operations were using unbounded `Promise.all` across all configured relays. This could lead to network saturation, browser connection limits (blocking other requests), and potential timeouts for users with many relays.
**Optimization:** Replaced `Promise.all` with `pMap` to enforce a concurrency limit of 3 (`RELAY_BACKGROUND_CONCURRENCY`), aligning with best practices established in `commentEvents.js`.
## Methodology
### Scenario
Simulate `listVideoReactions` call with a client configured for 20 relays.
- **Tools:** `benchmarks/reaction_loading_bench.mjs` (new harness)
- **Environment:** Node.js 22 (Agent Environment)
- **Metric:** Peak concurrent `pool.list()` calls.
### Baseline Measurements
| Metric | Value | Notes |
| :--- | :--- | :--- |
| Peak Concurrency | **20** | Unbounded; fired all requests simultaneously. |
| Total Time (Mock) | ~111ms | Limited only by the longest single request (mock delay 100ms). |
### After Optimization
| Metric | Value | Notes |
| :--- | :--- | :--- |
| Peak Concurrency | **3** | Capped by `RELAY_BACKGROUND_CONCURRENCY`. |
| Total Time (Mock) | ~705ms | Increased latency is expected and acceptable for background operations to preserve network health. |
## Changes
### 1. Concurrency Limiting in `js/nostr/reactionEvents.js`
- **Before:** `await Promise.all(relayList.map(...))`
- **After:** `await pMap(relayList, ..., { concurrency: 3 })`
- **Impact:** Prevents "thundering herd" of WebSocket requests.
### 2. New Benchmark Harness
- Added `benchmarks/reaction_loading_bench.mjs` to reproducible measure concurrency of reaction loading.
## Verification
- Unit tests (`tests/nostr/reaction-events.test.mjs`) passed.
- Benchmark confirmed concurrency cap is active.
## Next Steps
- **Audit `js/nostr/viewEvents.js`**: Similar unbounded `Promise.all` pattern exists there and should be patched in a future run.
- **Audit `js/nostr/dmSignalEvents.js`**: Another potential candidate for concurrency limiting.
+75
View File
@@ -0,0 +1,75 @@
# PlaybackService Overview
**File:** `js/services/playbackService.js`
The `PlaybackService` is the central orchestrator for video playback in bitvid. It implements the "Hybrid Playback Strategy", ensuring users get the fastest possible start (via direct URL) while maintaining the censorship-resistance of P2P (via WebTorrent) as a seamless fallback.
## Key Concepts
### 1. Hybrid Playback Strategy
The goal is to play video from a standard HTTPS URL (CDN/Server) first because it offers instant seek and low latency. If that fails (404, blocked, or stalls), the system automatically switches to the WebTorrent engine to stream the content from the P2P network using the magnet link.
### 2. PlaybackSession
Each attempt to play a video creates a `PlaybackSession`. This object encapsulates the state machine for that specific video request.
- **Request Signature:** A unique ID derived from the URL and Magnet. Used to prevent race conditions (e.g., user clicks the same video twice, or switches videos rapidly).
- **Watchdogs:** Event listeners attached to the `<video>` element that monitor for stalls, errors, or network timeouts. If a watchdog barks, the session triggers a fallback.
## Execution Flow
The `execute()` method in `PlaybackSession` drives the logic:
### URL-First (Default)
1. **Probe:** Send a HEAD request to the HTTPS URL to check reachability and CORS status.
2. **Play:** If reachable, set `video.src = url` and attempt playback.
3. **Monitor:** Attach watchdogs.
* **Success:** If `playing` event fires and progress continues, keep playing.
* **Failure:** If `error`, `stalled`, or `timeout` occurs, stop playback and trigger **Torrent Fallback**.
4. **Fallback:** Initialize WebTorrent with the magnet link.
### Torrent-First (Configurable)
If `urlFirstEnabled` is false or the user explicitly selects "P2P":
1. **Torrent:** Start WebTorrent immediately.
2. **Fallback:** If no peers are found within a timeout, fall back to the HTTPS URL (if available).
## Public API
### `playbackService.createSession(options)`
Creates a new session.
- `url`: HTTPS URL string.
- `magnet`: Magnet URI string.
- `videoElement`: The HTMLVideoElement to control.
- `forcedSource`: Optional 'url' or 'torrent' override.
### `session.start()`
Begins the playback flow. Returns a `Promise` that resolves when playback successfully starts (from either source) or rejects if both fail.
### `session.cleanupWatchdog()`
Removes all event listeners and timers. Automatically called when a session ends or is replaced.
## Example Usage
```javascript
import { playbackService } from './services/playbackService.js';
// 1. Create a session
const session = playbackService.createSession({
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:...',
videoElement: document.querySelector('video'),
// Optional callbacks
onSuccess: () => console.log('Playback started!'),
onFallback: (reason) => console.log('Switched to torrent due to:', reason)
});
// 2. Start playback
try {
const result = await session.start();
console.log(`Playing via: ${result.source}`); // 'url' or 'torrent'
} catch (err) {
console.error('Playback failed completely:', err);
}
```
## When to Refactor
- If the `execute()` method grows beyond 500 lines, consider splitting the URL and Torrent logic into separate strategy classes (`UrlPlaybackStrategy`, `TorrentPlaybackStrategy`).
- If the "Watchdog" logic becomes more complex (e.g., adaptive bitrates), extract it into a dedicated `PlaybackMonitor` class.
+76
View File
@@ -0,0 +1,76 @@
# ProfileModalController Architecture Overview
The `ProfileModalController` (`js/ui/profileModalController.js`) is the central orchestrator for the "Profile" modal in the application. This modal acts as the user's dashboard, aggregating account management, direct messages, settings, and moderation tools.
## Architecture Pattern: Facade & Orchestrator
Due to the modal's complexity, the `ProfileModalController` implements a **Facade Pattern**. It delegates domain-specific logic to specialized sub-controllers while presenting a unified interface to the main application (`bitvidApp`).
### Sub-Controllers
* **`ProfileDirectMessageController`**: Manages the Direct Messages UI, including conversation lists, message rendering, attachments, and NIP-17 sealing.
* **`ProfileRelayController`**: Handles the relay list, including adding/removing relays, restoring defaults, and displaying health metrics.
* **`ProfileWalletController`**: Manages NWC (Nostr Wallet Connect) settings and zap configurations.
* **`ProfileAdminController`**: Handles moderation lists (mute/block) and admin tools (if the user is an instance admin).
* **`ProfileModerationController`**: Manages content safety settings (blur thresholds, autoplay blocking).
* **`ProfileHashtagController`**: Manages hashtag preferences (interests/disinterests).
* **`ProfileStorageController`**: Manages R2/S3 storage credentials for file uploads.
### Responsibilities of the Main Controller
1. **Lifecycle Management**:
* `load()`: Fetches the HTML template and injects it into the DOM.
* `show(pane)` / `hide()`: Controls visibility and z-index stacking.
* `registerEventListeners()`: Binds global navigation and close buttons.
2. **Navigation State**:
* Tracks the `activePane` (e.g., 'account', 'messages', 'relays').
* Handles responsive layout switching (Mobile Menu vs. Desktop Pane).
3. **Authentication Synchronization**:
* `handleAuthLogin()`: Triggered when the user logs in. Hydrates all sub-controllers.
* `handleAuthLogout()`: Clears sensitive data (DMs, subscriptions) from the UI.
* `switchProfile()`: Orchestrates the session switch to another saved account.
4. **Shared UI Elements**:
* Manages the "Saved Profiles" switcher in the header.
* Handles the "Add Account" flow and login modal coordination.
## Initialization Flow
1. **Instantiation**: Created by `bitvidApp` with references to `services` (Nostr, State, etc.) and `callbacks`.
2. **Loading**: The `load()` method is called once. It:
* Fetches `components/profile-modal.html`.
* Injects it into the `modalContainer`.
* Caches DOM references (`cacheDomReferences()`).
* Initializes sub-controllers.
3. **Display**: When `show()` is called:
* The modal becomes visible.
* The requested pane is activated.
* Data for that pane is hydrated (e.g., fetching relay lists).
## State Management
The controller relies on a `state` object passed during construction (typically wrapping `js/state/cache.js` or similar). It avoids maintaining authoritative state internally, preferring to read from the global store and re-render.
* **Saved Profiles**: Stored in `state.getSavedProfiles()`.
* **Active User**: Derived from `state.getActivePubkey()`.
## Direct Messages Integration
The DM interface inside the modal is effectively a "mini-app". The `ProfileDirectMessageController` handles:
* Real-time subscription to Kind 4 and Kind 1059 events.
* Decryption (via `nostrClient` helpers).
* Attachment uploads (via `r2Service`).
* Typing indicators and read receipts.
## Key Invariants
1. **Sub-Controller Isolation**: Sub-controllers should not directly manipulate the DOM of other panes.
2. **Authentication Guard**: Sensitive panes (DMs, Wallet) must clear their state immediately upon logout (`handleAuthLogout`).
3. **Responsive Design**: The controller monitors window size to toggle between the mobile "Menu View" and the desktop "Pane View".
## When to Refactor
* **New Panes**: If adding a new feature pane, create a new `Profile[Feature]Controller.js` and instantiate it in the constructor. Do not add logic directly to the main file.
* **DOM Bloat**: If `cacheDomReferences` grows too large, move pane-specific selectors to their respective sub-controllers.
@@ -0,0 +1,44 @@
# Weekly Race Condition Report: 2026-02-14
## Audit Overview
- **Agent**: bitvid-race-condition-agent
- **Focus Areas**: `js/nostr/client.js`, `js/nostr/managers/ConnectionManager.js`, `js/nostr/videoEventBuffer.js`
- **Cadence**: Weekly
## Executive Summary
One critical race condition was identified in the `NostrClient` initialization logic. The client flagged itself as `isInitialized = true` synchronously at the start of the `init()` method, before the connection pool was actually ready. This allowed concurrent calls (or UI components checking the flag) to access the uninitialized pool, causing crashes (`TypeError: Cannot read properties of null`).
## Findings
### 1. Critical: NostrClient Initialization Race
- **Location**: `js/nostr/client.js`
- **Severity**: Critical
- **Description**: The `init()` method set `this.isInitialized = true` immediately to prevent re-entrant calls. However, `ensurePool()` and `connectToRelays()` are asynchronous. Any component checking `client.isInitialized` during this window would proceed to use the client and crash.
- **Interleaving**:
1. Caller A calls `client.init()`.
2. `client.isInitialized` becomes `true`.
3. Caller A awaits `restoreLocalData()` (async).
4. Caller B checks `client.isInitialized` -> `true`.
5. Caller B calls `client.subscribeVideos()`.
6. `subscribeVideos` calls `this.pool.sub`.
7. `this.pool` is `null` (because Caller A hasn't reached `ensurePool` yet).
8. Crash.
- **Fix Implemented**:
- Introduced `this.initPromise` to track the ongoing initialization.
- `init()` now returns the existing promise if called concurrently.
- `this.isInitialized` is only set to `true` **after** the pool is ready and relays are connected.
- Fix validated with a new reproduction test `tests/race/nostr-client-init-race.test.mjs`.
## Other Observations
### VideoEventBuffer (Low Risk)
- `js/nostr/videoEventBuffer.js` uses a debounce timer (`scheduleFlush`) and checks `document.hidden` to prevent UI thrashing.
- A potential "render race" exists where `flush()` triggers `onVideo` before NIP-71 metadata is fully hydrated. This is an intended trade-off for responsiveness (Stale-While-Revalidate pattern) and not a data corruption bug.
### ConnectionManager (Safe)
- `js/nostr/managers/ConnectionManager.js` properly guards `ensurePool` with `this.poolPromise`, which prevented double-instantiation but didn't protect the `NostrClient` wrapper from the race described above.
## Next Steps
- **Next Audit**: Review `js/nostr/nip07Permissions.js` and `js/nostr/nip46Client.js` for signer negotiation races.
- **Monitoring**: Watch for `Cannot read properties of null` errors in Sentry related to `pool.sub`.
+115
View File
@@ -0,0 +1,115 @@
# Scheduled Agent Prompts
Copy-paste these prompts to schedule recurring maintenance tasks for AI agents.
Each prompt is self-contained and targets one specific problem. Run them against
the `unstable` branch.
---
## 1. File Decomposition (run weekly or biweekly)
**Best agent:** Claude Code (needs full codebase context for safe refactoring)
```
Target the `unstable` branch. Run `node scripts/check-file-size.mjs --report`
to see all grandfathered oversized files and their line counts.
Pick the SINGLE largest grandfathered file that has not been decomposed recently.
Do NOT try to decompose multiple files in one PR.
For the chosen file:
1. Read the entire file and identify 2-3 cohesive blocks of logic that can be
extracted into separate modules (look for: groups of related helper functions,
self-contained class methods that could become a separate service, rendering
logic that could become its own controller).
2. Extract each block into a new file in the same directory.
3. Export the extracted code and import it from the original file.
4. Run `npm run lint` and `npm run test:unit` to verify nothing broke.
5. Update the grandfathered line count in `scripts/check-file-size.mjs` to
reflect the new (smaller) size of the original file.
6. Commit with message prefix `[decompose]` and create a PR targeting `unstable`.
Goal: reduce the file by at least 200 lines per session. Do not change any
behavior — this is a pure extraction refactor.
```
---
## 2. innerHTML Migration (run weekly or biweekly)
**Best agent:** Claude Code or Codex (scoped to individual files)
```
Target the `unstable` branch. Run `node scripts/check-innerhtml.mjs --report`
to see all files with innerHTML usage and their counts.
Pick ONE file from the list (prefer files with the highest count or files
tagged RISKY in the audit). Do NOT try to migrate multiple files at once.
For the chosen file:
1. Read the file and find all innerHTML assignments.
2. For each assignment, determine if it can be replaced with safe DOM APIs:
- Static text: use `element.textContent = "..."`.
- Creating elements: use `document.createElement()` + `appendChild()`.
- Template HTML with user data: ensure ALL interpolated values pass through
`escapeHtml()` from `js/utils/domUtils.js`.
3. Replace as many innerHTML assignments as possible with safe alternatives.
4. Run `npm run lint` and `npm run test:unit` to verify nothing broke.
5. Update the baseline: run `node scripts/check-innerhtml.mjs --update` and
copy the new BASELINE object into `scripts/check-innerhtml.mjs`.
6. Commit with message prefix `[security]` and create a PR targeting `unstable`.
Priority order for migration:
- RISKY files first (user data without escaping)
- High-count files next (profileModalController, searchView, channelProfile)
- Low-count static files last (these are low risk)
```
---
## 3. Constant Deduplication Audit (run monthly)
**Best agent:** Any agent (simple search task)
```
Target the `unstable` branch.
Search for numeric constants in `js/` that appear in multiple files with
the same value and similar purpose. Focus on:
- Timeout values (5000, 10000, 30000, 60000, etc.)
- Retry counts
- Cache TTLs
- Threshold values
For each duplicate found:
1. Determine the canonical location (check `js/constants.js`,
`js/nostr/relayConstants.js`, `js/nostr/cachePolicies.js`).
2. If a canonical location exists, replace the duplicate with an import.
3. If no canonical location exists, create one in the most appropriate
shared module.
4. Run `npm run lint` and `npm run test:unit`.
5. Commit with message prefix `[constants]` and create a PR targeting `unstable`.
```
---
## 4. Code Health Dashboard (run weekly)
**Best agent:** Any agent (read-only audit)
```
Target the `unstable` branch. Run all three audit scripts in report mode:
1. `node scripts/check-file-size.mjs --report`
2. `node scripts/check-innerhtml.mjs --report`
3. `npm run lint`
Report the following metrics:
- Total grandfathered oversized files and sum of excess lines
- Total innerHTML assignments across all files
- Any lint failures
Compare against the previous week's report (if available in issues/PRs).
Post the results as a GitHub issue comment or a summary in the PR description
so the maintainer can track progress.
```
+77
View File
@@ -0,0 +1,77 @@
# Smoke Test Harness
The `scripts/agent/smoke-test.mjs` script verifies critical application flows (login, relay connection, publishing, DM decryption) using a headless browser (Playwright) and a Node.js Nostr client.
It is designed to be lightweight, fast, and suitable for both local development and CI/CD pipelines.
## Features
- **Ephemeral Keys**: Generates fresh keys for every run; never touches your private keys.
- **Headless Browser**: Uses Playwright to verify UI rendering and login state.
- **End-to-End Verification**: Publishes events via Node.js client and verifies them in the browser, and vice-versa.
- **Artifact Generation**: produces logs, JSON summaries, and screenshots on failure (or success).
- **Flexible Configuration**: Supports local relays, external relays, and custom timeouts.
## Usage
### Local Development (Default)
Runs with a local ephemeral relay (port 8899) and starts the app with `npx serve` (port 8000).
```bash
npm run test:smoke
# OR
node scripts/agent/smoke-test.mjs
```
### Custom Output Directory
```bash
node scripts/agent/smoke-test.mjs --out=artifacts/my-run
```
### External Relays
To test against real relays (e.g., in a staging environment):
```bash
node scripts/agent/smoke-test.mjs --relays="wss://relay.example.com,wss://relay2.example.com" --serve=none --confirm-public
```
**Note**: When using public/external relays, you must provide `--confirm-public` to acknowledge that test data will be published. The test uses a unique `d` tag and ephemeral keys to minimize pollution, but always use test-specific relays when possible.
### CLI Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--relays` | string | `ws://localhost:8899` | Comma-separated list of relay URLs. |
| `--serve` | string | `npx` | How to serve the app: `npx`, `python`, or `none`. |
| `--out` | string | `artifacts` | Directory to save logs, screenshots, and summaries. |
| `--timeout` | number | `30` | Timeout in seconds for UI and network operations. |
| `--burst` | number | `1` | (Unused currently) Number of events to publish in burst. |
| `--dry-run` | boolean | `false` | If true, skips publishing and browser interactions. |
| `--confirm-public` | boolean | `false` | Required if non-localhost relays are used. |
## Artifacts
After a run, check the output directory (default `artifacts/`):
- `smoke-YYYYMMDD.log`: Full execution log.
- `smoke-summary-YYYYMMDD.json`: Structured summary (status, event IDs, pubkeys).
- `smoke-test-ui.png`: Screenshot of the feed (success state).
- `smoke-fail-*.png`: Screenshots captured upon failure (login or feed).
## CI Integration
To run this in GitHub Actions or other CI:
```yaml
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Build App
run: npm run build
- name: Run Smoke Test
run: node scripts/agent/smoke-test.mjs --serve=npx --timeout=60
```
+148
View File
@@ -0,0 +1,148 @@
# High-Quality, Low-Flake Playwright Testing Guide
This guide defines how we write and evolve Playwright tests in bitvid so they stay reliable while increasing coverage.
## Goals
1. Catch user-visible regressions early.
2. Keep CI signal trustworthy (low flake rate).
3. Improve runtime coverage with deterministic tests, not brittle ones.
## Core Principles
1. Test behavior, not implementation details.
2. Prefer deterministic setup over live-network dependence.
3. Wait for explicit app state, not arbitrary sleep.
4. Use resilient selectors and scoped locators.
5. Keep assertions strict on outcomes, flexible on timing and non-critical internals.
## Determinism First
Use the existing harness and fixture stack:
- `tests/e2e/helpers/bitvidTestFixture.ts`
- `window.__bitvidTest__` APIs in `js/testHarness.js`
Prefer these patterns:
1. Use seeded relay data instead of remote relays.
2. Use programmatic login (`loginAs`) instead of manual UI auth for most tests.
3. Use harness APIs to force edge conditions (`setSignerDecryptBehavior`, relay overrides).
4. Open complex modals via deterministic controller/harness methods when available.
Why: nondeterministic setup is the top source of flaky tests.
## Selector Strategy
1. Prefer `data-testid` selectors.
2. Scope locators to visible/active containers when duplicates can exist.
3. Avoid broad selectors like `button` unless nested under a stable root.
Example:
- Better: `#playerModal #closeModal:visible`
- Worse: `#closeModal` (can resolve hidden duplicate elements)
## Waiting Strategy (Do This, Not That)
Do:
1. Wait for explicit state changes (`data-open="true"`, absence of `.hidden`, expected text/attribute).
2. Use `waitForFunction` only with concrete conditions and clear timeouts.
3. Add fallback action paths for known intermittent UI event misses when justified.
Avoid:
1. `waitForTimeout(...)` as primary synchronization.
2. Assuming a click always opens a modal immediately.
3. Chained interactions without state confirmation between steps.
## Modal Testing Pattern
Recommended flow:
1. Trigger modal open via deterministic helper (harness/controller), fallback to UI click.
2. Wait for modal-open condition.
3. Assert visible + semantic state (`role`, `aria-modal`, `data-open`).
4. Interact with modal.
5. Close modal and verify closed state.
If your tests repeatedly fail opening the same modal, add a harness helper for that modal instead of adding sleeps.
## Accessibility Test Stability
Accessibility tests are especially prone to timing flake. For keyboard and modal tests:
1. Ensure focus target exists and is visible before key presses.
2. Prefer element-scoped key actions (`locator.press("Enter")`) over global keyboard when possible.
3. Verify modal opened before asserting internals.
4. For Escape-close tests, first confirm modal open state deterministically.
## Coverage Without Brittle UI Tests
Not all coverage gains require full UI choreography.
Use runtime module tests in browser context for pure/mostly-pure modules:
1. `page.evaluate(async () => import("/js/module.js"))`
2. Execute exported APIs with deterministic inputs.
3. Assert normalized outputs and edge cases.
This is effective for utilities, state transforms, and view-logic modules that are hard to reach through full flows.
## Anti-Patterns to Avoid
1. Duplicate clicks on close/open controls “just in case.”
2. Assertions that only check element attachment but not visible state when visibility matters.
3. Overly permissive assertions (`expect(x || true).toBe(true)`) unless documented as intentional.
4. Tests that rely on console noise rather than asserting intended state transitions.
## Flake Triage Workflow
When a test flakes:
1. Reproduce with repeats:
- `npx playwright test <spec> --project=e2e --repeat-each=5`
2. Identify failure type:
- setup nondeterminism
- selector ambiguity
- missing state wait
- race in app init
3. Fix in this order:
- add deterministic harness/controller hook
- tighten selectors
- add explicit state waits
- only then consider timeout tuning
4. Re-run repeats and then run coverage lane.
## Quality Gates for Sustainable Improvement
1. Keep `npm run test:e2e:coverage` green before raising coverage thresholds.
2. Track coverage trend, but do not trade reliability for raw percentage.
3. Ratchet coverage targets gradually (example: 40% -> 45% -> 50%).
4. Require flaky-test fixes to include deterministic synchronization changes, not just longer timeouts.
## PR Checklist for E2E Changes
1. Does this test use deterministic fixture/harness setup?
2. Are selectors stable (`data-testid` or scoped stable IDs)?
3. Are waits condition-based (not sleep-based)?
4. Does the test fail for real regressions and pass reliably under repeats?
5. If adding coverage tests, do they increase useful coverage in high-risk paths?
## Useful Commands
1. Single spec:
- `npx playwright test tests/e2e/<spec>.spec.ts --project=e2e`
2. Repeat to detect flake:
- `npx playwright test tests/e2e/<spec>.spec.ts --project=e2e --repeat-each=5`
3. Full deterministic E2E coverage run:
- `npm run test:e2e:coverage`
4. Regenerate report from existing raw artifacts:
- `npm run test:e2e:coverage:report`
## Related Docs
- `docs/testing/playwright-function-coverage.md`
- `docs/testing/playwright-integration-recommendations-2026-02-22.md`
- `AGENTS.md` (testing infrastructure and harness references)
+90
View File
@@ -0,0 +1,90 @@
# Playwright Function Coverage + Console Diagnostics
Use this workflow to measure how much of bitvid's runtime code is exercised by E2E Playwright tests and to export browser console/page errors for triage.
## Run
```bash
npm run test:e2e:coverage
```
This command:
1. Builds `dist/`
2. Runs `tests/e2e` on Chromium with browser JS coverage enabled
3. Captures browser `console` and `pageerror` output for every test
4. Generates aggregate reports
## Artifacts
- `artifacts/playwright-coverage/raw/*.json`
- Per-test raw coverage + console/page-error logs
- `artifacts/playwright-coverage/function-coverage-summary.json`
- Machine-readable function coverage report
- `artifacts/playwright-coverage/function-coverage-summary.md`
- Human-readable summary with coverage percentage and low-coverage files
- `artifacts/playwright-coverage/console-log-summary.json`
- Full exported console/page-error logs
- `artifacts/playwright-coverage/console-issues.log`
- Grouped issue signals for fast triage
## Notes
- Coverage is captured from Chromium runtime execution (`page.coverage`), then combined with static function estimates for source files in `js/` and `torrent/`.
- Firefox coverage is not included in this report because Playwright JS coverage is Chromium-only.
- For reliability patterns while increasing coverage, see `docs/testing/high-quality-playwright-tests.md`.
## Current Baseline (2026-02-22)
From a full real run (`npm run test:e2e:coverage`) on 2026-02-22:
- E2E result: `40 passed`
- Function coverage: `29.9%` (`2863/9576`)
- Loaded source files: `297/313`
- Console logs: `72`
- Console error-like entries: `4`
- Page errors: `0`
Primary issue groups captured:
1. `404 Not Found` resource errors (2 occurrences)
2. Forced decrypt-timeout warning in diagnostics test (expected by test, 1 occurrence)
3. CORS-blocked `.torrent` fetch in webseed playback test (1 occurrence)
4. `net::ERR_FAILED` in webseed playback test (1 occurrence)
5. WebSocket tracker handshake failure for `wss://tracker.btorrent.xyz/` (1 occurrence)
Recent high-impact coverage improvements:
- `js/nostr/commentEvents.js`: `90.91%` (`20/20`)
- `js/nostr/reactionEvents.js`: `78.57%` (`11/14`)
- `js/nostr/publishHelpers.js`: `79.41%` (`27/34`)
- `js/nostr/sessionActor.js`: `79.41%` (`27/34`)
## Integration Rules For Agents
1. For `tests/e2e/*.spec.ts`, import from `tests/e2e/helpers/instrumentedTest.ts` so coverage/log capture stays enabled.
2. For harness-based tests, use `tests/e2e/helpers/bitvidTestFixture.ts` (it already includes auto-capture).
3. Keep coverage runs Chromium-only when collecting percentages (`page.coverage` is Chromium-specific).
4. Do not remove `playwright.config.ts` global setup (`tests/e2e/helpers/playwrightCoverageGlobalSetup.ts`), which resets raw coverage artifacts.
5. Use `npm run test:e2e:coverage:report` only after a prior coverage-enabled run; it reads existing raw artifacts.
## How To Improve Coverage
Priority order for fastest gains:
1. Add E2E for high-function files currently at `0%` but loaded:
- Start with `js/nostr/commentEvents.js`, `js/nostr/reactionEvents.js`, `js/nostr/publishHelpers.js`, `js/nostr/sessionActor.js`.
2. Add deterministic DM coverage lane in E2E harness:
- Exercise NIP-04 and NIP-44 decrypt/encrypt flows through UI and harness hooks.
3. Add upload + publish deep-path tests:
- Validate URL-only, magnet-only, and dual-source publish paths with assertions on fallback behavior.
4. Add negative-path assertions that currently only surface in console:
- Explicitly assert/handle CORS/torrent fetch failures and tracker handshake failures.
5. Keep tests routed through test-harness APIs:
- Prefer `setTestRelays`, `setSignerDecryptBehavior`, and seeded relay events over live-network dependence.
## Suggested Quality Gates
1. Track `functionCoveragePct` trend in CI artifacts per PR.
2. Add a soft warning threshold first (for example `>=30%`), then ratchet upward as flaky paths are stabilized.
3. Fail PRs only after baseline is stable across multiple runs.
@@ -0,0 +1,63 @@
# Playwright Integration Recommendations (2026-02-22)
This document captures practical follow-ups for improving bitvid's end-to-end and extension integration testing.
## What is now in place
1. Deterministic E2E matrix projects:
- Chromium (`e2e`)
- Firefox (`e2e-firefox`)
2. Optional extension project:
- `e2e-nip07-extension` (opt-in via `PLAYWRIGHT_ENABLE_EXTENSION_E2E=1` and `PLAYWRIGHT_EXTENSION_PATH`)
3. Persistent-context extension launcher:
- Script: `scripts/playwright/run-extension-persistent.mjs`
- Command: `npm run test:e2e:extension`
- Purpose: run extension tests in Chromium with a real persistent profile context.
4. Test harness upgrades:
- `setTestRelays(urls)` aligns `relayManager` and `nostrClient`.
- `setSignerDecryptBehavior(mode, opts)` forces decrypt behavior for deterministic list-sync tests.
- `getListSyncEvents()`, `clearListSyncEvents()`, `waitForListSyncEvent()` capture and assert sync lifecycle.
5. Stable sync/test selectors:
- Permission prompt, hashtag/subscription statuses, blocked-list status/list/empty state.
## Recommended next steps
1. Add CI extension smoke lane (manual workflow dispatch only)
- Keep extension smoke out of default PR path.
- Add a separate workflow that runs only when maintainers provide:
- `PLAYWRIGHT_EXTENSION_PATH` artifact or unpacked extension directory
- any extension secrets/profile setup needed by the signer.
2. Add extension artifact capture on failure
- Always upload:
- `playwright-report`
- video/trace/screenshot for `tests/e2e-extension/**`
- JSON summary of `window.nostr` capabilities.
3. Add capability contract test
- Add a single test that asserts minimum NIP-07 surface before running deeper flows:
- `getPublicKey`
- `signEvent`
- at least one decrypt path (`nip04` or `nip44`).
4. Add "auth ready" test harness API
- Add a harness method that waits for authenticated app-ready state (`header UI + signer + relay ready`) so tests stop duplicating custom waits.
5. Add deterministic clock/random seed helper in test mode
- Expose optional fixed clock/random behavior to reduce cross-run variance in event ordering and timeout-sensitive tests.
6. Add a small extension compatibility matrix note
- Track known-good extension versions for:
- Alby/Nostr Connect style extensions
- Chromium version pinned in CI.
## Operational guardrails
1. Keep deterministic tests (mock signer + mock relay) as the default required PR gate.
2. Keep extension tests opt-in until flake rate is low and extension lifecycle setup is automated.
3. Do not treat extension `content.js` runtime noise as app regression unless bitvid telemetry/events also fail in the same window.
+50
View File
@@ -0,0 +1,50 @@
# WebTorrent Architecture & Strategy
This document details the architecture of the WebTorrent implementation in bitvid, focusing on the client management, Service Worker integration, and the critical logic for handling webseeds.
## Core Principle: Webseeds Are Peers
**Crucial Logic (Regression Risk):**
A recurring regression in this codebase involves treating "Webseeds" (HTTP sources) as distinct from "Peers" (P2P sources) in a way that marks webseed-only videos as "unhealthy".
**The Correct Behavior:**
* We **must** pass webseeds (`urlList`) directly to the WebTorrent client via `client.add(url, { urlList: [...] })`.
* We **must** rely on the WebTorrent client to count that webseed as a connected peer.
* **Health = Peers > 0**. If a webseed connects, `numPeers` becomes 1. This is "Healthy".
**Anti-Pattern (Do Not Implement):**
* *Do not* attempt to manually check if `peers === 0 && hasWebseed` and label it as a special "healthy" state in the UI logic.
* *Do not* fail to pass `urlList` to `client.add`. If you don't pass it, WebTorrent won't see the peer, `numPeers` stays 0, and the video is marked unhealthy.
## Architecture Components
### 1. The Singleton Client (`TorrentClient`)
`js/webtorrent.js` exports a singleton `torrentClient`. We do not instantiate multiple clients.
* **Reason:** WebTorrent clients are expensive. They open many socket connections and maintain DHT state.
* **Reuse:** The same client instance is used for both "Probing" (checking health) and "Streaming" (playback).
### 2. The Service Worker (`sw.min.js`)
Browser-based WebTorrent cannot write directly to the filesystem or stream directly to a `<video>` tag's `src` attribute efficiently.
* **Solution:** We register a Service Worker that acts as a proxy.
* **Flow:** `<video src="/webtorrent/...">` -> Browser Network Request -> Service Worker Intercept -> Fetch bytes from `TorrentClient` -> Return stream.
### 3. The "Claim" Dance (Critical Startup Logic)
A major source of "grey screen" bugs (video loading forever) occurs when the Service Worker is installed but **not controlling the page**.
* **Scenario:** User loads page -> SW installs -> User plays video. If the SW hasn't "claimed" the client, the network request bypasses the SW and fails (404).
* **Fix:** `TorrentClient.init()` explicitly waits for `navigator.serviceWorker.controller` to exist. It calls `clients.claim()` in the SW and waits for the `controllerchange` event.
## Probing Strategy (`probePeers`)
We probe videos before playing them to determine if they are available.
1. **Input:** Magnet URI + optional Webseed URL.
2. **Action:** Call `client.add` with `urlList` set to the webseed.
3. **Wait:** Wait for `torrent.on('wire')` (connection established) or `peers > 0`.
4. **Result:**
* **Healthy:** `peers > 0`. (Includes webseeds!)
* **Unhealthy:** Timeout with `peers === 0`.
## Browser Specifics
* **Brave:** Requires aggressive Service Worker cleanup (`unregister` all) on startup due to lingering stale workers preventing new registrations.
* **Firefox:** Uses a specific `highWaterMark` tuning and separate handler (`handleFirefoxTorrent`) to manage memory buffering differences.
* **Chrome/Chromium:** Standard path (`handleChromeTorrent`), but includes logic to strip specific trackers/webseeds that trigger CORS warnings (e.g., specific demo trackers).
+478 -847
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
// js/app/bindCoordinator.js
/**
* Binds every method on a coordinator object to the given Application instance.
*
* Coordinator factories return plain objects whose methods reference `this`
* (the Application) and close over injected `deps`. This helper produces a
* mirror object where every function-valued property is bound to `app`, so
* callers can invoke `coordinator.method()` without worrying about context.
*
* @param {object} app - The Application instance to bind to.
* @param {object} raw - The object returned by a coordinator factory.
* @returns {object} A new object with every method bound to `app`.
*/
export default function bindCoordinator(app, raw) {
const bound = Object.create(null);
for (const key of Object.keys(raw)) {
const value = raw[key];
bound[key] = typeof value === "function" ? value.bind(app) : value;
}
return bound;
}
+1766
View File
File diff suppressed because it is too large. Load diff
+647
View File
@@ -0,0 +1,647 @@
// js/app/modalCoordinator.js
/**
* Modal open/close lifecycle and controller routing.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
*/
export function createModalCoordinator(deps) {
const {
devLogger,
nostrClient,
recordVideoViewApi,
torrentClient,
watchHistoryService,
isWatchHistoryDebugEnabled,
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
formatViewCount,
ingestLocalViewEvent,
pointerArrayToKey,
pointerKey,
getCanonicalDesignSystemMode,
BITVID_WEBSITE_URL,
} = deps;
return {
normalizeModalTrigger(candidate) {
if (!candidate) {
return null;
}
const doc =
(this.videoModal && this.videoModal.document) ||
(typeof document !== "undefined" ? document : null);
const isElement =
typeof candidate === "object" &&
candidate !== null &&
typeof candidate.nodeType === "number" &&
candidate.nodeType === 1 &&
typeof candidate.focus === "function";
if (!isElement) {
return null;
}
if (doc && typeof doc.contains === "function" && !doc.contains(candidate)) {
return null;
}
return candidate;
},
setLastModalTrigger(candidate) {
this.lastModalTrigger = this.normalizeModalTrigger(candidate);
return this.lastModalTrigger;
},
getDesignSystemMode() {
return getCanonicalDesignSystemMode();
},
isDesignSystemNew() {
return true;
},
/**
* Show the modal and set the "Please stand by" poster on the video.
*/
async showModalWithPoster(video = this.currentVideo, options = {}) {
const result = await this.videoModalController.showModalWithPoster(video, options);
this.cacheTorrentStatusNodes();
return result;
},
applyModalLoadingPoster() {
this.videoModalController.applyModalLoadingPoster();
},
forceRemoveModalPoster(reason = "manual-clear") {
return this.videoModalController.forceRemoveModalPoster(reason);
},
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
const result = await this.videoModalController.ensureVideoModalReady({ ensureVideoElement });
this.modalVideo = result.videoElement;
return result;
},
formatViewCountLabel(total) {
const value = Number.isFinite(total) ? Number(total) : 0;
const label = value === 1 ? "view" : "views";
return `${formatViewCount(value)} ${label}`;
},
pruneDetachedViewCountElements() {
if (this.videoListView) {
this.videoListView.pruneDetachedViewCountElements();
}
},
teardownAllViewCountSubscriptions() {
if (this.videoListView) {
this.videoListView.teardownAllViewCountSubscriptions();
}
},
teardownModalViewCountSubscription() {
if (typeof this.modalViewCountUnsub === "function") {
try {
this.modalViewCountUnsub();
} catch (error) {
devLogger.warn("[viewCount] Failed to tear down modal subscription:", error);
}
}
this.modalViewCountUnsub = null;
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
}
},
subscribeModalViewCount(pointer, pointerKey) {
const viewEl = this.videoModal?.getViewCountElement() || null;
if (!viewEl) {
return;
}
this.teardownModalViewCountSubscription();
if (!pointer || !pointerKey) {
return;
}
if (this.videoModal) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
this.videoModal.setViewCountPointer(pointerKey);
}
try {
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
const latestViewEl = this.videoModal?.getViewCountElement() || null;
if (!latestViewEl) {
return;
}
if (Number.isFinite(total)) {
const numeric = Number(total);
if (this.videoModal) {
const label = this.formatViewCountLabel(numeric);
this.videoModal.updateViewCountLabel(
partial ? `${label} (partial)` : label
);
}
latestViewEl.dataset.viewCountState = partial ? "partial" : "ready";
return;
}
if (status === "hydrating") {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("Loading views\u2026");
}
latestViewEl.dataset.viewCountState = "hydrating";
} else {
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
}
latestViewEl.dataset.viewCountState = status;
}
});
this.modalViewCountUnsub = () => {
try {
unsubscribeFromVideoViewCount(pointer, token);
} catch (error) {
devLogger.warn(
"[viewCount] Failed to unsubscribe modal view counter:",
error
);
} finally {
this.modalViewCountUnsub = null;
}
};
} catch (error) {
devLogger.warn("[viewCount] Failed to subscribe modal view counter:", error);
if (this.videoModal) {
this.videoModal.updateViewCountLabel("\u2013 views");
this.videoModal.setViewCountPointer(null);
}
}
},
async hideModal() {
// 1) Clear timers/listeners immediately so playback stats stop updating
this.cancelPendingViewLogging();
this.clearActiveIntervals();
this.removeTorrentStatusVisibilityHandlers();
this.teardownModalViewCountSubscription();
if (this.reactionController) {
this.reactionController.unsubscribe();
}
this.pendingModeratedPlayback = null;
if (
this.videoModal &&
typeof this.videoModal.clearSimilarContent === "function"
) {
try {
this.videoModal.clearSimilarContent();
} catch (error) {
devLogger.warn("[hideModal] Failed to clear similar content:", error);
}
}
// 2) Close the modal UI right away so the user gets instant feedback
const modalVideoElement =
(this.videoModal &&
typeof this.videoModal.getVideoElement === "function" &&
this.videoModal.getVideoElement()) ||
this.modalVideo ||
null;
if (modalVideoElement) {
try {
modalVideoElement.pause();
modalVideoElement.removeAttribute("src");
modalVideoElement.load();
} catch (error) {
devLogger.warn("[hideModal] Failed to reset modal video element:", error);
}
}
if (this.videoModal) {
try {
this.videoModal.close();
} catch (error) {
devLogger.warn("[hideModal] Failed to close video modal immediately:", error);
}
}
this.lastModalTrigger = null;
this.currentMagnetUri = null;
this.clearTorrentStatusNodes();
// 3) Kick off heavy cleanup work asynchronously. We still await it so
// callers that depend on teardown finishing behave the same, but the
// user-visible UI is already closed.
const performCleanup = async () => {
try {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
}
} catch (err) {
devLogger.warn("[hideModal] webtorrent cancel fetch failed:", err);
}
await this.cleanup({
preserveSubscriptions: true,
preserveObservers: true,
preserveModals: true,
});
};
// 4) Remove only `?v=` but **keep** the hash
const url = new URL(window.location.href);
url.searchParams.delete("v"); // remove ?v= param
const newUrl = url.pathname + url.search + url.hash;
window.history.replaceState({}, "", newUrl);
try {
await performCleanup();
} catch (error) {
devLogger.error("[hideModal] Cleanup failed:", error);
}
},
cancelPendingViewLogging() {
this.watchHistoryTelemetry?.cancelPlaybackLogging?.();
},
resetViewLoggingState() {
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
},
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
return;
}
if (
!pointerInfo ||
!pointerInfo.key ||
typeof watchHistoryService?.setLocalMetadata !== "function"
) {
return;
}
if (!video || typeof video !== "object") {
return;
}
const metadata = {
video: {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
},
};
try {
watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
} catch (error) {
devLogger.warn(
"[watchHistory] Failed to persist local metadata for pointer:",
pointerInfo.key,
error,
);
}
},
dropWatchHistoryMetadata(pointerKey) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.dropMetadata(pointerKey);
return;
}
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
if (typeof watchHistoryService?.removeLocalMetadata !== "function") {
return;
}
try {
watchHistoryService.removeLocalMetadata(pointerKey);
} catch (error) {
devLogger.warn(
"[watchHistory] Failed to remove cached metadata for pointer:",
pointerKey,
error,
);
}
},
async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) {
if (!this.watchHistoryController) {
this.showError("Watch history sync is not available right now.");
return;
}
try {
await this.watchHistoryController.removeEntry({
dataset,
trigger,
removeCard: dataset.removeCard === "true",
reason: dataset.reason || "remove-item",
});
} catch (error) {
if (!error?.handled) {
this.showError("Failed to remove from history. Please try again.");
}
}
},
async handleWatchHistoryRemoval(payload = {}) {
if (!this.watchHistoryTelemetry) {
this.showError("Watch history sync is not available right now.");
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryTelemetry.handleRemoval(payload);
},
flushWatchHistory(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryTelemetry) {
return Promise.resolve();
}
return this.watchHistoryTelemetry.flush(reason, context);
},
getActiveViewIdentityKey() {
if (!this.watchHistoryTelemetry) {
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
return this.watchHistoryTelemetry.getActiveViewIdentityKey();
},
deriveViewIdentityKeyFromEvent(event) {
if (!this.watchHistoryTelemetry) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event);
},
buildViewCooldownKey(pointerKey, identityKey) {
if (!this.watchHistoryTelemetry) {
const normalizedPointerKey =
typeof pointerKey === "string" && pointerKey.trim()
? pointerKey.trim()
: "";
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity =
typeof identityKey === "string" && identityKey.trim()
? identityKey.trim().toLowerCase()
: "";
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
return this.watchHistoryTelemetry.buildViewCooldownKey(
pointerKey,
identityKey
);
},
preparePlaybackLogging(videoEl) {
if (!this.watchHistoryTelemetry) {
this.cancelPendingViewLogging();
return;
}
const pointer = this.currentVideoPointer;
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
this.watchHistoryTelemetry.cancelPlaybackLogging();
return;
}
this.watchHistoryTelemetry.preparePlaybackLogging({
videoElement: videoEl,
pointer,
pointerKey,
video: this.currentVideo,
});
},
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
if (!videoElement) {
this.log(
`[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.`
);
return videoElement;
}
const safe = (fn) => {
try {
fn();
} catch (err) {
devLogger.warn("[teardownVideoElement]", err);
}
};
const describeSource = () => {
try {
return videoElement.currentSrc || videoElement.src || "<unset>";
} catch (err) {
return "<unavailable>";
}
};
this.log(
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
);
safe(() => videoElement.pause());
safe(() => {
videoElement.removeAttribute("src");
videoElement.src = "";
});
safe(() => {
videoElement.srcObject = null;
});
safe(() => {
if ("crossOrigin" in videoElement) {
videoElement.crossOrigin = null;
}
if (videoElement.hasAttribute("crossorigin")) {
videoElement.removeAttribute("crossorigin");
}
});
safe(() => {
if (typeof videoElement.load === "function") {
videoElement.load();
}
});
if (!replaceNode || !videoElement.parentNode) {
this.log(
`[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).`
);
return videoElement;
}
const parent = videoElement.parentNode;
const clone = videoElement.cloneNode(false);
if (clone.dataset && "autoplayBound" in clone.dataset) {
delete clone.dataset.autoplayBound;
}
if (clone.hasAttribute("data-autoplay-bound")) {
clone.removeAttribute("data-autoplay-bound");
}
safe(() => {
clone.removeAttribute("src");
clone.src = "";
});
safe(() => {
clone.srcObject = null;
});
safe(() => {
if ("crossOrigin" in clone) {
clone.crossOrigin = null;
}
if (clone.hasAttribute("crossorigin")) {
clone.removeAttribute("crossorigin");
}
});
clone.autoplay = videoElement.autoplay;
clone.controls = videoElement.controls;
clone.loop = videoElement.loop;
clone.muted = videoElement.muted;
clone.defaultMuted = videoElement.defaultMuted;
clone.preload = videoElement.preload;
clone.playsInline = videoElement.playsInline;
clone.poster = "";
if (clone.hasAttribute("poster")) {
clone.removeAttribute("poster");
}
let replaced = false;
safe(() => {
parent.replaceChild(clone, videoElement);
replaced = true;
});
if (!replaced) {
return videoElement;
}
safe(() => {
if (typeof clone.load === "function") {
clone.load();
}
});
this.log(
`[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).`
);
return clone;
},
resetTorrentStats() {
try {
if (this.videoModal && typeof this.videoModal.resetStats === "function") {
this.videoModal.resetStats();
} else {
devLogger.info(
"[Application] resetTorrentStats: videoModal.resetStats not available \u2014 skipping."
);
}
} catch (err) {
devLogger.warn("[Application] resetTorrentStats failed", err);
}
},
setShareButtonState(enabled) {
if (this.videoModal) {
this.videoModal.setShareEnabled(enabled);
}
},
getShareUrlBase() {
if (typeof BITVID_WEBSITE_URL === "string" && BITVID_WEBSITE_URL) {
// Ensure no trailing slash for consistency if desired, though buildShareUrlFromNevent
// appends ?v=... so trailing slash is fine if URL ctor handles it.
// BITVID_WEBSITE_URL usually has a trailing slash in config, but let's be safe.
return BITVID_WEBSITE_URL.replace(/\/$/, "");
}
try {
const current = new URL(window.location.href);
// If we are in the embed, we want to strip that filename.
if (current.pathname.endsWith("/embed.html")) {
return `${current.origin}${current.pathname.replace(/\/embed\.html$/, "")}`;
}
return `${current.origin}${current.pathname}`;
} catch (err) {
const origin = window.location?.origin || "";
const pathname = window.location?.pathname || "";
if (origin || pathname) {
return `${origin}${pathname}`;
}
const href = window.location?.href || "";
if (href) {
const base = href.split(/[?#]/)[0];
if (base) {
return base;
}
}
devLogger.warn("Unable to determine share URL base:", err);
return "";
}
},
};
}
+324
View File
@@ -0,0 +1,324 @@
// js/app/moderationCoordinator.js
/**
* Moderation setting update flow, decoration, and action routing.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
*/
export function createModerationCoordinator(deps) {
const {
devLogger,
ModerationActionController,
setModerationOverride,
clearModerationOverride,
userBlocks,
buildVideoAddressPointer,
VIDEO_EVENT_KIND,
} = deps;
return {
async handleModerationSettingsChange({ settings, skipRefresh = false } = {}) {
const normalized = this.normalizeModerationSettings(settings);
this.moderationSettings = normalized;
const feedContext = {
feedName: this.feedName || "",
feedVariant: this.feedVariant || "",
};
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video, feedContext);
}
}
}
if (
this.videoListView &&
Array.isArray(this.videoListView.videoCardInstances)
) {
for (const card of this.videoListView.videoCardInstances) {
if (!card || typeof card.refreshModerationUi !== "function") {
continue;
}
if (card.video && typeof card.video === "object") {
this.decorateVideoModeration(card.video, feedContext);
}
try {
card.refreshModerationUi();
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh moderation UI:",
error,
);
}
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video, feedContext);
}
}
}
if (this.currentVideo && typeof this.currentVideo === "object") {
this.decorateVideoModeration(this.currentVideo, feedContext);
}
this.moderationDecorator.updateSettings(normalized);
if (!skipRefresh) {
try {
await this.onVideosShouldRefresh({ reason: "moderation-settings-change" });
} catch (error) {
devLogger.warn(
"[Application] Failed to refresh videos after moderation settings change:",
error,
);
}
}
return normalized;
},
refreshVisibleModerationUi({ reason } = {}) {
const context = reason ? ` after ${reason}` : "";
const feedContext = {
feedName: this.feedName || "",
feedVariant: this.feedVariant || "",
};
const redecorateVideo = (video) => {
if (!video || typeof video !== "object") {
return;
}
try {
this.decorateVideoModeration(video, feedContext);
} catch (error) {
devLogger.warn(
`[Application] Failed to decorate video moderation${context}:`,
error,
);
}
};
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
redecorateVideo(video);
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
redecorateVideo(video);
}
}
if (this.videoListView && Array.isArray(this.videoListView.videoCardInstances)) {
for (const card of this.videoListView.videoCardInstances) {
if (!card || typeof card !== "object") {
continue;
}
if (card.video && typeof card.video === "object") {
redecorateVideo(card.video);
}
if (typeof card.refreshModerationUi === "function") {
try {
card.refreshModerationUi();
} catch (error) {
devLogger.warn(
`[Application] Failed to refresh moderation UI on card${context}:`,
error,
);
}
}
}
}
if (this.currentVideo && typeof this.currentVideo === "object") {
redecorateVideo(this.currentVideo);
try {
this.videoModal?.refreshActiveVideoModeration?.({ video: this.currentVideo });
} catch (error) {
devLogger.warn(
`[Application] Failed to refresh video modal moderation UI${context}:`,
error,
);
}
}
},
deriveModerationReportType(summary) {
return this.moderationDecorator.deriveModerationReportType(summary);
},
deriveModerationTrustedCount(summary, reportType) {
return this.moderationDecorator.deriveModerationTrustedCount(summary, reportType);
},
getReporterDisplayName(pubkey) {
return this.moderationDecorator.getReporterDisplayName(pubkey);
},
normalizeModerationSettings(settings = null) {
return this.moderationDecorator.normalizeModerationSettings(settings);
},
getActiveModerationThresholds() {
if (!this.moderationSettings || typeof this.moderationSettings !== "object") {
this.moderationSettings = this.moderationDecorator.normalizeModerationSettings(this.moderationSettings);
}
return this.moderationSettings;
},
decorateVideoModeration(video, feedContext = {}) {
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
if (
video &&
video.pubkey &&
this.isAuthorBlocked(video.pubkey) &&
decorated &&
decorated.moderation
) {
decorated.moderation.viewerMuted = true;
decorated.moderation.hidden = true;
decorated.moderation.hideReason = "viewer-block";
}
return decorated;
},
initializeModerationActionController() {
if (this.moderationActionController) {
return this.moderationActionController;
}
this.moderationActionController = new ModerationActionController({
services: {
setModerationOverride,
clearModerationOverride,
userBlocks,
},
selectors: {
getVideoById: (id) =>
this.videosMap instanceof Map && id ? this.videosMap.get(id) : null,
getCurrentVideo: () => this.currentVideo,
},
actions: {
decorateVideoModeration: (video) => this.decorateVideoModeration(video),
resumePlayback: (video) => this.resumePendingModeratedPlayback(video),
refreshVideos: (payload) => this.onVideosShouldRefresh(payload),
showStatus: (message, options) => this.showStatus(message, options),
showError: (message) => this.showError(message),
describeBlockError: (error) => this.describeUserBlockActionError(error),
},
auth: {
isLoggedIn: () => this.isUserLoggedIn(),
getViewerPubkey: () => this.pubkey,
normalizePubkey: (value) => this.normalizeHexPubkey(value),
},
ui: {
refreshCardModerationUi: (card, options) =>
this.refreshCardModerationUi(card, options),
dispatchModerationEvent: (eventName, detail) =>
this.dispatchModerationEvent(eventName, detail),
},
});
return this.moderationActionController;
},
refreshCardModerationUi(card, { reason } = {}) {
if (!card || typeof card.refreshModerationUi !== "function") {
return false;
}
try {
card.refreshModerationUi();
return true;
} catch (error) {
const suffix = reason ? ` ${reason}` : "";
devLogger.warn(
`[Application] Failed to refresh moderation UI${suffix}:`,
error,
);
return false;
}
},
dispatchModerationEvent(eventName, detail = {}) {
const doc =
(this.videoModal && this.videoModal.document) ||
(typeof document !== "undefined" ? document : null);
if (!doc || typeof doc.dispatchEvent !== "function") {
return false;
}
try {
doc.dispatchEvent(new CustomEvent(eventName, { detail }));
return true;
} catch (error) {
const eventLabels = {
"video:moderation-override": "moderation override event",
"video:moderation-block": "moderation block event",
"video:moderation-hide": "moderation hide event",
};
const label = eventLabels[eventName] || eventName;
devLogger.warn(`[Application] Failed to dispatch ${label}:`, error);
return false;
}
},
handleModerationOverride(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleOverride(payload);
},
async handleModerationBlock(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleBlock(payload);
},
handleModerationHide(payload = {}) {
const controller = this.initializeModerationActionController();
if (!controller) {
return false;
}
return controller.handleHide(payload);
},
getVideoAddressPointer(video) {
if (
this.discussionCountService &&
typeof this.discussionCountService.getVideoAddressPointer === "function"
) {
return this.discussionCountService.getVideoAddressPointer(video);
}
return buildVideoAddressPointer(video, { defaultKind: VIDEO_EVENT_KIND });
},
};
}
+1216
View File
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
// js/app/routerCoordinator.js
/**
* Routing logic for the application.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
export function createRouterCoordinator(deps) {
const {
devLogger,
} = deps;
return {
goToProfile(pubkey) {
if (typeof pubkey !== "string") {
this.showError("No creator info available.");
return;
}
let candidate = pubkey.trim();
if (!candidate) {
this.showError("No creator info available.");
return;
}
if (candidate.startsWith("nostr:")) {
candidate = candidate.slice("nostr:".length);
}
const normalizedHex = this.normalizeHexPubkey(candidate);
const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null;
if (!npub) {
devLogger.warn(
"[Application] Invalid pubkey for profile navigation:",
candidate,
);
this.showError("Invalid creator profile.");
return;
}
window.location.hash = `#view=channel-profile&npub=${npub}`;
},
openCreatorChannel() {
if (!this.currentVideo || !this.currentVideo.pubkey) {
this.showError("No creator info available.");
return;
}
try {
// Encode the hex pubkey to npub
const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey);
// Close the video modal
this.hideModal();
// Switch to channel profile view
window.location.hash = `#view=channel-profile&npub=${npub}`;
} catch (err) {
devLogger.error("Failed to open creator channel:", err);
this.showError("Could not open channel.");
}
},
handleProfileChannelLink(element) {
if (!(element instanceof HTMLElement)) {
return;
}
const targetNpub =
typeof element.dataset.targetNpub === "string"
? element.dataset.targetNpub
: "";
if (this.profileController) {
this.profileController.hide();
}
if (targetNpub) {
window.location.hash = `#view=channel-profile&npub=${encodeURIComponent(
targetNpub,
)}`;
}
}
};
}
+321
View File
@@ -0,0 +1,321 @@
// js/app/uiCoordinator.js
/**
* UI State and Chrome management.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
import { SHORT_TIMEOUT_MS, DEBOUNCE_DELAY_MS } from "../constants.js";
export function createUiCoordinator(deps) {
const {
devLogger,
} = deps;
return {
dispatchAuthLoadingState(detail = {}) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
return false;
}
try {
const payload = { ...(typeof detail === "object" && detail ? detail : {}) };
window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload }));
return true;
} catch (error) {
devLogger.warn("[Application] Failed to dispatch auth loading state:", error);
return false;
}
},
updateAuthLoadingState(partial = {}) {
const baseState =
this.authLoadingState && typeof this.authLoadingState === "object"
? this.authLoadingState
: { profile: "idle", lists: "idle", dms: "idle" };
const nextState = {
...baseState,
...(partial && typeof partial === "object" ? partial : {}),
};
this.authLoadingState = nextState;
const root = typeof document !== "undefined" ? document.documentElement : null;
if (root) {
root.dataset.authProfileLoading = nextState.profile || "idle";
root.dataset.authListsLoading = nextState.lists || "idle";
root.dataset.authDmsLoading = nextState.dms || "idle";
}
this.dispatchAuthLoadingState(nextState);
return nextState;
},
updateActiveProfileUI(pubkey, profile = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated({
pubkey,
profile,
});
return;
}
const picture = profile.picture || "assets/svg/default-profile.svg";
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
const channelLink = document.getElementById("profileChannelLink");
if (channelLink instanceof HTMLElement) {
const targetNpub = this.safeEncodeNpub(pubkey);
if (targetNpub) {
channelLink.href = `#view=channel-profile&npub=${targetNpub}`;
channelLink.dataset.targetNpub = targetNpub;
channelLink.classList.remove("hidden");
} else {
channelLink.removeAttribute("href");
if (channelLink.dataset) {
delete channelLink.dataset.targetNpub;
}
channelLink.classList.add("hidden");
}
}
},
applyAuthenticatedUiState() {
const loginButton = this.loginButton || document.getElementById("loginButton");
if (loginButton) {
loginButton.classList.add("hidden");
loginButton.setAttribute("hidden", "");
}
const logoutButton = this.logoutButton || document.getElementById("logoutButton");
if (logoutButton) {
logoutButton.classList.remove("hidden");
}
const userStatus = this.userStatus || document.getElementById("userStatus");
if (userStatus) {
userStatus.classList.add("hidden");
}
const uploadButton = this.uploadButton || document.getElementById("uploadButton");
if (uploadButton) {
uploadButton.classList.remove("hidden");
uploadButton.removeAttribute("hidden");
}
const profileButton = this.profileButton || document.getElementById("profileButton");
if (profileButton) {
profileButton.classList.remove("hidden");
profileButton.removeAttribute("hidden");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.remove("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.remove("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.remove("hidden");
}
},
applyLoggedOutUiState() {
const loginButton = this.loginButton || document.getElementById("loginButton");
if (loginButton) {
loginButton.classList.remove("hidden");
loginButton.removeAttribute("hidden");
}
const logoutButton = this.logoutButton || document.getElementById("logoutButton");
if (logoutButton) {
logoutButton.classList.add("hidden");
}
const userStatus = this.userStatus || document.getElementById("userStatus");
if (userStatus) {
userStatus.classList.add("hidden");
}
const userPubKey = this.userPubKey || document.getElementById("userPubKey");
if (userPubKey) {
userPubKey.textContent = "";
}
const uploadButton = this.uploadButton || document.getElementById("uploadButton");
if (uploadButton) {
uploadButton.classList.add("hidden");
uploadButton.setAttribute("hidden", "");
}
const profileButton = this.profileButton || document.getElementById("profileButton");
if (profileButton) {
profileButton.classList.add("hidden");
profileButton.setAttribute("hidden", "");
}
const subscriptionsLink = this.resolveSubscriptionsLink();
if (subscriptionsLink) {
subscriptionsLink.classList.add("hidden");
}
const forYouLink = this.resolveForYouLink();
if (forYouLink) {
forYouLink.classList.add("hidden");
}
const exploreLink = this.resolveExploreLink();
if (exploreLink) {
exploreLink.classList.add("hidden");
}
},
syncAuthUiState() {
if (this.isUserLoggedIn()) {
this.applyAuthenticatedUiState();
} else {
this.applyLoggedOutUiState();
}
},
refreshChromeElements() {
const doc = typeof document !== "undefined" ? document : null;
const findElement = (id) => {
if (!doc || typeof doc.getElementById !== "function") {
return null;
}
const element = doc.getElementById(id);
if (!element || typeof element.addEventListener !== "function") {
return null;
}
return element;
};
this.loginButton = findElement("loginButton");
this.logoutButton = findElement("logoutButton");
this.uploadButton = findElement("uploadButton");
this.profileButton = findElement("profileButton");
this.closeLoginModalBtn = findElement("closeLoginModal");
return {
loginButton: this.loginButton,
logoutButton: this.logoutButton,
uploadButton: this.uploadButton,
profileButton: this.profileButton,
closeLoginModalButton: this.closeLoginModalBtn,
};
},
resolveSubscriptionsLink() {
if (
this.subscriptionsLink instanceof HTMLElement &&
this.subscriptionsLink.isConnected
) {
return this.subscriptionsLink;
}
const linkCandidate = document.getElementById("subscriptionsLink");
if (linkCandidate instanceof HTMLElement) {
this.subscriptionsLink = linkCandidate;
return this.subscriptionsLink;
}
this.subscriptionsLink = null;
return null;
},
resolveForYouLink() {
if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) {
return this.forYouLink;
}
const linkCandidate = document.getElementById("forYouLink");
if (linkCandidate instanceof HTMLElement) {
this.forYouLink = linkCandidate;
return this.forYouLink;
}
this.forYouLink = null;
return null;
},
resolveExploreLink() {
if (
this.exploreLink instanceof HTMLElement &&
this.exploreLink.isConnected
) {
return this.exploreLink;
}
const linkCandidate = document.getElementById("exploreLink");
if (linkCandidate instanceof HTMLElement) {
this.exploreLink = linkCandidate;
return this.exploreLink;
}
this.exploreLink = null;
return null;
},
hydrateSidebarNavigation() {
const chromeElements = this.refreshChromeElements();
this.resolveSubscriptionsLink();
this.resolveForYouLink();
this.resolveExploreLink();
if (this.appChromeController) {
if (typeof this.appChromeController.setElements === "function") {
this.appChromeController.setElements(chromeElements);
} else {
if (this.appChromeController.elements) {
Object.assign(this.appChromeController.elements, chromeElements);
}
this.appChromeController.initialize();
}
}
this.syncAuthUiState();
},
maybeShowExperimentalLoginWarning(provider) {
const normalizedProvider =
typeof provider === "string" ? provider.trim().toLowerCase() : "";
if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") {
return;
}
const now = Date.now();
if (
this.lastExperimentalWarningKey === normalizedProvider &&
typeof this.lastExperimentalWarningAt === "number" &&
now - this.lastExperimentalWarningAt < DEBOUNCE_DELAY_MS
) {
return;
}
this.lastExperimentalWarningKey = normalizedProvider;
this.lastExperimentalWarningAt = now;
const providerLabel =
normalizedProvider === "nsec"
? "Direct nsec or seed"
: "NIP-46 remote signer";
this.showStatus(
`${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`,
{ showSpinner: false, autoHideMs: SHORT_TIMEOUT_MS },
);
}
};
}
+65 -77
View File
@@ -16,10 +16,6 @@ import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderer
import { ALLOW_NSFW_CONTENT } from "./config.js";
import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js";
import moderationService from "./services/moderationService.js";
import {
fetchProfileMetadata,
ensureProfileMetadataSubscription,
} from "./services/profileMetadataService.js";
import { createModerationStage } from "./feedEngine/stages.js";
import {
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
@@ -915,6 +911,9 @@ function resolveChannelModeration(pubkey, app = getApp()) {
}
const matches = collectChannelVideos(normalized, app);
if (!Array.isArray(matches)) {
return null;
}
let fallbackModeration = null;
let fallbackVideo = null;
@@ -2799,23 +2798,13 @@ async function runZapAttempt({ amount, overrideFee = null, walletSettings }) {
});
const videoEvent = getZapVideoEvent();
let previousOverride;
const hasGlobal = typeof globalThis !== "undefined";
if (
hasGlobal &&
typeof overrideFee === "number" &&
Number.isFinite(overrideFee)
) {
previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee;
}
try {
const result = await splitAndZap(
{
videoEvent,
amountSats: context.shares.total,
walletSettings: settings
walletSettings: settings,
overrideFee
},
dependencies
);
@@ -2836,21 +2825,6 @@ async function runZapAttempt({ amount, overrideFee = null, walletSettings }) {
error
);
throw error;
} finally {
if (
hasGlobal &&
typeof overrideFee === "number" &&
Number.isFinite(overrideFee)
) {
if (typeof previousOverride === "number") {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
} else if (
globalThis &&
"__BITVID_PLATFORM_FEE_OVERRIDE__" in globalThis
) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
}
}
}
@@ -2877,25 +2851,37 @@ async function executePendingRetry({ walletSettings }) {
const aggregatedReceipts = [];
const aggregatedTracker = [];
const nextRetryShares = [];
let lastError = null;
for (const share of shares) {
const overrideFee = share.type === "platform" ? 100 : 0;
try {
const attempt = await runZapAttempt({
const results = await Promise.allSettled(
shares.map((share) => {
const overrideFee = share.type === "platform" ? 100 : 0;
return runZapAttempt({
amount: share.amount,
overrideFee,
walletSettings
});
})
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const share = shares[i];
if (result.status === "fulfilled") {
const attempt = result.value;
if (!attempt) {
setZapStatus("", "neutral");
return;
continue;
}
if (attempt?.result?.receipts) {
aggregatedReceipts.push(...attempt.result.receipts);
} else if (Array.isArray(attempt?.shareTracker)) {
aggregatedTracker.push(...attempt.shareTracker);
}
} catch (error) {
} else {
const error = result.reason;
lastError = error;
const tracker = Array.isArray(error?.__zapShareTracker)
? error.__zapShareTracker
: [];
@@ -2916,20 +2902,28 @@ async function executePendingRetry({ walletSettings }) {
const failureShares = tracker.filter(
(entry) => entry && entry.status !== "success" && entry.amount > 0
);
markZapRetryPending(failureShares.length ? failureShares : [share]);
const message = error?.message || "Retry failed.";
setZapStatus(message, "error");
app?.showError?.(message);
renderZapReceipts(
aggregatedTracker.length ? aggregatedTracker : tracker,
{
partial: true
}
);
return;
if (failureShares.length) {
nextRetryShares.push(...failureShares);
} else {
nextRetryShares.push(share);
}
}
}
if (nextRetryShares.length > 0) {
markZapRetryPending(nextRetryShares);
const message = lastError?.message || "Retry failed.";
setZapStatus(message, "error");
app?.showError?.(message);
renderZapReceipts(
aggregatedTracker.length ? aggregatedTracker : [],
{
partial: true
}
);
return;
}
renderZapReceipts(
aggregatedReceipts.length ? aggregatedReceipts : aggregatedTracker,
{ partial: false }
@@ -5103,20 +5097,31 @@ async function fetchChannelProfileFromRelays(pubkey) {
}
try {
const profileEntry = await fetchProfileMetadata(pubkey, {
nostr: nostrClient,
relays: relayUrls,
logger: devLogger,
});
const events = await pool.list(relayUrls, [
{ kinds: [0], authors: [pubkey], limit: 1 }
]);
if (!profileEntry) {
return { event: null, profile: {} };
let newestEvent = null;
for (const event of Array.isArray(events) ? events : []) {
if (!event || !event.content) {
continue;
}
if (!newestEvent || event.created_at > newestEvent.created_at) {
newestEvent = event;
}
}
return {
event: profileEntry.event || null,
profile: profileEntry.profile || {},
};
if (newestEvent?.content) {
try {
const meta = JSON.parse(newestEvent.content);
return { event: newestEvent, profile: meta };
} catch (error) {
userLogger.warn("Failed to parse channel metadata payload:", error);
return { event: newestEvent, profile: {} };
}
}
return { event: newestEvent, profile: {} };
} catch (error) {
throw error;
}
@@ -5206,23 +5211,6 @@ async function loadUserProfile(pubkey) {
npub: currentChannelNpub,
loadToken
});
ensureProfileMetadataSubscription({
pubkey,
nostr: nostrClient,
onProfile: ({ profile, event }) => {
if (loadToken !== currentProfileLoadToken) {
return;
}
applyChannelProfileMetadata({
profile,
event,
pubkey,
npub: currentChannelNpub,
loadToken,
});
},
});
} catch (error) {
if (loadToken === currentProfileLoadToken) {
userLogger.error("Failed to fetch user profile data:", error);
+2 -31
View File
@@ -47,39 +47,10 @@ import {
export const WATCH_HISTORY_VERSION_TAG_VALUE = "2";
const runtimeScope = typeof globalThis === "undefined" ? null : globalThis;
const parseBooleanOverride = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
return true;
}
if (normalized === "false") {
return false;
}
}
return null;
};
const devModeOverride = parseBooleanOverride(
runtimeScope?.__BITVID_DEV_MODE_OVERRIDE__
);
const verboseDevModeOverride = parseBooleanOverride(
runtimeScope?.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__
);
export const isDevMode =
devModeOverride === null ? Boolean(IS_DEV_MODE) : devModeOverride;
export const isDevMode = Boolean(IS_DEV_MODE);
export { IS_DEV_MODE };
export const isVerboseDevMode =
verboseDevModeOverride === null
? Boolean(IS_VERBOSE_DEV_MODE)
: verboseDevModeOverride;
export const isVerboseDevMode = Boolean(IS_VERBOSE_DEV_MODE);
export { IS_VERBOSE_DEV_MODE };
export const isLockdownMode = Boolean(IS_LOCKDOWN_MODE);
+44 -35
View File
@@ -27,8 +27,7 @@ const scrollSpyState = {
headings: [],
linkLookup: new Map(),
activeId: "",
rafId: null,
onScroll: null,
observer: null,
};
function getHashParams() {
@@ -260,15 +259,10 @@ function resetSectionHighlight() {
}
function clearScrollSpy() {
if (scrollSpyState.onScroll && typeof window !== "undefined") {
window.removeEventListener("scroll", scrollSpyState.onScroll);
window.removeEventListener("resize", scrollSpyState.onScroll);
if (scrollSpyState.observer) {
scrollSpyState.observer.disconnect();
scrollSpyState.observer = null;
}
if (scrollSpyState.rafId && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(scrollSpyState.rafId);
}
scrollSpyState.rafId = null;
scrollSpyState.onScroll = null;
scrollSpyState.headings = [];
scrollSpyState.linkLookup = new Map();
resetSectionHighlight();
@@ -328,6 +322,7 @@ function setupScrollSpy(container) {
const headings = Array.from(
container.querySelectorAll("h2[id], h3[id], h4[id], h5[id]")
);
console.log(`[Spy] setup found ${headings.length} headings`);
if (headings.length === 0) {
return;
}
@@ -345,34 +340,48 @@ function setupScrollSpy(container) {
scrollSpyState.headings = trackedHeadings;
scrollSpyState.linkLookup = linkLookup;
const updateActiveFromScroll = () => {
scrollSpyState.rafId = null;
const offset = 96;
let nextId = scrollSpyState.headings[0]?.id || "";
for (const heading of scrollSpyState.headings) {
const top = heading.getBoundingClientRect().top - offset;
if (top <= 0) {
nextId = heading.id;
} else {
break;
}
// Initial scan to handle page loads in the middle of sections
const offset = 96;
let nextId = trackedHeadings[0]?.id || "";
for (const heading of trackedHeadings) {
if (heading.getBoundingClientRect().top - offset <= 0) {
nextId = heading.id;
} else {
break;
}
if (nextId) {
setActiveSection(nextId);
}
};
}
if (nextId) {
setActiveSection(nextId);
}
const onScroll = () => {
if (scrollSpyState.rafId) {
return;
}
scrollSpyState.rafId = requestAnimationFrame(updateActiveFromScroll);
};
if (typeof IntersectionObserver === "undefined") {
return;
}
scrollSpyState.onScroll = onScroll;
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
onScroll();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
} else if (entry.boundingClientRect.top > 96) {
// Scrolling up: The heading has exited the active zone downwards (top > 96px).
// Activate the previous heading.
const index = trackedHeadings.findIndex((h) => h.id === entry.target.id);
if (index > 0) {
setActiveSection(trackedHeadings[index - 1].id);
}
}
});
},
{
// Top offset 96px. Bottom offset 50% to support shorter screens.
rootMargin: "-96px 0px -50% 0px",
threshold: 0,
}
);
trackedHeadings.forEach((heading) => observer.observe(heading));
scrollSpyState.observer = observer;
}
function resolveDocItem(slug) {
+440
View File
@@ -0,0 +1,440 @@
import Application from "./app.js";
import { nostrClient } from "./nostrClientFacade.js";
import { convertEventToVideo, getDTagValueFromTags } from "./nostr/index.js";
import { sanitizeRelayList } from "./nostr/nip46Client.js";
import { resolveVideoPointer } from "./utils/videoPointer.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
import { THEME_ACCENT_OVERRIDES } from "../config/instance-config.js";
import { HEX64_REGEX } from "./utils/hex.js";
// Set accent color from instance config
try {
const accent = THEME_ACCENT_OVERRIDES?.light?.accent || "#ff6b6b";
document.documentElement.style.setProperty("--bitvid-accent", accent);
} catch (e) {
// Fallback if config is missing or malformed
document.documentElement.style.setProperty("--bitvid-accent", "#ff6b6b");
}
// early in embed bootstrap (insert before any heavy init)
const urlParams = new URLSearchParams(window.location.search);
const embedDebugEnabled =
urlParams.get("embed_debug") === "1" || urlParams.get("debug") === "embed";
let diag = null;
const diagPromise = embedDebugEnabled
? import("./embedDiagnostics.js")
.then((m) => {
diag = m.initEmbedDiagnostics({ enabled: true });
// immediately signal startup
diag.emit("embed-start", {
url: location.href,
userAgent: navigator.userAgent,
});
})
.catch((e) => {
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(
{
__bitvid_debug: true,
type: "embed-diag-failed",
payload: { error: String(e) },
},
"*"
);
}
} catch (e2) {
/* no-op */
}
})
: Promise.resolve();
const POINTER_PARAM = "pointer";
const PLAYBACK_PARAM = "playback";
const statusEl = document.getElementById("embedStatus");
const videoEl = document.getElementById("embedVideo");
const toneClasses = {
default: "text-muted",
error: "text-danger",
success: "text-success",
};
const setStatus = (message, tone = "default") => {
if (!statusEl) {
return;
}
const normalizedMessage = typeof message === "string" ? message.trim() : "";
statusEl.textContent = normalizedMessage;
Object.values(toneClasses).forEach((className) => {
if (className) {
statusEl.classList.remove(className);
}
});
const toneClass = toneClasses[tone] || toneClasses.default;
if (toneClass) {
statusEl.classList.add(toneClass);
}
};
// ModalManager will instantiate EmbedPlayerModal automatically when running in embed mode.
const app = new Application();
app.showError = (message) => {
const normalizedMessage = typeof message === "string" ? message.trim() : "";
if (normalizedMessage) {
userLogger.error(normalizedMessage);
}
setStatus(normalizedMessage || "Playback failed. Please try again.", "error");
};
app.showStatus = (message) => {
setStatus(message, "default");
};
app.showSuccess = (message) => {
setStatus(message, "success");
};
app.setup();
const resolvePlaybackPreference = (value) => {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "url" || normalized === "torrent") {
return normalized;
}
return null;
};
const ensureNostrReady = async () => {
try {
const toolkitResult = await nostrToolsReady;
diag?.emit("nostr-tools-ready", { ok: Boolean(toolkitResult) });
} catch (error) {
userLogger.warn("[embed] Failed to initialize NostrTools:", error);
diag?.emit("nostr-tools-ready", { ok: false, error: String(error) });
}
try {
await nostrClient.ensurePool();
} catch (error) {
devLogger.warn("[embed] Failed to ensure Nostr pool:", error);
}
nostrClient.init().catch((error) => {
devLogger.warn("[embed] nostrClient.init failed:", error);
});
};
const decodePointer = (pointer) => {
const trimmed = typeof pointer === "string" ? pointer.trim() : "";
if (!trimmed) {
return null;
}
if (/^(naddr|nevent)/i.test(trimmed)) {
const decoder = window?.NostrTools?.nip19?.decode;
if (typeof decoder === "function") {
try {
return decoder(trimmed);
} catch (error) {
userLogger.warn("[embed] Failed to decode pointer:", error);
}
}
}
if (trimmed.includes(":")) {
return {
type: "naddr",
data: {
relay: null,
relays: [],
identifier: trimmed.split(":").slice(2).join(":"),
pubkey: trimmed.split(":")[1] || "",
kind: Number.parseInt(trimmed.split(":")[0], 10),
},
};
}
if (HEX64_REGEX.test(trimmed)) {
return {
type: "nevent",
data: {
id: trimmed,
relays: [],
},
};
}
return null;
};
const buildRelayList = (relays) => {
const combined = [
...(Array.isArray(relays) ? relays : []),
...(Array.isArray(nostrClient.relays) ? nostrClient.relays : []),
];
const result = sanitizeRelayList(combined);
diag?.emit("relay-list", { relays: result });
return result;
};
const resolveEventFromPointer = async (pointer) => {
const decoded = decodePointer(pointer);
if (!decoded) {
return { video: null, pointerHint: null };
}
if (decoded.type === "nevent") {
const eventId = decoded.data?.id || "";
const relays = decoded.data?.relays || [];
let video = null;
if (eventId) {
diag?.emit("event-request", { type: "start", id: eventId });
if (relays.length) {
try {
const raw = await nostrClient.fetchRawEventById(eventId, {
relays: buildRelayList(relays),
});
if (raw) {
video = convertEventToVideo(raw);
}
} catch (error) {
devLogger.warn("[embed] Failed to fetch nevent via relays:", error);
}
}
if (!video) {
video = await nostrClient.getEventById(eventId);
}
diag?.emit("event-request", {
type: "done",
id: eventId,
found: Boolean(video),
videoRootId: video?.videoRootId,
});
}
return {
video,
pointerHint: {
relay: Array.isArray(relays) && relays.length ? relays[0] : null,
eventId,
},
};
}
if (decoded.type === "naddr") {
const data = decoded.data || {};
const kind = Number.isFinite(data.kind) ? data.kind : null;
const pubkey = typeof data.pubkey === "string" ? data.pubkey.trim() : "";
const identifier =
typeof data.identifier === "string" ? data.identifier.trim() : "";
const relays = Array.isArray(data.relays) ? data.relays : [];
if (!kind || !pubkey || !identifier) {
return { video: null, pointerHint: null };
}
diag?.emit("event-request", {
type: "start",
naddr: { kind, pubkey, identifier },
});
await nostrClient.ensurePool();
const pool = nostrClient.pool;
const relayList = buildRelayList(relays);
const filter = {
kinds: [kind],
authors: [pubkey],
"#d": [identifier],
};
let rawEvent = null;
if (pool?.get && relayList.length) {
try {
rawEvent = await pool.get(relayList, filter);
} catch (error) {
devLogger.warn("[embed] Failed to fetch naddr via pool.get:", error);
}
}
if (!rawEvent && pool?.list && relayList.length) {
try {
const events = await pool.list(relayList, [filter]);
if (Array.isArray(events) && events.length) {
const flattened = events.flat().filter(Boolean);
rawEvent = flattened.sort(
(a, b) => (b.created_at || 0) - (a.created_at || 0)
)[0];
}
} catch (error) {
devLogger.warn("[embed] Failed to fetch naddr via pool.list:", error);
}
}
const video = rawEvent ? convertEventToVideo(rawEvent) : null;
diag?.emit("event-request", {
type: "done",
naddr: { kind, pubkey, identifier },
found: Boolean(video),
videoRootId: video?.videoRootId,
});
return {
video,
pointerHint: {
relay: relayList.length ? relayList[0] : null,
dTag: identifier,
kind,
pubkey,
},
};
}
return { video: null, pointerHint: null };
};
const setPointerState = ({ video, hint } = {}) => {
if (!video) {
return;
}
const dTag = getDTagValueFromTags(video.tags) || hint?.dTag || "";
const pointerInfo = resolveVideoPointer({
kind: video.kind,
pubkey: video.pubkey,
videoRootId: video.videoRootId,
dTag,
fallbackEventId: video.id || hint?.eventId,
relay: hint?.relay || video.relay,
});
app.currentVideoPointer = pointerInfo?.pointer || null;
app.currentVideoPointerKey = pointerInfo?.key || null;
if (pointerInfo) {
diag?.emit("pointer-resolved", pointerInfo);
}
if (app.currentVideo) {
app.currentVideo.pointer = app.currentVideoPointer;
app.currentVideo.pointerKey = app.currentVideoPointerKey;
}
// Ensure stats subscriptions are active since we don't go through playVideoByEventId
if (app.subscribeModalViewCount) {
app.subscribeModalViewCount(
app.currentVideoPointer,
app.currentVideoPointerKey
);
}
if (app.reactionController && app.reactionController.subscribe) {
app.reactionController.subscribe(
app.currentVideoPointer,
app.currentVideoPointerKey
);
}
};
const startEmbed = async () => {
if (window.__bitvidEmbedStarted) {
return;
}
window.__bitvidEmbedStarted = true;
try {
await diagPromise;
} catch (error) {
// Should be caught above, but just in case
}
if (!(videoEl instanceof HTMLVideoElement)) {
setStatus("Embed video element is missing.", "error");
return;
}
const params = new URLSearchParams(window.location.search);
const pointer = params.get(POINTER_PARAM) || "";
const playback = resolvePlaybackPreference(params.get(PLAYBACK_PARAM));
if (!pointer) {
setStatus("Missing video pointer.", "error");
return;
}
setStatus("Resolving video…", "default");
try {
app.authService?.hydrateFromStorage?.();
} catch (error) {
devLogger.warn("[embed] Failed to hydrate auth state:", error);
}
await ensureNostrReady();
let resolved = null;
try {
resolved = await resolveEventFromPointer(pointer);
} catch (error) {
devLogger.warn("[embed] Failed to resolve pointer:", error);
}
const video = resolved?.video || null;
if (!video) {
setStatus("Unable to resolve the requested video.", "error");
return;
}
if (app.videoModal && typeof app.videoModal.load === "function") {
try {
await app.videoModal.load();
} catch (error) {
devLogger.warn("[embed] Failed to early-load video modal:", error);
}
}
app.currentVideo = video;
setPointerState({ video, hint: resolved?.pointerHint || null });
const url = typeof video.url === "string" ? video.url.trim() : "";
const magnet = typeof video.magnet === "string" ? video.magnet.trim() : "";
if (!url && !magnet) {
setStatus("No playback sources found for this video.", "error");
return;
}
if (typeof video.title === "string" && video.title.trim()) {
document.title = `${video.title.trim()} | bitvid embed`;
}
try {
await app.playVideoWithFallback({
url,
magnet,
forcedSource: playback || undefined,
trigger: videoEl,
});
if (app.videoModal && typeof app.videoModal.setShareUrl === "function") {
const shareUrl = app.buildShareUrlFromEventId(video.id);
app.videoModal.setShareUrl(shareUrl);
}
} catch (error) {
devLogger.warn("[embed] Playback failed:", error);
setStatus("Playback failed. Please try again.", "error");
}
};
startEmbed();
+7 -2
View File
@@ -1,7 +1,6 @@
import Application from "./app.js";
import { nostrClient } from "./nostrClientFacade.js";
import { convertEventToVideo, getDTagValueFromTags } from "./nostr/index.js";
import { sanitizeRelayList } from "./nostr/nip46Client.js";
import { resolveVideoPointer } from "./utils/videoPointer.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
@@ -181,7 +180,13 @@ const buildRelayList = (relays) => {
...(Array.isArray(nostrClient.relays) ? nostrClient.relays : []),
];
const result = sanitizeRelayList(combined);
const result = Array.from(
new Set(
combined
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
.filter(Boolean)
)
);
diag?.emit("relay-list", { relays: result });
return result;
};
+24 -16
View File
@@ -301,6 +301,13 @@ export function createExploreDiversitySorter({
const selected = [];
const remaining = [...candidates];
// Cache max similarity for each remaining candidate against the selected set
// Map<CandidateItem, { penalty: number, similarItemId: string }>
const similarityCache = new Map();
for (const candidate of remaining) {
similarityCache.set(candidate, { penalty: 0, similarItemId: "" });
}
while (remaining.length > 0) {
let bestCandidate = null;
let bestMmr = Number.NEGATIVE_INFINITY;
@@ -326,22 +333,7 @@ export function createExploreDiversitySorter({
for (const candidate of remaining) {
const rawScore = normalizeScore(candidate?.metadata?.exploreScore);
let penalty = 0;
let similarItemId = "";
if (selected.length > 0) {
const candidateVector = vectors.get(candidate);
for (const selectedItem of selected) {
const similarity = cosineSimilarity(
candidateVector,
vectors.get(selectedItem),
);
if (similarity > penalty) {
penalty = similarity;
similarItemId = stableVideoId(selectedItem);
}
}
}
const { penalty, similarItemId } = similarityCache.get(candidate);
const mmrScore =
normalizedLambda * rawScore - (1 - normalizedLambda) * penalty;
@@ -404,6 +396,22 @@ export function createExploreDiversitySorter({
const index = remaining.indexOf(bestCandidate);
if (index >= 0) {
remaining.splice(index, 1);
similarityCache.delete(bestCandidate);
}
// Update similarity cache for remaining candidates against the newly selected item
const bestCandidateVector = vectors.get(bestCandidate);
for (const candidate of remaining) {
const candidateVector = vectors.get(candidate);
const similarity = cosineSimilarity(
candidateVector,
bestCandidateVector,
);
const cache = similarityCache.get(candidate);
if (similarity > cache.penalty) {
cache.penalty = similarity;
cache.similarItemId = stableVideoId(bestCandidate);
}
}
}
+4 -5
View File
@@ -261,11 +261,10 @@ export function createResolvePostedAtStage({
});
}
for (const task of tasks) {
try {
await task();
} catch (error) {
context?.log?.(`[${stageName}] resolution task failed`, error);
const results = await Promise.allSettled(tasks.map((task) => task()));
for (const result of results) {
if (result.status === "rejected") {
context?.log?.(`[${stageName}] resolution task failed`, result.reason);
}
}
+134
View File
@@ -0,0 +1,134 @@
export const PRIORITY_BASELINE = 1_000_000;
export function getViewportCenter() {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth) || 0;
const height = Number(window.innerHeight) || 0;
if (width <= 0 && height <= 0) {
return null;
}
return {
x: width > 0 ? width / 2 : 0,
y: height > 0 ? height / 2 : 0,
};
}
export function getIntersectionRect(entry) {
if (!entry) {
return null;
}
const rect = entry.intersectionRect;
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
const fallback = entry.boundingClientRect;
if (fallback && fallback.width > 0 && fallback.height > 0) {
return fallback;
}
return null;
}
export function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const filtered = entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
const rect = getIntersectionRect(entry);
if (!rect) {
return null;
}
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
const centerY = rect.top + rect.height / 2;
const verticalDistance = viewportCenter
? Math.abs(centerY - viewportCenter.y)
: Number.POSITIVE_INFINITY;
return { entry, ratio, centerY, verticalDistance };
})
.filter(Boolean);
if (filtered.length === 0) {
return [];
}
if (!viewportCenter) {
return filtered
.sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
return a.centerY - b.centerY;
})
.map((item, index) => ({
entry: item.entry,
priority: PRIORITY_BASELINE - index,
}));
}
const ordered = filtered
.slice()
.sort((a, b) => a.centerY - b.centerY);
let centerIndex = 0;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < ordered.length; i += 1) {
const candidate = ordered[i];
if (candidate.verticalDistance < minDistance) {
minDistance = candidate.verticalDistance;
centerIndex = i;
}
}
const prioritized = [];
const pushCandidate = (candidate) => {
if (!candidate) {
return;
}
prioritized.push(candidate);
};
pushCandidate(ordered[centerIndex]);
let left = centerIndex - 1;
let right = centerIndex + 1;
while (left >= 0 || right < ordered.length) {
const leftCandidate = left >= 0 ? ordered[left] : null;
const rightCandidate = right < ordered.length ? ordered[right] : null;
if (leftCandidate && rightCandidate) {
const leftDistance = leftCandidate.verticalDistance;
const rightDistance = rightCandidate.verticalDistance;
const distanceDelta = Math.abs(leftDistance - rightDistance);
if (distanceDelta <= 0.5) {
if (rightCandidate.ratio > leftCandidate.ratio) {
pushCandidate(rightCandidate);
right += 1;
} else {
pushCandidate(leftCandidate);
left -= 1;
}
} else if (leftDistance < rightDistance) {
pushCandidate(leftCandidate);
left -= 1;
} else {
pushCandidate(rightCandidate);
right += 1;
}
} else if (rightCandidate) {
pushCandidate(rightCandidate);
right += 1;
} else if (leftCandidate) {
pushCandidate(leftCandidate);
left -= 1;
}
}
return prioritized.map((candidate, index) => ({
entry: candidate.entry,
priority: PRIORITY_BASELINE - index,
}));
}
+1408
View File
File diff suppressed because it is too large. Load diff
+3 -30
View File
@@ -49,36 +49,14 @@ export function trackersFromMagnet(magnet) {
return Array.from(deduped);
}
export function webSeedsFromMagnet(magnet) {
if (typeof magnet !== "string") {
return [];
}
const parsed = parseMagnetLite(magnet);
if (!parsed || !Array.isArray(parsed.ws)) {
return [];
}
const deduped = new Set();
parsed.ws.forEach((url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
deduped.add(trimmed);
});
return Array.from(deduped);
}
function parseMagnetLite(magnet) {
if (typeof magnet !== "string") {
return { infoHash: "", announce: [], ws: [] };
return { infoHash: "", announce: [] };
}
const trimmed = magnet.trim();
if (!trimmed) {
return { infoHash: "", announce: [], ws: [] };
return { infoHash: "", announce: [] };
}
const lower = trimmed.toLowerCase();
@@ -86,7 +64,6 @@ function parseMagnetLite(magnet) {
const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : "";
const params = paramsPart.split("&");
const announce = [];
const ws = [];
let infoHash = "";
for (const param of params) {
@@ -112,12 +89,8 @@ function parseMagnetLite(magnet) {
if (value) {
announce.push(value);
}
} else if (key === "ws" || key === "webseed") {
if (value) {
ws.push(value);
}
}
}
return { infoHash, announce, ws };
return { infoHash, announce };
}
+31 -25
View File
@@ -42,7 +42,7 @@ function resolveNip44Module(extension) {
return nip44;
}
export async function createNip07Adapter(initialExtension, { preloadedPubkey } = {}) {
export async function createNip07Adapter(initialExtension) {
const getExtension = () => {
if (typeof window !== "undefined" && window.nostr) {
return window.nostr;
@@ -86,10 +86,15 @@ export async function createNip07Adapter(initialExtension, { preloadedPubkey } =
};
const pubkey =
preloadedPubkey ||
(typeof bootstrapExtension.getPublicKey === "function"
typeof bootstrapExtension.getPublicKey === "function"
? normalizeActorKey(await getPublicKey())
: "");
: "";
const hasNip04 =
bootstrapExtension.nip04 && typeof bootstrapExtension.nip04 === "object";
const bootstrapNip44 = resolveNip44Module(bootstrapExtension);
const hasNip44 = !!bootstrapNip44;
// Wrapper builders to ensure dynamic lookup at call time
const createNip04Encrypt = () => (pubkey, plaintext, options = {}) => {
@@ -201,29 +206,30 @@ export async function createNip07Adapter(initialExtension, { preloadedPubkey } =
requestPermissions,
destroy: async () => {},
canSign: () => typeof getExtension()?.signEvent === "function",
// Always expose these methods; they perform their own existence checks at runtime.
// This handles race conditions where NIP-04/44 APIs are injected lazily.
nip04Encrypt: createNip04Encrypt(),
nip04Decrypt: createNip04Decrypt(),
nip44Encrypt: createNip44Encrypt(),
nip44Decrypt: createNip44Decrypt(),
capabilities: {
sign: typeof bootstrapExtension.signEvent === "function",
nip44: hasNip44,
nip04: hasNip04,
},
};
// Define capabilities as dynamic properties to reflect current extension state
Object.defineProperty(signer, "capabilities", {
get: () => {
const ext = getExtension();
const hasNip04 = !!(ext?.nip04 && typeof ext.nip04 === "object");
const hasNip44 = !!resolveNip44Module(ext);
return {
sign: typeof ext?.signEvent === "function",
nip44: hasNip44,
nip04: hasNip04,
};
},
enumerable: true,
configurable: true,
});
if (hasNip04) {
if (typeof bootstrapExtension.nip04.encrypt === "function") {
signer.nip04Encrypt = createNip04Encrypt();
}
if (typeof bootstrapExtension.nip04.decrypt === "function") {
signer.nip04Decrypt = createNip04Decrypt();
}
}
if (hasNip44) {
if (typeof bootstrapNip44.encrypt === "function") {
signer.nip44Encrypt = createNip44Encrypt();
}
if (typeof bootstrapNip44.decrypt === "function") {
signer.nip44Decrypt = createNip44Decrypt();
}
}
return signer;
}
+141 -753
View File
File diff suppressed because it is too large. Load diff
+5 -8
View File
@@ -13,7 +13,6 @@ import { CACHE_POLICIES } from "./cachePolicies.js";
import { isSessionActor } from "./sessionActor.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT];
@@ -33,15 +32,13 @@ function getAllowedCommentKinds() {
return ALLOWED_COMMENT_KINDS.slice();
}
function sanitizeRelayList(primary, fallback) {
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
if (Array.isArray(primary) && primary.length) {
return primary;
}
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return sanitizeRelayUrls(RELAY_URLS);
return RELAY_URLS;
}
function normalizeRelay(candidate) {
+982
View File
@@ -0,0 +1,982 @@
import {
getNostrEventSchema,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
import { normalizePointerInput } from "./watchHistory.js";
import { devLogger } from "../utils/logger.js";
const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind)
? COMMENT_EVENT_SCHEMA.kind
: 1111;
export const LEGACY_COMMENT_KIND = 1;
const ALLOWED_COMMENT_KINDS = Object.freeze(
COMMENT_EVENT_KIND === LEGACY_COMMENT_KIND
? [COMMENT_EVENT_KIND]
: [COMMENT_EVENT_KIND, LEGACY_COMMENT_KIND]
);
export function getAllowedCommentKinds() {
return ALLOWED_COMMENT_KINDS.slice();
}
export function normalizeRelay(candidate) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
if (Array.isArray(candidate)) {
const relayCandidate = candidate[2];
if (typeof relayCandidate === "string" && relayCandidate.trim()) {
return relayCandidate.trim();
}
}
if (candidate && typeof candidate === "object") {
if (typeof candidate.relay === "string" && candidate.relay.trim()) {
return candidate.relay.trim();
}
if (typeof candidate.url === "string" && candidate.url.trim()) {
return candidate.url.trim();
}
if (Array.isArray(candidate.relays)) {
for (const relayCandidate of candidate.relays) {
if (typeof relayCandidate === "string" && relayCandidate.trim()) {
return relayCandidate.trim();
}
}
}
if (candidate.tag) {
return normalizeRelay(candidate.tag);
}
if (candidate.pointer) {
return normalizeRelay(candidate.pointer);
}
}
return "";
}
export function normalizePointerCandidate(candidate, expectedType) {
if (!candidate) {
return null;
}
if (Array.isArray(candidate)) {
const tagName =
typeof candidate[0] === "string" ? candidate[0].trim().toLowerCase() : "";
if (
tagName === expectedType &&
typeof candidate[1] === "string" &&
candidate[1].trim()
) {
return {
value: candidate[1].trim(),
relay: normalizeRelay(candidate),
};
}
return null;
}
if (typeof candidate === "string") {
const pointer = normalizePointerInput(candidate);
if (pointer?.type === expectedType && pointer.value) {
return {
value: pointer.value.trim(),
relay: normalizeRelay(pointer),
};
}
if (expectedType === "e" && candidate.trim() && !candidate.includes(":")) {
return { value: candidate.trim(), relay: "" };
}
if (expectedType === "a" && candidate.trim() && candidate.includes(":")) {
return { value: candidate.trim(), relay: "" };
}
return null;
}
if (candidate && typeof candidate === "object") {
if (
typeof candidate.type === "string" &&
candidate.type.toLowerCase() === expectedType &&
typeof candidate.value === "string" &&
candidate.value.trim()
) {
return {
value: candidate.value.trim(),
relay: normalizeRelay(candidate),
};
}
if (
expectedType === "e" &&
typeof candidate.id === "string" &&
candidate.id.trim()
) {
return {
value: candidate.id.trim(),
relay: normalizeRelay(candidate),
};
}
if (
expectedType === "a" &&
typeof candidate.address === "string" &&
candidate.address.trim()
) {
return {
value: candidate.address.trim(),
relay: normalizeRelay(candidate),
};
}
if (candidate.tag) {
return normalizePointerCandidate(candidate.tag, expectedType);
}
if (candidate.pointer) {
return normalizePointerCandidate(candidate.pointer, expectedType);
}
}
return null;
}
export function normalizeTagName(name) {
return typeof name === "string" ? name.trim().toLowerCase() : "";
}
export function normalizeTagValue(value) {
if (value == null) return "";
if (Number.isFinite(value)) return String(Math.floor(value));
return typeof value === "string" ? (value.trim().toLowerCase() || "") : "";
}
export function normalizeDescriptorString(value) {
if (value == null) return "";
if (Number.isFinite(value)) return String(Math.floor(value));
if (typeof value === "string") return value.trim().toLowerCase() || "";
if (typeof value === "object" && value.value !== undefined) {
return normalizeDescriptorString(value.value);
}
return "";
}
export function normalizeDescriptorRelay(value) {
return typeof value === "string" ? value.trim() : "";
}
export function pickString(...candidates) {
for (const c of candidates) {
if (typeof c === "string" && c.trim()) return c.trim();
}
return "";
}
export function pickKind(...candidates) {
for (const c of candidates) {
if (Number.isFinite(c)) return String(Math.floor(c));
if (typeof c === "string" && c.trim()) return c.trim();
}
return "";
}
export function isEventCandidate(candidate) {
if (!candidate || typeof candidate !== "object") {
return false;
}
if (Array.isArray(candidate.tags)) {
return true;
}
if (typeof candidate.id === "string" && candidate.id.trim()) {
return true;
}
if (typeof candidate.pubkey === "string" && candidate.pubkey.trim()) {
return true;
}
return false;
}
export function resolveEventCandidate(...candidates) {
for (const candidate of candidates) {
if (isEventCandidate(candidate)) {
return candidate;
}
}
return null;
}
export function collectTagsFromEvent(event) {
if (!event || typeof event !== "object") {
return [];
}
const tags = Array.isArray(event.tags) ? event.tags : [];
return tags.filter((tag) => Array.isArray(tag) && tag.length >= 2);
}
export function findTagByName(tags, ...names) {
const normalizedNames = names
.flat()
.map((name) => normalizeTagName(name))
.filter(Boolean);
if (!normalizedNames.length) {
return null;
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const [name] = tag;
if (typeof name !== "string") {
continue;
}
if (normalizedNames.includes(normalizeTagName(name))) {
return tag;
}
}
return null;
}
export class CommentTargetNormalizer {
constructor(targetInput, overrides) {
this.target = targetInput && typeof targetInput === "object" ? targetInput : {};
this.options = overrides && typeof overrides === "object" ? overrides : {};
// Pointers
this.videoEventPointer = null;
this.videoDefinitionPointer = null;
this.rootDefinitionPointer = null;
this.rootEventPointer = null;
this.parentCommentPointer = null;
// Events
this.videoEventObject = null;
this.parentCommentEvent = null;
// Tags
this.rootEventTags = [];
this.parentEventTags = [];
this.combinedTags = [];
// Normalized Values
this.normalizedVideoEventId = "";
this.normalizedVideoEventRelay = "";
this.normalizedVideoDefinitionAddress = "";
this.normalizedVideoDefinitionRelay = "";
this.normalizedParentCommentId = "";
this.normalizedParentCommentRelay = "";
this.normalizedThreadParticipantPubkey = "";
this.normalizedThreadParticipantRelay = "";
this.normalizedRootIdentifier = "";
this.normalizedRootIdentifierRelay = "";
this.normalizedParentIdentifier = "";
this.normalizedParentIdentifierRelay = "";
this.normalizedVideoKind = "";
this.normalizedVideoAuthorPubkey = "";
this.derivedDefinitionKind = "";
this.derivedDefinitionPubkey = "";
this.normalizedRootKind = "";
this.normalizedParentKind = "";
this.normalizedRootAuthorPubkey = "";
this.normalizedParentAuthorPubkey = "";
this.normalizedRootAuthorRelay = "";
this.normalizedParentAuthorRelay = "";
}
normalize() {
this.extractPointers();
this.resolveEventsAndTags();
this.extractInitialValues();
this.enrichFromTags();
this.applyDefaults();
return this.getDescriptor();
}
extractPointers() {
const { target, options } = this;
this.videoEventPointer =
normalizePointerCandidate(options.videoEventPointer, "e") ||
normalizePointerCandidate(options.videoEvent, "e") ||
normalizePointerCandidate(target.videoEventPointer, "e") ||
normalizePointerCandidate(target.videoEvent, "e") ||
normalizePointerCandidate(target.eventPointer, "e") ||
normalizePointerCandidate(target.event, "e");
this.videoDefinitionPointer =
normalizePointerCandidate(options.videoDefinitionPointer, "a") ||
normalizePointerCandidate(options.videoDefinition, "a") ||
normalizePointerCandidate(target.videoDefinitionPointer, "a") ||
normalizePointerCandidate(target.videoDefinition, "a") ||
normalizePointerCandidate(target.definitionPointer, "a") ||
normalizePointerCandidate(target.definition, "a");
this.rootDefinitionPointer =
normalizePointerCandidate(options.rootDefinitionPointer, "a") ||
normalizePointerCandidate(options.rootPointer, "a") ||
normalizePointerCandidate(target.rootDefinitionPointer, "a") ||
normalizePointerCandidate(target.rootPointer, "a");
this.rootEventPointer =
normalizePointerCandidate(options.rootEventPointer, "e") ||
normalizePointerCandidate(options.rootPointer, "e") ||
normalizePointerCandidate(target.rootEventPointer, "e") ||
normalizePointerCandidate(target.rootPointer, "e");
this.parentCommentPointer =
normalizePointerCandidate(options.parentCommentPointer, "e") ||
normalizePointerCandidate(options.parentComment, "e") ||
normalizePointerCandidate(target.parentCommentPointer, "e") ||
normalizePointerCandidate(target.parentComment, "e") ||
normalizePointerCandidate(target.parentPointer, "e");
}
resolveEventsAndTags() {
const { target, options } = this;
this.videoEventObject = resolveEventCandidate(
options.videoEvent,
options.rootEvent,
target.videoEvent,
target.rootEvent,
);
this.parentCommentEvent = resolveEventCandidate(
options.parentCommentEvent,
options.parentComment,
target.parentCommentEvent,
target.parentComment,
);
this.rootEventTags = collectTagsFromEvent(this.videoEventObject);
this.parentEventTags = collectTagsFromEvent(this.parentCommentEvent);
this.combinedTags = [...this.parentEventTags, ...this.rootEventTags];
}
extractInitialValues() {
const { target, options } = this;
const normalize = (val) => (typeof val === "string" ? val.trim() : "");
this.normalizedVideoEventId = normalize(pickString(
options.videoEventId,
target.videoEventId,
target.eventId,
this.videoEventPointer?.value,
this.rootEventPointer?.value,
));
this.normalizedVideoEventRelay = normalize(pickString(
options.videoEventRelay,
target.videoEventRelay,
target.eventRelay,
this.videoEventPointer?.relay,
this.rootEventPointer?.relay,
));
this.normalizedVideoDefinitionAddress = normalize(pickString(
options.videoDefinitionAddress,
target.videoDefinitionAddress,
target.definitionAddress,
this.videoDefinitionPointer?.value,
this.rootDefinitionPointer?.value,
));
this.normalizedVideoDefinitionRelay = normalize(pickString(
options.videoDefinitionRelay,
target.videoDefinitionRelay,
target.definitionRelay,
this.videoDefinitionPointer?.relay,
this.rootDefinitionPointer?.relay,
));
this.normalizedParentCommentId = normalize(pickString(
options.parentCommentId,
target.parentCommentId,
target.parentId,
this.parentCommentPointer?.value,
));
this.normalizedParentCommentRelay = normalize(pickString(
options.parentCommentRelay,
target.parentCommentRelay,
target.parentRelay,
this.parentCommentPointer?.relay,
));
this.normalizedThreadParticipantPubkey = normalize(pickString(
options.threadParticipantPubkey,
target.threadParticipantPubkey,
target.participantPubkey,
target.authorPubkey,
));
this.normalizedThreadParticipantRelay = normalize(pickString(
options.threadParticipantRelay,
target.threadParticipantRelay,
target.participantRelay,
));
this.normalizedRootIdentifier = normalize(pickString(
options.rootIdentifier,
target.rootIdentifier,
options.rootPointer,
target.rootPointer,
));
this.normalizedRootIdentifierRelay = normalize(pickString(
options.rootIdentifierRelay,
target.rootIdentifierRelay,
));
this.normalizedParentIdentifier = normalize(pickString(
options.parentIdentifier,
target.parentIdentifier,
));
this.normalizedParentIdentifierRelay = normalize(pickString(
options.parentIdentifierRelay,
target.parentIdentifierRelay,
));
this.normalizedVideoKind = pickKind(
options.videoKind,
target.videoKind,
this.videoEventObject?.kind,
);
this.normalizedVideoAuthorPubkey = pickString(
options.videoAuthorPubkey,
target.videoAuthorPubkey,
this.videoEventObject?.pubkey,
);
const definitionSegments = this.normalizedVideoDefinitionAddress
? this.normalizedVideoDefinitionAddress.split(":")
: [];
this.derivedDefinitionKind = definitionSegments[0] || "";
this.derivedDefinitionPubkey = definitionSegments[1] || "";
const rootKindCandidate = pickString(
options.rootKind,
target.rootKind,
options.videoKind,
target.videoKind,
this.normalizedVideoKind,
this.derivedDefinitionKind,
);
this.normalizedRootKind =
typeof rootKindCandidate === "string" ? rootKindCandidate.trim() : "";
const parentKindCandidate = pickString(
options.parentKind,
target.parentKind,
options.parentCommentKind,
target.parentCommentKind,
pickKind(this.parentCommentEvent?.kind),
);
this.normalizedParentKind =
typeof parentKindCandidate === "string" ? parentKindCandidate.trim() : "";
let normalizedRootAuthorPubkey = pickString(
options.rootAuthorPubkey,
target.rootAuthorPubkey,
options.videoAuthorPubkey,
target.videoAuthorPubkey,
this.normalizedVideoAuthorPubkey,
this.derivedDefinitionPubkey,
);
this.normalizedRootAuthorPubkey =
typeof normalizedRootAuthorPubkey === "string"
? normalizedRootAuthorPubkey.trim()
: "";
let normalizedParentAuthorPubkey = pickString(
options.parentAuthorPubkey,
target.parentAuthorPubkey,
options.parentCommentPubkey,
target.parentCommentPubkey,
this.parentCommentEvent?.pubkey,
);
this.normalizedParentAuthorPubkey =
typeof normalizedParentAuthorPubkey === "string"
? normalizedParentAuthorPubkey.trim()
: "";
let normalizedRootAuthorRelay = pickString(
options.rootAuthorRelay,
target.rootAuthorRelay,
);
this.normalizedRootAuthorRelay =
typeof normalizedRootAuthorRelay === "string"
? normalizedRootAuthorRelay.trim()
: "";
let normalizedParentAuthorRelay = pickString(
options.parentAuthorRelay,
target.parentAuthorRelay,
);
this.normalizedParentAuthorRelay =
typeof normalizedParentAuthorRelay === "string"
? normalizedParentAuthorRelay.trim()
: "";
}
enrichFromTags() {
const getTagField = (tag, index) =>
Array.isArray(tag) && typeof tag[index] === "string"
? tag[index].trim()
: "";
if (!this.normalizedRootIdentifier) {
const tag = findTagByName(this.combinedTags, "I");
const value = getTagField(tag, 1);
if (value) {
this.normalizedRootIdentifier = value;
const relay = getTagField(tag, 2);
if (relay) {
this.normalizedRootIdentifierRelay = relay;
}
}
} else if (!this.normalizedRootIdentifierRelay) {
const tag = findTagByName(this.combinedTags, "I");
const relay = getTagField(tag, 2);
if (relay) {
this.normalizedRootIdentifierRelay = relay;
}
}
if (!this.normalizedVideoDefinitionAddress) {
const tag = findTagByName(this.combinedTags, "A", "a");
const value = getTagField(tag, 1);
if (value) {
this.normalizedVideoDefinitionAddress = value;
const relay = getTagField(tag, 2);
if (relay) {
this.normalizedVideoDefinitionRelay = relay;
}
}
} else if (!this.normalizedVideoDefinitionRelay) {
const tag = findTagByName(this.combinedTags, "A", "a");
const relay = getTagField(tag, 2);
if (relay) {
this.normalizedVideoDefinitionRelay = relay;
}
}
const rootEventTag = findTagByName(this.combinedTags, "E");
if (rootEventTag) {
const value = getTagField(rootEventTag, 1);
if (value && !this.normalizedVideoEventId) {
this.normalizedVideoEventId = value;
}
const relay = getTagField(rootEventTag, 2);
if (relay && !this.normalizedVideoEventRelay) {
this.normalizedVideoEventRelay = relay;
}
const authorHint = getTagField(rootEventTag, 3);
if (authorHint && !this.normalizedRootAuthorPubkey) {
this.normalizedRootAuthorPubkey = authorHint;
}
}
if (!this.normalizedVideoEventId || !this.normalizedParentCommentId) {
const eventValues = [];
for (const tag of this.combinedTags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (typeof tag[0] === "string" && tag[0].trim().toLowerCase() === "e") {
const value = getTagField(tag, 1);
if (value) {
eventValues.push({ tag, value });
}
}
}
if (!this.normalizedVideoEventId && eventValues.length) {
this.normalizedVideoEventId = eventValues[0].value;
const relay = getTagField(eventValues[0].tag, 2);
if (relay && !this.normalizedVideoEventRelay) {
this.normalizedVideoEventRelay = relay;
}
}
if (!this.normalizedParentCommentId && eventValues.length) {
for (let index = eventValues.length - 1; index >= 0; index -= 1) {
const candidate = eventValues[index];
if (
candidate.value &&
candidate.value !== this.normalizedVideoEventId
) {
this.normalizedParentCommentId = candidate.value;
const relay = getTagField(candidate.tag, 2);
if (relay) {
this.normalizedParentCommentRelay = relay;
}
const authorHint = getTagField(candidate.tag, 3);
if (authorHint && !this.normalizedParentAuthorPubkey) {
this.normalizedParentAuthorPubkey = authorHint;
}
break;
}
}
}
}
if (this.normalizedParentCommentId && !this.normalizedParentCommentRelay) {
const parentTag = this.parentEventTags.find(
(tag) =>
Array.isArray(tag) &&
tag.length >= 2 &&
typeof tag[0] === "string" &&
tag[0].trim().toLowerCase() === "e" &&
getTagField(tag, 1) === this.normalizedParentCommentId,
);
if (parentTag) {
const relay = getTagField(parentTag, 2);
if (relay) {
this.normalizedParentCommentRelay = relay;
}
const authorHint = getTagField(parentTag, 3);
if (authorHint && !this.normalizedParentAuthorPubkey) {
this.normalizedParentAuthorPubkey = authorHint;
}
}
}
if (!this.normalizedParentIdentifierRelay) {
const tag = findTagByName(this.parentEventTags, "i");
const relay = getTagField(tag, 2);
if (relay) {
this.normalizedParentIdentifierRelay = relay;
}
}
if (!this.normalizedRootKind) {
const tag = findTagByName(this.combinedTags, "K");
const value = getTagField(tag, 1);
if (value) {
this.normalizedRootKind = value;
}
}
if (!this.normalizedVideoKind && this.normalizedRootKind) {
this.normalizedVideoKind = this.normalizedRootKind;
}
if (!this.normalizedRootAuthorPubkey || !this.normalizedRootAuthorRelay) {
const tag = findTagByName(this.combinedTags, "P");
if (tag) {
const value = getTagField(tag, 1);
if (value && !this.normalizedRootAuthorPubkey) {
this.normalizedRootAuthorPubkey = value;
}
const relay = getTagField(tag, 2);
if (relay && !this.normalizedRootAuthorRelay) {
this.normalizedRootAuthorRelay = relay;
}
}
}
if (!this.normalizedVideoAuthorPubkey && this.normalizedRootAuthorPubkey) {
this.normalizedVideoAuthorPubkey = this.normalizedRootAuthorPubkey;
}
if (!this.normalizedParentKind) {
const tag = findTagByName(this.parentEventTags, "k");
const value = getTagField(tag, 1);
if (value) {
this.normalizedParentKind = value;
}
}
if (!this.normalizedParentAuthorRelay && this.normalizedParentAuthorPubkey) {
const parentTag = this.parentEventTags.find(
(tag) =>
Array.isArray(tag) &&
tag.length >= 2 &&
typeof tag[0] === "string" &&
tag[0].trim().toLowerCase() === "p" &&
normalizeTagValue(tag[1]) === normalizeTagValue(this.normalizedParentAuthorPubkey),
);
if (parentTag) {
const relay = getTagField(parentTag, 2);
if (relay) {
this.normalizedParentAuthorRelay = relay;
}
}
}
if (!this.normalizedParentAuthorPubkey || !this.normalizedParentAuthorRelay) {
const tag = findTagByName(this.parentEventTags, "p");
if (tag) {
const value = getTagField(tag, 1);
if (value && !this.normalizedParentAuthorPubkey) {
this.normalizedParentAuthorPubkey = value;
}
const relay = getTagField(tag, 2);
if (relay && !this.normalizedParentAuthorRelay) {
this.normalizedParentAuthorRelay = relay;
}
}
}
}
applyDefaults() {
if (!this.normalizedParentKind) {
if (this.normalizedParentCommentId) {
this.normalizedParentKind = String(COMMENT_EVENT_KIND);
} else if (this.normalizedRootKind) {
this.normalizedParentKind = this.normalizedRootKind;
} else if (this.derivedDefinitionKind) {
this.normalizedParentKind = this.derivedDefinitionKind;
}
}
if (!this.normalizedRootKind) {
this.normalizedRootKind = this.normalizedParentKind;
}
if (!this.normalizedParentAuthorPubkey) {
this.normalizedParentAuthorPubkey = this.normalizedThreadParticipantPubkey;
}
if (!this.normalizedRootAuthorPubkey) {
if (!this.normalizedParentCommentId && this.normalizedParentAuthorPubkey) {
this.normalizedRootAuthorPubkey = this.normalizedParentAuthorPubkey;
}
}
if (!this.normalizedRootAuthorRelay && this.normalizedVideoDefinitionRelay) {
this.normalizedRootAuthorRelay = this.normalizedVideoDefinitionRelay;
}
if (!this.normalizedParentAuthorRelay) {
this.normalizedParentAuthorRelay = this.normalizedThreadParticipantRelay;
}
if (!this.normalizedRootAuthorRelay && !this.normalizedParentCommentId) {
this.normalizedRootAuthorRelay = this.normalizedParentAuthorRelay;
}
if (!this.normalizedRootAuthorPubkey && this.derivedDefinitionPubkey) {
this.normalizedRootAuthorPubkey = this.derivedDefinitionPubkey;
}
if (!this.normalizedParentAuthorPubkey && !this.normalizedParentCommentId) {
this.normalizedParentAuthorPubkey = this.normalizedRootAuthorPubkey;
}
if (!this.normalizedParentAuthorRelay && !this.normalizedParentCommentId) {
this.normalizedParentAuthorRelay = this.normalizedRootAuthorRelay;
}
}
getDescriptor() {
const descriptor = {
videoEventId: normalizeDescriptorString(this.normalizedVideoEventId),
videoEventRelay: normalizeDescriptorRelay(this.normalizedVideoEventRelay),
videoDefinitionAddress: normalizeDescriptorString(this.normalizedVideoDefinitionAddress),
videoDefinitionRelay: normalizeDescriptorRelay(this.normalizedVideoDefinitionRelay),
videoKind: normalizeDescriptorString(this.normalizedVideoKind),
videoAuthorPubkey: normalizeDescriptorString(this.normalizedVideoAuthorPubkey),
parentCommentId: normalizeDescriptorString(this.normalizedParentCommentId),
parentCommentRelay: normalizeDescriptorRelay(this.normalizedParentCommentRelay),
threadParticipantPubkey: normalizeDescriptorString(this.normalizedThreadParticipantPubkey),
threadParticipantRelay: normalizeDescriptorRelay(this.normalizedThreadParticipantRelay),
rootIdentifier: normalizeDescriptorString(this.normalizedRootIdentifier),
rootIdentifierRelay: normalizeDescriptorRelay(this.normalizedRootIdentifierRelay),
parentIdentifier: normalizeDescriptorString(this.normalizedParentIdentifier),
parentIdentifierRelay: normalizeDescriptorRelay(this.normalizedParentIdentifierRelay),
rootKind: normalizeDescriptorString(this.normalizedRootKind),
rootAuthorPubkey: normalizeDescriptorString(this.normalizedRootAuthorPubkey),
rootAuthorRelay: normalizeDescriptorRelay(this.normalizedRootAuthorRelay),
parentKind: normalizeDescriptorString(this.normalizedParentKind),
parentAuthorPubkey: normalizeDescriptorString(this.normalizedParentAuthorPubkey),
parentAuthorRelay: normalizeDescriptorRelay(this.normalizedParentAuthorRelay),
};
if (!descriptor.videoEventId) {
return null;
}
return descriptor;
}
}
export function normalizeCommentTarget(targetInput = {}, overrides = {}) {
return new CommentTargetNormalizer(targetInput, overrides).normalize();
}
export function isVideoCommentEvent(event, descriptor) {
if (!event || typeof event !== "object") {
return false;
}
if (!getAllowedCommentKinds().includes(Number(event.kind))) {
return false;
}
const targetDescriptor =
descriptor && typeof descriptor === "object" ? descriptor : {};
const tags = Array.isArray(event.tags) ? event.tags : [];
const normalizedRootIdentifier = normalizeTagValue(
targetDescriptor.rootIdentifier,
);
const normalizedVideoDefinitionAddress = normalizeTagValue(
targetDescriptor.videoDefinitionAddress,
);
const normalizedVideoEventId = normalizeTagValue(
targetDescriptor.videoEventId,
);
const normalizedParentCommentId = normalizeTagValue(
targetDescriptor.parentCommentId,
);
const requiresRootIdentifierMatch = Boolean(normalizedRootIdentifier);
const requiresAddressMatch =
!requiresRootIdentifierMatch && Boolean(normalizedVideoDefinitionAddress);
const requiresEventMatch =
!requiresRootIdentifierMatch &&
!requiresAddressMatch &&
Boolean(normalizedVideoEventId);
const requiresParentTag = Boolean(normalizedParentCommentId);
let hasIdentifierTag = !requiresRootIdentifierMatch;
let hasDefinitionTag = !requiresAddressMatch;
let hasEventTag = !requiresEventMatch;
let hasParentTag = false;
let matchedDefinitionPointer = false;
let matchedEventPointer = false;
const expectedRootKind = normalizeDescriptorString(
targetDescriptor.rootKind || targetDescriptor.videoKind,
);
const expectedRootAuthor = normalizeDescriptorString(
targetDescriptor.rootAuthorPubkey || targetDescriptor.videoAuthorPubkey,
);
const expectedParentKind = normalizeDescriptorString(targetDescriptor.parentKind);
const expectedParentAuthor = normalizeDescriptorString(
targetDescriptor.parentAuthorPubkey,
);
const kindTagValues = [];
const authorTagValues = [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const [rawName, rawValue] = tag;
const trimmedName = typeof rawName === "string" ? rawName.trim() : "";
const upperName = trimmedName.toUpperCase();
const normalizedValue = normalizeTagValue(rawValue);
if (!upperName || !normalizedValue) {
continue;
}
if (upperName === "I") {
if (
normalizedRootIdentifier &&
normalizedValue === normalizedRootIdentifier
) {
hasIdentifierTag = true;
}
} else if (upperName === "A") {
if (
normalizedVideoDefinitionAddress &&
normalizedValue === normalizedVideoDefinitionAddress
) {
hasDefinitionTag = true;
matchedDefinitionPointer = true;
}
} else if (upperName === "E") {
if (
normalizedVideoEventId &&
normalizedValue === normalizedVideoEventId
) {
hasEventTag = true;
matchedEventPointer = true;
}
if (
normalizedParentCommentId &&
normalizedValue === normalizedParentCommentId
) {
hasParentTag = true;
}
} else if (upperName === "K") {
kindTagValues.push(normalizedValue);
} else if (upperName === "P") {
authorTagValues.push(normalizedValue);
}
}
const sawRootKindTag = kindTagValues.length > 0 && Boolean(expectedRootKind);
const sawRootAuthorTag = authorTagValues.length > 0 && Boolean(expectedRootAuthor);
const sawParentKindTag = kindTagValues.length > 0 && Boolean(expectedParentKind);
const sawParentAuthorTag =
authorTagValues.length > 0 && Boolean(expectedParentAuthor);
const rootKindMatches =
!expectedRootKind || kindTagValues.includes(expectedRootKind);
const rootAuthorMatches =
!expectedRootAuthor || authorTagValues.includes(expectedRootAuthor);
const parentKindMatches =
!expectedParentKind || kindTagValues.includes(expectedParentKind);
const parentAuthorMatches =
!expectedParentAuthor || authorTagValues.includes(expectedParentAuthor);
if (!hasDefinitionTag && requiresAddressMatch && matchedEventPointer) {
hasDefinitionTag = true;
}
if (!hasIdentifierTag && requiresRootIdentifierMatch) {
if (matchedDefinitionPointer || matchedEventPointer) {
devLogger.debug(
"[nostr] Comment accepted without explicit identifier tag due to pointer match",
{
eventId: event?.id,
matchedDefinitionPointer,
matchedEventPointer,
},
);
hasIdentifierTag = true;
}
}
if (!hasIdentifierTag || !hasDefinitionTag || !hasEventTag) {
if (matchedEventPointer && (!requiresParentTag || hasParentTag)) {
devLogger.debug(
"[nostr] Comment accepted via legacy pointer fallback",
{
eventId: event?.id,
hasIdentifierTag,
hasDefinitionTag,
hasEventTag,
requiresParentTag,
hasParentTag,
},
);
hasIdentifierTag = true;
hasDefinitionTag = true;
hasEventTag = true;
} else {
return false;
}
}
if (requiresParentTag && !hasParentTag) {
return false;
}
if (sawRootKindTag && expectedRootKind && !rootKindMatches) {
return false;
}
if (sawRootAuthorTag && expectedRootAuthor && !rootAuthorMatches) {
return false;
}
if (sawParentKindTag && expectedParentKind && !parentKindMatches) {
return false;
}
if (sawParentAuthorTag && expectedParentAuthor && !parentAuthorMatches) {
return false;
}
return true;
}
+70
View File
@@ -0,0 +1,70 @@
// js/nostr/decryptionSchemeCache.js
/**
* Shared decryption scheme cache across all list services (blocks,
* subscriptions, hashtag preferences). When one service discovers the
* correct decryption scheme for a pubkey, the others can reuse it
* immediately instead of independently probing the NIP-07 extension.
*
* This eliminates redundant extension decrypt calls during the critical
* login path where all three services decrypt simultaneously.
*/
// Extended from 10 minutes to 2 hours. The cache is cleared on logout, so
// there's no privacy concern with a longer TTL. A short TTL caused redundant
// scheme probing during normal browsing sessions, triggering unnecessary
// NIP-07 extension calls that slow down background list re-syncs.
const DECRYPTION_CACHE_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
/** @type {Map<string, { scheme: string, timestamp: number }>} */
const cache = new Map();
/**
* Retrieve the last successfully-used decryption scheme for a pubkey.
* Returns null if the entry is missing or expired.
*
* @param {string} pubkey - Hex pubkey to look up.
* @returns {string|null} The scheme identifier (e.g. "nip44_v2") or null.
*/
export function getLastSuccessfulScheme(pubkey) {
if (typeof pubkey !== "string" || !pubkey) {
return null;
}
const entry = cache.get(pubkey);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > DECRYPTION_CACHE_TTL_MS) {
cache.delete(pubkey);
return null;
}
return entry.scheme;
}
/**
* Record a successful decryption scheme for a pubkey so other services
* can skip scheme discovery.
*
* @param {string} pubkey - Hex pubkey.
* @param {string} scheme - The scheme that succeeded (e.g. "nip44_v2").
*/
export function setLastSuccessfulScheme(pubkey, scheme) {
if (typeof pubkey !== "string" || !pubkey) {
return;
}
if (typeof scheme !== "string" || !scheme) {
return;
}
cache.set(pubkey, { scheme, timestamp: Date.now() });
}
/**
* Clear all cached entries. Useful on logout.
*/
export function clearDecryptionSchemeCache() {
cache.clear();
}
-2
View File
@@ -2,7 +2,6 @@
import { NostrClient } from "./client.js";
import {
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "./nip07Permissions.js";
@@ -27,7 +26,6 @@ export function requestDefaultExtensionPermissions(
export {
NostrClient,
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
};
+5 -8
View File
@@ -9,20 +9,17 @@ import { devLogger, userLogger } from "../utils/logger.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { getActiveSigner } from "../nostrClientRegistry.js";
import { isSessionActor } from "./sessionActor.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const DEFAULT_TYPING_EXPIRY_SECONDS = 15;
function sanitizeRelayList(primary, fallback) {
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
if (Array.isArray(primary) && primary.length) {
return primary;
}
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return sanitizeRelayUrls(RELAY_URLS);
return RELAY_URLS;
}
function normalizeString(value) {
+90
View File
@@ -0,0 +1,90 @@
/**
* A Map extension that maintains an author-based index for fast lookups.
* Used for `rawEvents` in NostrClient.
*/
export class EventsMap extends Map {
constructor(entries) {
super(entries);
this.authorIndex = new Map(); // pubkey -> Set<Event>
// If entries provided, index them
if (entries) {
for (const [key, value] of this.entries()) {
this._addToIndex(value);
}
}
}
_normalizePubkey(pubkey) {
if (typeof pubkey !== "string") return "";
return pubkey.trim().toLowerCase();
}
_addToIndex(event) {
if (!event || typeof event !== "object") return;
const pubkey = this._normalizePubkey(event.pubkey);
if (!pubkey) return;
let set = this.authorIndex.get(pubkey);
if (!set) {
set = new Set();
this.authorIndex.set(pubkey, set);
}
set.add(event);
}
_removeFromIndex(event) {
if (!event || typeof event !== "object") return;
const pubkey = this._normalizePubkey(event.pubkey);
if (!pubkey) return;
const set = this.authorIndex.get(pubkey);
if (set) {
set.delete(event);
if (set.size === 0) {
this.authorIndex.delete(pubkey);
}
}
}
set(key, value) {
const existing = this.get(key);
// If the value is identical, we still set it in the map,
// but the index doesn't strictly need updating.
// However, if the key was mapped to a DIFFERENT object with the SAME pubkey,
// we must update the Set (remove old object, add new object).
if (existing) {
if (existing === value) {
// No index change needed
return super.set(key, value);
}
this._removeFromIndex(existing);
}
super.set(key, value);
this._addToIndex(value);
return this;
}
delete(key) {
const value = this.get(key);
const result = super.delete(key);
if (result && value) {
this._removeFromIndex(value);
}
return result;
}
clear() {
super.clear();
this.authorIndex.clear();
}
getEventsByAuthor(pubkey) {
const normalized = this._normalizePubkey(pubkey);
if (!normalized) return [];
const set = this.authorIndex.get(normalized);
// Return array to allow iteration
return set ? Array.from(set) : [];
}
}
+70
View File
@@ -0,0 +1,70 @@
function toFiniteTimestamp(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.max(0, Math.floor(numeric));
}
function normalizeEventId(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}
export function compareListEventsDesc(a, b, options = {}) {
const timestampA = toFiniteTimestamp(a?.created_at);
const timestampB = toFiniteTimestamp(b?.created_at);
if (timestampA !== timestampB) {
return timestampB - timestampA;
}
const preferredKinds = Array.isArray(options.preferredKinds)
? options.preferredKinds
: [];
if (preferredKinds.length) {
const rank = new Map(
preferredKinds.map((kind, index) => [Number(kind), index]),
);
const rankA = rank.has(Number(a?.kind)) ? rank.get(Number(a?.kind)) : Number.MAX_SAFE_INTEGER;
const rankB = rank.has(Number(b?.kind)) ? rank.get(Number(b?.kind)) : Number.MAX_SAFE_INTEGER;
if (rankA !== rankB) {
return rankA - rankB;
}
}
const idA = normalizeEventId(a?.id);
const idB = normalizeEventId(b?.id);
if (idA !== idB) {
return idA < idB ? 1 : -1;
}
return 0;
}
export function selectNewestListEvent(events, options = {}) {
if (!Array.isArray(events) || !events.length) {
return null;
}
let newest = null;
for (const event of events) {
if (!event || typeof event !== "object") {
continue;
}
if (!newest || compareListEventsDesc(event, newest, options) < 0) {
newest = event;
}
}
return newest;
}
export function computeIncrementalSinceWithOverlap(createdAt, overlapSeconds = 1) {
const normalizedCreatedAt = toFiniteTimestamp(createdAt);
const normalizedOverlap = Number.isFinite(overlapSeconds)
? Math.max(0, Math.floor(overlapSeconds))
: 0;
return Math.max(0, normalizedCreatedAt - normalizedOverlap);
}
+965
View File
@@ -0,0 +1,965 @@
import {
DEFAULT_RELAY_URLS,
RELAY_URLS,
ensureNostrTools,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
nostrToolsBootstrapFailure,
} from "../toolkit.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { sanitizeRelayList } from "../nip46Client.js";
import {
logCountTimeoutCleanupFailure,
logRelayCountFailure,
} from "../countDiagnostics.js";
import {
RELAY_CONNECT_TIMEOUT_MS,
RELAY_RECONNECT_BASE_DELAY_MS,
RELAY_RECONNECT_MAX_DELAY_MS,
RELAY_RECONNECT_MAX_ATTEMPTS,
RELAY_BACKOFF_BASE_DELAY_MS,
RELAY_BACKOFF_MAX_DELAY_MS,
RELAY_CIRCUIT_BREAKER_THRESHOLD,
RELAY_CIRCUIT_BREAKER_COOLDOWN_MS,
RELAY_FAILURE_WINDOW_MS,
RELAY_FAILURE_WINDOW_THRESHOLD,
RELAY_SUMMARY_LOG_INTERVAL_MS,
} from "../relayConstants.js";
import { LONG_TIMEOUT_MS } from "../../constants.js";
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
const resolvedTimeout = Number(timeoutMs);
const effectiveTimeout =
Number.isFinite(resolvedTimeout) && resolvedTimeout > 0
? Math.floor(resolvedTimeout)
: 4000;
let timeoutId = null;
let settled = false;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (settled) {
return;
}
settled = true;
if (typeof onTimeout === "function") {
try {
onTimeout();
} catch (cleanupError) {
logCountTimeoutCleanupFailure(cleanupError);
}
}
reject(new Error(message));
}, effectiveTimeout);
Promise.resolve(promise)
.then((value) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(error);
});
});
}
export class ConnectionManager {
constructor() {
this.pool = null;
this.poolPromise = null;
this.relays = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
this.readRelays = Array.from(this.relays);
this.writeRelays = Array.from(this.relays);
this.countUnsupportedRelays = new Set();
this.unreachableRelays = new Set();
this.relayBackoff = new Map();
this.relayFailureCounts = new Map();
this.relayFailureWindows = new Map();
this.relayCircuitBreakers = new Map();
this.relaySummaryLogTimestamps = new Map();
this.relayReconnectTimer = null;
this.relayReconnectAttempt = 0;
this.countRequestCounter = 0;
}
async ensurePool() {
if (this.pool) {
return this.pool;
}
if (this.poolPromise) {
return this.poolPromise;
}
const tools = await ensureNostrTools();
const SimplePool = resolveSimplePoolConstructor(tools);
if (typeof SimplePool !== "function") {
if (tools && typeof tools === "object") {
const availableKeys = Object.keys(tools).join(", ");
devLogger.warn(
"[nostr] NostrTools helpers did not expose SimplePool. Available keys:",
availableKeys
);
} else {
userLogger.warn(
"[nostr] NostrTools helpers were unavailable. Check that nostr-tools bundles can load on this domain."
);
}
if (nostrToolsBootstrapFailure) {
userLogger.warn(
"[nostr] nostr-tools bootstrap failure details:",
nostrToolsBootstrapFailure
);
}
const error = new Error(
"NostrTools SimplePool is unavailable. Verify that nostr-tools resources can load on this domain."
);
error.code = "nostr-simplepool-unavailable";
if (nostrToolsBootstrapFailure) {
error.bootstrapFailure = nostrToolsBootstrapFailure;
}
this.poolPromise = null;
throw error;
}
const creation = Promise.resolve().then(() => {
const instance = new SimplePool();
if (typeof instance.ensureRelay === "function") {
const originalEnsureRelay = instance.ensureRelay.bind(instance);
instance.ensureRelay = async (url) => {
const relay = await originalEnsureRelay(url);
if (relay && typeof relay.setMaxListeners === "function") {
try {
relay.setMaxListeners(200);
} catch (error) {
// ignore
}
}
return relay;
};
} else {
devLogger.warn(
"[nostr] SimplePool.ensureRelay missing; max listeners patch skipped."
);
}
shimLegacySimplePoolMethods(instance);
this.pool = instance;
return instance;
});
const shared = creation
.then((instance) => {
this.poolPromise = Promise.resolve(instance);
return instance;
})
.catch((error) => {
this.poolPromise = null;
throw error;
});
this.poolPromise = shared;
return shared;
}
async connectToRelays() {
const relayTargets = this.getHealthyRelays(this.relays);
if (!relayTargets.length) {
return [];
}
const results = await Promise.all(
relayTargets.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, RELAY_CONNECT_TIMEOUT_MS);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
for (const result of results) {
if (result.success) {
this.clearRelayBackoff(result.url);
} else {
this.markRelayUnreachable(result.url, LONG_TIMEOUT_MS, {
reason: "connect-timeout",
});
if (isDevMode) {
devLogger.warn(`[nostr] Marked relay as unreachable: ${result.url}`);
}
}
}
return results;
}
resolveRelayReconnectDelayMs(attempt) {
const safeAttempt = Number.isFinite(attempt) ? Math.max(0, attempt) : 0;
const computed = RELAY_RECONNECT_BASE_DELAY_MS * Math.pow(2, safeAttempt);
return Math.min(RELAY_RECONNECT_MAX_DELAY_MS, computed);
}
resetRelayReconnectState() {
this.relayReconnectAttempt = 0;
if (this.relayReconnectTimer) {
clearTimeout(this.relayReconnectTimer);
this.relayReconnectTimer = null;
}
}
scheduleRelayReconnect({ reason = "retry" } = {}) {
if (this.relayReconnectTimer) {
return;
}
if (this.relayReconnectAttempt >= RELAY_RECONNECT_MAX_ATTEMPTS) {
if (isDevMode) {
devLogger.debug(
"[nostr] Relay reconnect attempts exhausted.",
{
attempts: this.relayReconnectAttempt,
reason,
},
);
}
return;
}
const delayMs = this.resolveRelayReconnectDelayMs(this.relayReconnectAttempt);
const attemptNumber = this.relayReconnectAttempt + 1;
this.relayReconnectAttempt = attemptNumber;
if (isDevMode) {
devLogger.debug("[nostr] Scheduling relay reconnect attempt.", {
attempt: attemptNumber,
delayMs,
reason,
});
}
this.relayReconnectTimer = setTimeout(async () => {
this.relayReconnectTimer = null;
try {
const results = await this.connectToRelays();
const successful = results.some((result) => result.success);
if (successful) {
this.resetRelayReconnectState();
return;
}
} catch (error) {
devLogger.warn("[nostr] Relay reconnect attempt failed:", error);
}
this.scheduleRelayReconnect({ reason: "reconnect-failed" });
}, delayMs);
}
logRelaySummary({
key,
level = "warn",
message,
payload,
} = {}) {
if (!key || !message) {
return;
}
const now = Date.now();
const lastLogged = this.relaySummaryLogTimestamps.get(key) || 0;
if (now - lastLogged < RELAY_SUMMARY_LOG_INTERVAL_MS) {
return;
}
this.relaySummaryLogTimestamps.set(key, now);
const channel = isDevMode ? devLogger : userLogger;
const logFn =
typeof channel[level] === "function" ? channel[level] : channel.warn;
if (payload) {
logFn(`[nostr] ${message}`, payload);
} else {
logFn(`[nostr] ${message}`);
}
}
resolveRelayBackoffMs(failureCount, ttlOverride) {
const baseMs = RELAY_BACKOFF_BASE_DELAY_MS;
const maxMs = RELAY_BACKOFF_MAX_DELAY_MS;
const computed = Math.min(
maxMs,
baseMs * Math.pow(2, Math.max(0, failureCount - 1))
);
const override = Number(ttlOverride);
if (Number.isFinite(override) && override > 0) {
return Math.min(computed, Math.floor(override));
}
return computed;
}
clearRelayBackoff(url) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const hadBackoffState =
this.relayBackoff.has(normalized) ||
this.unreachableRelays.has(normalized) ||
this.relayFailureCounts.has(normalized) ||
this.relayCircuitBreakers.has(normalized);
this.relayBackoff.delete(normalized);
this.relayFailureCounts.delete(normalized);
this.relayFailureWindows.delete(normalized);
this.unreachableRelays.delete(normalized);
this.relayCircuitBreakers.delete(normalized);
if (hadBackoffState) {
this.logRelaySummary({
key: `relay-recovered:${normalized}`,
level: "info",
message: "Relay recovered; backoff cleared.",
payload: { relay: normalized },
});
}
}
recordRelayFailureWindow(url) {
if (!url || typeof url !== "string") {
return 0;
}
const normalized = url.trim();
if (!normalized) {
return 0;
}
const now = Date.now();
const cutoff = now - RELAY_FAILURE_WINDOW_MS;
const history = this.relayFailureWindows.get(normalized) || [];
const filtered = history.filter(
(timestamp) => Number.isFinite(timestamp) && timestamp >= cutoff,
);
filtered.push(now);
this.relayFailureWindows.set(normalized, filtered);
return filtered.length;
}
markRelayUnreachable(url, ttlMs = LONG_TIMEOUT_MS, { reason = null } = {}) {
if (!url || typeof url !== "string") {
return;
}
const normalized = url.trim();
if (!normalized) {
return;
}
const windowFailureCount = this.recordRelayFailureWindow(normalized);
const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1;
this.relayFailureCounts.set(normalized, nextFailureCount);
const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs);
const retryAt = Date.now() + backoffMs;
this.relayBackoff.set(normalized, {
retryAt,
backoffMs,
failureCount: nextFailureCount,
reason,
});
this.unreachableRelays.add(normalized);
const shouldOpenCircuit =
nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD ||
windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD;
if (shouldOpenCircuit) {
const now = Date.now();
const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS;
const existing = this.relayCircuitBreakers.get(normalized);
const nextOpenUntil =
existing && Number.isFinite(existing.openUntil)
? Math.max(existing.openUntil, openUntil)
: openUntil;
const circuitReason =
reason ||
(nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD
? "consecutive-failures"
: "windowed-failures");
this.relayCircuitBreakers.set(normalized, {
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
});
this.logRelaySummary({
key: `relay-circuit:${normalized}`,
level: "warn",
message: "Circuit breaker opened for relay.",
payload: {
relay: normalized,
openUntil: nextOpenUntil,
failureCount: nextFailureCount,
reason: circuitReason,
},
});
}
this.logRelaySummary({
key: `relay-backoff:${normalized}`,
level: "warn",
message: "Relay backoff applied.",
payload: {
relay: normalized,
backoffMs,
failureCount: nextFailureCount,
retryAt,
reason,
},
});
}
getHealthyRelays(candidates) {
const source = Array.isArray(candidates) ? candidates : this.relays;
if (!this.unreachableRelays.size) {
return source;
}
const now = Date.now();
return source.filter((url) => {
const circuit = this.relayCircuitBreakers.get(url);
if (circuit && Number.isFinite(circuit.openUntil)) {
if (circuit.openUntil > now) {
return false;
}
this.relayCircuitBreakers.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.relayBackoff.delete(url);
this.logRelaySummary({
key: `relay-circuit-reset:${url}`,
level: "info",
message: "Circuit breaker reset for relay.",
payload: { relay: url },
});
}
if (!this.unreachableRelays.has(url)) {
return true;
}
const entry = this.relayBackoff.get(url);
if (!entry) {
return false;
}
if (Number.isFinite(entry.retryAt) && entry.retryAt > now) {
return false;
}
this.relayBackoff.delete(url);
this.relayFailureCounts.delete(url);
this.relayFailureWindows.delete(url);
this.unreachableRelays.delete(url);
this.logRelaySummary({
key: `relay-backoff-expired:${url}`,
level: "info",
message: "Relay backoff expired.",
payload: { relay: url },
});
return true;
});
}
applyRelayPreferences(preferences = {}) {
const normalizedPrefs =
preferences && typeof preferences === "object" ? preferences : {};
const sanitizedAll = sanitizeRelayList(
Array.isArray(normalizedPrefs.all)
? normalizedPrefs.all
: this.relays
);
const effectiveAll =
sanitizedAll.length > 0
? sanitizedAll
: sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const sanitizedRead = sanitizeRelayList(
Array.isArray(normalizedPrefs.read)
? normalizedPrefs.read
: effectiveAll
);
const sanitizedWrite = sanitizeRelayList(
Array.isArray(normalizedPrefs.write)
? normalizedPrefs.write
: effectiveAll
);
this.relays = effectiveAll.length ? effectiveAll : Array.from(RELAY_URLS);
this.readRelays = sanitizedRead.length ? sanitizedRead : Array.from(this.relays);
this.writeRelays = sanitizedWrite.length ? sanitizedWrite : Array.from(this.relays);
}
makeCountUnsupportedError(relayUrl) {
const normalizedUrl =
typeof relayUrl === "string" && relayUrl.trim()
? relayUrl.trim()
: "";
const error = new Error(
`[nostr] Relay ${normalizedUrl} does not support COUNT frames.`
);
error.code = "count-unsupported";
error.relay = normalizedUrl;
error.unsupported = true;
return error;
}
getRequestTimeoutMs(timeoutMs) {
const candidate = Number(timeoutMs);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
const poolTimeout = Number(this.pool?.getTimeout);
if (Number.isFinite(poolTimeout) && poolTimeout > 0) {
return Math.floor(poolTimeout);
}
return 3400;
}
normalizeCountFilter(filter) {
if (!filter || typeof filter !== "object") {
return null;
}
const normalized = {};
const toStringArray = (value) => {
if (value === undefined || value === null) {
return [];
}
const source = Array.isArray(value) ? value : [value];
const collected = [];
for (const item of source) {
if (typeof item !== "string") {
continue;
}
const trimmed = item.trim();
if (!trimmed || collected.includes(trimmed)) {
continue;
}
collected.push(trimmed);
}
return collected;
};
if (filter.kinds !== undefined) {
const kindsSource = Array.isArray(filter.kinds)
? filter.kinds
: [filter.kinds];
const normalizedKinds = [];
const seenKinds = new Set();
for (const candidate of kindsSource) {
const parsed = Number(candidate);
if (!Number.isFinite(parsed)) {
continue;
}
const normalizedValue = Math.floor(parsed);
if (seenKinds.has(normalizedValue)) {
continue;
}
seenKinds.add(normalizedValue);
normalizedKinds.push(normalizedValue);
}
if (normalizedKinds.length) {
normalized.kinds = normalizedKinds;
}
}
const ids = toStringArray(filter.ids);
if (ids.length) {
normalized.ids = ids;
}
const authors = toStringArray(filter.authors);
if (authors.length) {
normalized.authors = authors;
}
for (const [key, value] of Object.entries(filter)) {
if (!key.startsWith("#")) {
continue;
}
const tagValues = toStringArray(value);
if (tagValues.length) {
normalized[key] = tagValues;
}
}
if (filter.since !== undefined) {
const parsedSince = Number(filter.since);
if (Number.isFinite(parsedSince)) {
normalized.since = Math.floor(parsedSince);
}
}
if (filter.until !== undefined) {
const parsedUntil = Number(filter.until);
if (Number.isFinite(parsedUntil)) {
normalized.until = Math.floor(parsedUntil);
}
}
if (filter.limit !== undefined) {
const parsedLimit = Number(filter.limit);
if (Number.isFinite(parsedLimit) && parsedLimit >= 0) {
normalized.limit = Math.floor(parsedLimit);
}
}
return Object.keys(normalized).length ? normalized : null;
}
normalizeCountFilters(filters) {
if (!filters) {
return [];
}
const list = Array.isArray(filters) ? filters : [filters];
const normalized = [];
for (const candidate of list) {
const normalizedFilter = this.normalizeCountFilter(candidate);
if (normalizedFilter) {
normalized.push(normalizedFilter);
}
}
return normalized;
}
generateCountRequestId(prefix = "count") {
this.countRequestCounter += 1;
if (this.countRequestCounter > Number.MAX_SAFE_INTEGER - 1) {
this.countRequestCounter = 1;
}
const normalizedPrefix =
typeof prefix === "string" && prefix.trim() ? prefix.trim() : "count";
const timestamp = Date.now().toString(36);
const counter = this.countRequestCounter.toString(36);
const random = Math.random().toString(36).slice(2, 8);
return `${normalizedPrefix}:${timestamp}:${counter}${random}`;
}
extractCountValue(payload) {
if (typeof payload === "number") {
const value = Math.floor(payload);
return value >= 0 ? value : 0;
}
if (payload && typeof payload === "object") {
const candidate =
typeof payload.count === "number"
? payload.count
: Number(payload.count);
if (Number.isFinite(candidate)) {
const value = Math.floor(candidate);
return value >= 0 ? value : 0;
}
}
const parsed = Number(payload);
if (Number.isFinite(parsed)) {
const value = Math.floor(parsed);
return value >= 0 ? value : 0;
}
return 0;
}
async sendRawCountFrame(relayUrl, filters, options = {}) {
if (!this.pool) {
throw new Error(
"Nostr pool not initialized. Call ensurePool() before requesting counts."
);
}
const normalizedUrl =
typeof relayUrl === "string" ? relayUrl.trim() : "";
if (!normalizedUrl) {
throw new Error("Invalid relay URL for COUNT request.");
}
if (this.countUnsupportedRelays.has(normalizedUrl)) {
throw this.makeCountUnsupportedError(normalizedUrl);
}
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
throw new Error("At least one filter is required for a COUNT request.");
}
const requestId =
typeof options.subId === "string" && options.subId.trim()
? options.subId.trim()
: this.generateCountRequestId();
let relay;
try {
relay = await this.pool.ensureRelay(normalizedUrl);
} catch (error) {
const connectError = new Error(
`Failed to connect to relay ${normalizedUrl}`
);
connectError.code = "relay-connect-failed";
this.markRelayUnreachable(normalizedUrl, LONG_TIMEOUT_MS, {
reason: "connect-failed",
});
throw connectError;
}
if (!relay) {
const relayError = new Error(
`Relay ${normalizedUrl} is unavailable for COUNT requests.`
);
relayError.code = "relay-unavailable";
this.markRelayUnreachable(normalizedUrl, LONG_TIMEOUT_MS, {
reason: "relay-unavailable",
});
throw relayError;
}
const frame = ["COUNT", requestId, ...normalizedFilters];
let countPromise;
if (
relay.openCountRequests instanceof Map &&
typeof relay.send === "function"
) {
countPromise = new Promise((resolve, reject) => {
const cleanup = () => {
if (relay.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
};
relay.openCountRequests.set(requestId, {
resolve: (value) => {
cleanup();
resolve(value);
},
reject: (error) => {
cleanup();
reject(error);
},
});
let sendResult;
try {
sendResult = relay.send(JSON.stringify(frame));
} catch (error) {
cleanup();
reject(error);
return;
}
if (sendResult && typeof sendResult.catch === "function") {
sendResult.catch((error) => {
cleanup();
reject(error);
});
}
});
} else if (typeof relay.count === "function") {
countPromise = relay.count(normalizedFilters, { id: requestId });
} else {
this.countUnsupportedRelays.add(normalizedUrl);
throw this.makeCountUnsupportedError(normalizedUrl);
}
const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs);
let rawResult;
try {
rawResult = await withRequestTimeout(
countPromise,
timeoutMs,
() => {
if (relay?.openCountRequests instanceof Map) {
relay.openCountRequests.delete(requestId);
}
},
`COUNT request timed out after ${timeoutMs}ms`
);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("COUNT request timed out")
) {
error.code = "count-timeout";
}
throw error;
}
const countValue = this.extractCountValue(rawResult);
return ["COUNT", requestId, { count: countValue }];
}
async countEventsAcrossRelays(filters, options = {}) {
const normalizedFilters = this.normalizeCountFilters(filters);
if (!normalizedFilters.length) {
return { total: 0, best: null, perRelay: [], partial: false };
}
const relayList =
Array.isArray(options.relays) && options.relays.length
? options.relays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS;
const normalizedRelayList = relayList
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean);
const eligibleRelays = this.getHealthyRelays(normalizedRelayList);
const eligibleRelaySet = new Set(eligibleRelays);
const activeRelays = [];
const precomputedEntries = [];
for (const url of normalizedRelayList) {
if (this.countUnsupportedRelays.has(url)) {
const error = this.makeCountUnsupportedError(url);
precomputedEntries.push({
url,
ok: false,
error,
unsupported: true,
});
continue;
}
if (!eligibleRelaySet.has(url)) {
precomputedEntries.push({
url,
ok: false,
skipped: true,
reason: "backoff",
});
continue;
}
activeRelays.push(url);
}
const activeResults = await Promise.all(
activeRelays.map(async (url) => {
try {
const frame = await this.sendRawCountFrame(url, normalizedFilters, {
timeoutMs: options.timeoutMs,
});
const count = this.extractCountValue(frame?.[2]);
this.clearRelayBackoff(url);
return { url, ok: true, frame, count };
} catch (error) {
const isUnsupported = error?.code === "count-unsupported";
const isTimeout =
error?.code === "count-timeout" || error?.code === "timeout";
if (isUnsupported) {
this.countUnsupportedRelays.add(url);
} else {
logRelayCountFailure(url, error);
// If the count request timed out, we don't want to kill the entire relay connection
// because it might still be good for subscriptions.
// We only trip the circuit breaker for hard errors.
if (!isTimeout) {
this.markRelayUnreachable(url, LONG_TIMEOUT_MS, {
reason: "count-error",
});
} else if (isDevMode) {
devLogger.warn(
`[nostr] Relay ${url} count timed out (ignored for circuit breaker).`
);
}
}
return {
url,
ok: false,
error,
unsupported: isUnsupported,
timedOut: isTimeout,
errorCode: error?.code || null,
};
}
})
);
const resultsByUrl = new Map();
for (const entry of [...precomputedEntries, ...activeResults]) {
if (entry && typeof entry.url === "string") {
resultsByUrl.set(entry.url, entry);
}
}
const perRelayResults = normalizedRelayList.map((url) => {
if (resultsByUrl.has(url)) {
return resultsByUrl.get(url);
}
return { url, ok: false };
});
let bestEstimate = null;
const perRelay = perRelayResults.map((entry) => {
if (!entry || !entry.ok) {
return entry;
}
const numericValue = Number(entry.count);
const normalizedCount =
Number.isFinite(numericValue) && numericValue >= 0 ? numericValue : 0;
const normalizedEntry = {
...entry,
count: normalizedCount,
};
if (!Number.isFinite(numericValue) || numericValue < 0) {
normalizedEntry.rawCount = entry.count;
}
if (
!bestEstimate ||
normalizedCount > bestEstimate.count ||
(bestEstimate && normalizedCount === bestEstimate.count && !bestEstimate.frame)
) {
bestEstimate = {
relay: normalizedEntry.url,
count: normalizedCount,
frame: normalizedEntry.frame,
};
}
return normalizedEntry;
});
const total = bestEstimate ? bestEstimate.count : 0;
const partial = perRelayResults.some(
(entry) => entry?.timedOut || entry?.skipped
);
return {
total,
best: bestEstimate,
perRelay,
partial,
};
}
}
+408
View File
@@ -0,0 +1,408 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { CACHE_POLICIES } from "../cachePolicies.js";
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
const EVENTS_CACHE_DB_NAME = "bitvid-events-cache";
const EVENTS_CACHE_DB_VERSION = 1;
const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500;
function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) {
if (typeof requestIdleCallback === "function") {
return requestIdleCallback(callback, { timeout });
}
return setTimeout(callback, timeout);
}
function wrapIdbRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
});
}
function waitForTransaction(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error || new Error("IndexedDB transaction failed"));
tx.onabort = () => reject(tx.error || new Error("IndexedDB transaction aborted"));
});
}
/**
* Persists video events and tombstones to IndexedDB.
*
* **Schema:**
* - `events`: Store for video objects. Key: `id`.
* - `tombstones`: Store for deletion timestamps. Key: `key`.
* - `meta`: Store for versioning and timestamps. Key: `key`.
*
* **Strategy: Incremental Persistence**
* - Snapshots are saved periodically (debounced) to avoid write trashing.
* - "Fingerprints" (hashes/stringified) are tracked in memory to minimize I/O.
* - Only changed items (where fingerprint differs) are written to IDB.
* - If the TTL expires, the cache is cleared on restore to force a fresh fetch.
*
* **Concurrency Note:**
* This class assumes single-threaded access (JavaScript main thread).
* It relies on `dirtyKeys` for optimization but falls back to full scans if needed.
*/
export class EventsCacheStore {
/**
* Initializes the EventsCacheStore.
* Tracks fingerprints of persisted items to avoid redundant writes.
*/
constructor() {
/** @type {Promise<IDBDatabase>|null} Singleton promise for opening the DB */
this.dbPromise = null;
/** @type {Map<string, string>} Map of Event ID -> JSON Fingerprint */
this.persistedEventFingerprints = new Map();
/** @type {Map<string, string>} Map of Tombstone Key -> Fingerprint */
this.persistedTombstoneFingerprints = new Map();
/** @type {boolean} Whether we have loaded existing fingerprints from IDB */
this.hasLoadedFingerprints = false;
}
/**
* Checks if IndexedDB is available in the current environment.
* @returns {boolean} True if IndexedDB is supported.
*/
isSupported() {
return typeof indexedDB !== "undefined";
}
/**
* Opens (or returns existing) connection to the IndexedDB database.
* Handles schema upgrades (creating object stores).
*
* @returns {Promise<IDBDatabase|null>} The database instance or null if not supported.
*/
async getDb() {
if (!this.isSupported()) {
return null;
}
if (this.dbPromise) {
return this.dbPromise;
}
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(EVENTS_CACHE_DB_NAME, EVENTS_CACHE_DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("events")) {
db.createObjectStore("events", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("tombstones")) {
db.createObjectStore("tombstones", { keyPath: "key" });
}
if (!db.objectStoreNames.contains("meta")) {
db.createObjectStore("meta", { keyPath: "key" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Failed to open events cache database"));
});
return this.dbPromise;
}
/**
* Computes a unique fingerprint for a video object.
* Used to detect changes without deep comparison.
*
* @param {object} video - The video object to fingerprint.
* @returns {string} The computed fingerprint (JSON string).
*/
computeEventFingerprint(video) {
try {
return JSON.stringify(video);
} catch (error) {
devLogger.warn("[nostr] Failed to fingerprint cached event", error);
return String(Date.now());
}
}
/**
* Computes a fingerprint for a tombstone timestamp.
*
* @param {number} timestamp - The timestamp.
* @returns {string} The fingerprint.
*/
computeTombstoneFingerprint(timestamp) {
return `ts:${timestamp}`;
}
/**
* Pre-loads fingerprints of all persisted items into memory.
*
* This is critical for the "Incremental Persistence" strategy. By knowing
* what is already on disk (via fingerprints), we can avoid writing unchanged records.
*
* @param {IDBDatabase} db - The database instance.
*/
async ensureFingerprintsLoaded(db) {
if (this.hasLoadedFingerprints || !db) {
return;
}
const tx = db.transaction(["events", "tombstones"], "readonly");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const [events, tombstones] = await Promise.all([
wrapIdbRequest(eventsStore.getAll()),
wrapIdbRequest(tombstoneStore.getAll()),
waitForTransaction(tx),
]);
for (const entry of Array.isArray(events) ? events : []) {
if (entry && entry.id && entry.fingerprint) {
this.persistedEventFingerprints.set(entry.id, entry.fingerprint);
}
}
for (const entry of Array.isArray(tombstones) ? tombstones : []) {
if (entry && entry.key) {
const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp);
this.persistedTombstoneFingerprints.set(entry.key, fingerprint);
}
}
this.hasLoadedFingerprints = true;
}
/**
* Reads metadata from the store (version, savedAt).
* @param {IDBDatabase} db - The database instance.
* @returns {Promise<object|undefined>} The metadata object.
*/
async readMeta(db) {
const tx = db.transaction(["meta"], "readonly");
const metaStore = tx.objectStore("meta");
const meta = await wrapIdbRequest(metaStore.get("meta"));
await waitForTransaction(tx);
return meta;
}
/**
* Restores the full state from IndexedDB.
*
* @returns {Promise<{version: number, savedAt: number, events: Map, tombstones: Map}|null>}
* The restored snapshot or null if cache is missing/expired.
*/
async restoreSnapshot() {
const db = await this.getDb();
if (!db) {
return null;
}
const meta = await this.readMeta(db);
if (!meta || meta.version !== 1 || !meta.savedAt) {
return null;
}
// Check TTL: If cache is too old, wipe it and return null to force a fresh fetch.
const now = Date.now();
if (now - meta.savedAt > EVENTS_CACHE_TTL_MS) {
const tx = db.transaction(["events", "tombstones", "meta"], "readwrite");
tx.objectStore("events").clear();
tx.objectStore("tombstones").clear();
tx.objectStore("meta").clear();
await waitForTransaction(tx);
return null;
}
const tx = db.transaction(["events", "tombstones"], "readonly");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const [events, tombstones] = await Promise.all([
wrapIdbRequest(eventsStore.getAll()),
wrapIdbRequest(tombstoneStore.getAll()),
waitForTransaction(tx),
]);
const eventsMap = new Map();
const tombstoneMap = new Map();
for (const entry of Array.isArray(events) ? events : []) {
if (entry && entry.id && entry.video) {
eventsMap.set(entry.id, entry.video);
if (entry.fingerprint) {
this.persistedEventFingerprints.set(entry.id, entry.fingerprint);
}
}
}
for (const entry of Array.isArray(tombstones) ? tombstones : []) {
if (entry && entry.key && Number.isFinite(entry.timestamp)) {
tombstoneMap.set(entry.key, entry.timestamp);
const fingerprint = entry.fingerprint || this.computeTombstoneFingerprint(entry.timestamp);
this.persistedTombstoneFingerprints.set(entry.key, fingerprint);
}
}
this.hasLoadedFingerprints = true;
return {
version: 1,
savedAt: meta.savedAt,
events: eventsMap,
tombstones: tombstoneMap,
};
}
/**
* Core logic for incremental persistence.
* Compares items against `fingerprintsMap` to decide what to write/delete.
*
* **Optimization Logic:**
* 1. Iterates only `dirtyKeys` (if provided) or all `items`.
* 2. Computes current fingerprint.
* 3. Updates IDB only if fingerprint differs from `fingerprintsMap`.
* 4. Updates `fingerprintsMap` with the new fingerprint.
* 5. Checks if deletions are needed (if `items.size < fingerprintsMap.size`).
* - Note: `fingerprintsMap` grows during the loop as new items are added.
* - This ensures that if we deleted items from `items`, `items.size` will be smaller
* than `fingerprintsMap.size`, triggering the deletion scan.
*
* @param {object} params
* @param {IDBObjectStore} params.store - The IDB store to write to.
* @param {Map} params.items - The current state (Map of items).
* @param {Map} params.fingerprintsMap - The map of persisted fingerprints.
* @param {function} params.computeFingerprint - Helper to compute fingerprint.
* @param {Set<string>|null} params.dirtyKeys - Optional set of keys that changed.
* @param {function} params.isValid - Validator for items.
* @param {function} params.createRecord - Helper to create IDB record object.
* @returns {{writes: number, deletes: number, skipped: number}} Stats.
*/
_diffAndStore({
store,
items,
fingerprintsMap,
computeFingerprint,
dirtyKeys,
isValid,
createRecord,
}) {
let writes = 0;
let deletes = 0;
let skipped = 0;
const iterable =
dirtyKeys instanceof Set
? Array.from(dirtyKeys).map((key) => [key, items.get(key)])
: items.entries();
for (const [key, value] of iterable) {
if (!isValid(key, value)) {
continue;
}
if (dirtyKeys && !dirtyKeys.has(key) && fingerprintsMap.has(key)) {
skipped++;
continue;
}
const fingerprint = computeFingerprint(value);
const prevFingerprint = fingerprintsMap.get(key);
if (prevFingerprint === fingerprint) {
continue;
}
store.put(createRecord(key, value, fingerprint));
fingerprintsMap.set(key, fingerprint);
writes++;
}
// Deletion detection strategy:
// If dirtyKeys is null, we are doing a full scan, so check everything.
// If dirtyKeys is provided, we only check deletions if 'items' is smaller than our known persisted set.
// NOTE: fingerprintsMap is mutated in the loop above (adding new/updated items).
// So if items were removed from 'items' map, fingerprintsMap.size (which includes the removed ones)
// will be larger than items.size, correctly triggering the scan.
const shouldScanDeletions = !dirtyKeys || items.size < fingerprintsMap.size;
if (shouldScanDeletions) {
for (const persistedKey of Array.from(fingerprintsMap.keys())) {
if (items.has(persistedKey)) {
continue;
}
store.delete(persistedKey);
fingerprintsMap.delete(persistedKey);
deletes++;
}
}
return { writes, deletes, skipped };
}
/**
* Persists the current state to IndexedDB.
* Only writes changes (diffs against fingerprints).
*
* @param {{events: Map, tombstones: Map, savedAt: number}} payload - The state to save.
* @param {Set<string>|null} [dirtyEventIds] - Optional set of event IDs that have changed.
* @param {Set<string>|null} [dirtyTombstoneKeys] - Optional set of tombstone keys that have changed.
* @returns {Promise<{persisted: boolean, eventWrites: number, eventDeletes: number, tombstoneWrites: number, tombstoneDeletes: number}>}
* Stats about the persistence operation.
*/
async persistSnapshot(
payload,
dirtyEventIds = null,
dirtyTombstoneKeys = null
) {
const db = await this.getDb();
if (!db) {
return { persisted: false };
}
await this.ensureFingerprintsLoaded(db);
const tx = db.transaction(["events", "tombstones", "meta"], "readwrite");
const eventsStore = tx.objectStore("events");
const tombstoneStore = tx.objectStore("tombstones");
const metaStore = tx.objectStore("meta");
const { events, tombstones, savedAt } = payload;
const eventsResult = this._diffAndStore({
store: eventsStore,
items: events,
fingerprintsMap: this.persistedEventFingerprints,
computeFingerprint: (v) => this.computeEventFingerprint(v),
dirtyKeys: dirtyEventIds,
isValid: (id, video) => id && video,
createRecord: (id, video, fingerprint) => ({ id, video, fingerprint }),
});
const tombstonesResult = this._diffAndStore({
store: tombstoneStore,
items: tombstones,
fingerprintsMap: this.persistedTombstoneFingerprints,
computeFingerprint: (v) => this.computeTombstoneFingerprint(v),
dirtyKeys: dirtyTombstoneKeys,
isValid: (key, timestamp) => key && Number.isFinite(timestamp),
createRecord: (key, timestamp, fingerprint) => ({
key,
timestamp,
fingerprint,
}),
});
metaStore.put({ key: "meta", savedAt, version: 1 });
await waitForTransaction(tx);
return {
persisted: true,
eventWrites: eventsResult.writes,
eventDeletes: eventsResult.deletes,
tombstoneWrites: tombstonesResult.writes,
tombstoneDeletes: tombstonesResult.deletes,
skipped: eventsResult.skipped + tombstonesResult.skipped,
};
}
}
+354
View File
@@ -0,0 +1,354 @@
import { EventsCacheStore } from "./EventsCacheStore.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
import { CACHE_POLICIES } from "../cachePolicies.js";
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000);
const EVENTS_CACHE_PERSIST_DELAY_MS = 450;
const EVENTS_CACHE_IDLE_TIMEOUT_MS = 1500;
function scheduleIdleTask(callback, timeout = EVENTS_CACHE_IDLE_TIMEOUT_MS) {
if (typeof requestIdleCallback === "function") {
return requestIdleCallback(callback, { timeout });
}
return setTimeout(callback, timeout);
}
export class PersistenceManager {
constructor(client) {
this.client = client;
this.eventsCacheStore = new EventsCacheStore();
this.cachePersistTimerId = null;
this.cachePersistIdleId = null;
this.cachePersistInFlight = null;
this.cachePersistReason = null;
this.hasRestoredLocalData = false;
}
/**
* Restores application state from the best available local cache.
*
* 1. Attempts to load from `IndexedDB` (preferred).
* 2. Falls back to `localStorage` if IDB fails or is empty.
* 3. Rehydrates `allEvents`, `activeMap`, and `tombstones`.
*
* This implements the "Stale-While-Revalidate" pattern: the UI renders
* immediately with this data while network requests proceed in the background.
*
* @returns {Promise<boolean>} True if data was successfully restored.
*/
async restoreLocalData() {
if (this.hasRestoredLocalData) {
return this.client.allEvents.size > 0;
}
this.hasRestoredLocalData = true;
const restoredFromIndexedDb = await this.restoreFromIndexedDb();
if (restoredFromIndexedDb) {
return true;
}
return this.restoreFromLocalStorage();
}
async restoreFromIndexedDb() {
try {
const payload = await this.eventsCacheStore.restoreSnapshot();
if (!payload) {
return false;
}
return this.applyCachedPayload(payload, "IndexedDB");
} catch (error) {
devLogger.warn("[nostr] Failed to restore IndexedDB cache:", error);
return false;
}
}
restoreFromLocalStorage() {
if (typeof localStorage === "undefined") {
return false;
}
const now = Date.now();
const parsePayload = (raw) => {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed;
}
} catch (err) {
devLogger.warn("[nostr] Failed to parse cached events:", err);
}
return null;
};
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
const applied = this.applyCachedPayload(payload, "localStorage");
if (!applied && payload) {
try {
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
} catch (err) {
devLogger.warn("[nostr] Failed to clear expired cache:", err);
}
}
return applied;
}
applyCachedPayload(payload, sourceLabel) {
if (!payload || payload.version !== 1) {
return false;
}
const now = Date.now();
if (
typeof payload.savedAt !== "number" ||
payload.savedAt <= 0 ||
now - payload.savedAt > EVENTS_CACHE_TTL_MS
) {
return false;
}
const events = payload.events;
if (!events || (typeof events !== "object" && !(events instanceof Map))) {
return false;
}
this.client.allEvents.clear();
this.client.rawEvents.clear();
this.client.activeMap.clear();
this.client.rootCreatedAtByRoot.clear();
this.client.tombstones.clear();
this.client.dirtyEventIds.clear();
this.client.dirtyTombstones.clear();
const tombstoneEntries =
events instanceof Map && payload.tombstones instanceof Map
? payload.tombstones.entries()
: Array.isArray(payload.tombstones)
? payload.tombstones
: [];
for (const entry of tombstoneEntries) {
const [key, value] = Array.isArray(entry) ? entry : [entry.key, entry.timestamp];
const normalizedKey = typeof key === "string" ? key.trim() : "";
const timestamp = Number.isFinite(value) ? Math.floor(value) : 0;
if (normalizedKey && timestamp > 0) {
this.client.tombstones.set(normalizedKey, timestamp);
}
}
const eventEntries =
events instanceof Map ? events.entries() : Object.entries(events);
for (const [id, video] of eventEntries) {
if (!id || !video || typeof video !== "object") {
continue;
}
this.client.applyRootCreatedAt(video);
const activeKey = this.client.getActiveKey(video);
if (video.deleted) {
this.client.recordTombstone(activeKey, video.created_at);
} else {
this.client.applyTombstoneGuard(video);
}
this.client.allEvents.set(id, video);
if (video.deleted) {
continue;
}
const existing = this.client.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.client.activeMap.set(activeKey, video);
}
}
if (this.client.allEvents.size > 0 && isDevMode) {
devLogger.log(
`[nostr] Restored ${this.client.allEvents.size} cached events from ${sourceLabel}`,
);
}
this.client.dirtyEventIds.clear();
this.client.dirtyTombstones.clear();
return this.client.allEvents.size > 0;
}
clearCachePersistHandles() {
if (this.cachePersistTimerId) {
clearTimeout(this.cachePersistTimerId);
this.cachePersistTimerId = null;
}
if (this.cachePersistIdleId) {
if (typeof cancelIdleCallback === "function") {
try {
cancelIdleCallback(this.cachePersistIdleId);
} catch (error) {
devLogger.warn("[nostr] Failed to cancel cache idle callback:", error);
}
} else {
clearTimeout(this.cachePersistIdleId);
}
this.cachePersistIdleId = null;
}
}
buildCachePayload() {
return {
version: 1,
savedAt: Date.now(),
events: new Map(this.client.allEvents),
tombstones: new Map(this.client.tombstones),
};
}
/**
* Schedules a persistence operation to save current state to cache.
*
* - Uses a debounce strategy (75ms) to prevent write thrashing during bursts.
* - Can be forced to run immediately via `options.immediate`.
* - Coordinates with `requestIdleCallback` to avoid blocking the main thread.
*
* @param {string} [reason="unspecified"] - Debug label for why persistence was triggered.
* @param {object} [options] - Configuration.
* @param {boolean} [options.immediate=false] - If true, bypasses debounce and persists immediately.
* @returns {Promise<boolean>|null} The persistence promise or null if debounced.
*/
saveLocalData(reason = "unspecified", options = {}) {
const { immediate = false } = options;
if (immediate) {
this.clearCachePersistHandles();
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
this.cachePersistInFlight = null;
});
return this.cachePersistInFlight;
}
if (this.cachePersistTimerId || this.cachePersistIdleId || this.cachePersistInFlight) {
return this.cachePersistInFlight;
}
this.cachePersistReason = reason;
devLogger.log(
`[nostr] Scheduling cached events persist (${reason}) with debounce ${EVENTS_CACHE_PERSIST_DELAY_MS}ms`,
);
this.cachePersistTimerId = setTimeout(() => {
this.cachePersistTimerId = null;
this.cachePersistIdleId = scheduleIdleTask(() => {
this.cachePersistIdleId = null;
this.cachePersistInFlight = this.persistLocalData(reason).finally(() => {
this.cachePersistInFlight = null;
});
});
}, EVENTS_CACHE_PERSIST_DELAY_MS);
return this.cachePersistInFlight;
}
/**
* Executes the actual persistence logic, writing to IndexedDB (preferred) or localStorage.
*
* @param {string} reason - Debug label.
* @returns {Promise<boolean>} True if successful.
* @private
*/
async persistLocalData(reason = "unspecified") {
// Strategy:
// 1. Try to persist to IndexedDB (preferred, large capacity).
// 2. If IDB fails or returns false, fall back to LocalStorage (limited capacity).
// The `EventsCacheStore` handles differential updates (only saving changed fingerprints)
// to keep IDB writes fast.
// Capture snapshots of dirty sets to optimize persistence
const dirtyEventIdsSnapshot = new Set(this.client.dirtyEventIds);
const dirtyTombstonesSnapshot = new Set(this.client.dirtyTombstones);
// Clear the tracked items from the main sets so we don't re-process them next time.
// New items added during the async save will remain in the main sets.
for (const id of dirtyEventIdsSnapshot) {
this.client.dirtyEventIds.delete(id);
}
for (const key of dirtyTombstonesSnapshot) {
this.client.dirtyTombstones.delete(key);
}
const payload = this.buildCachePayload();
const startedAt = Date.now();
let summary = null;
let target = "localStorage";
try {
summary = await this.eventsCacheStore.persistSnapshot(
payload,
dirtyEventIdsSnapshot,
dirtyTombstonesSnapshot
);
if (summary?.persisted) {
target = "IndexedDB";
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to persist events cache to IndexedDB:",
error
);
}
if (!summary?.persisted) {
// If persistence failed, re-queue the dirty items for the next attempt.
for (const id of dirtyEventIdsSnapshot) {
this.client.dirtyEventIds.add(id);
}
for (const key of dirtyTombstonesSnapshot) {
this.client.dirtyTombstones.add(key);
}
this.persistCacheToLocalStorage(payload);
}
const durationMs = Date.now() - startedAt;
devLogger.log(
`[nostr] Cached events persisted via ${target} (reason=${reason}, duration=${durationMs}ms, events+${summary?.eventWrites ?? 0}/-${summary?.eventDeletes ?? 0}, tombstones+${summary?.tombstoneWrites ?? 0}/-${summary?.tombstoneDeletes ?? 0}, skipped=${summary?.skipped ?? 0})`,
);
return summary?.persisted;
}
persistCacheToLocalStorage(payload) {
if (typeof localStorage === "undefined") {
return;
}
const serializedEvents = {};
for (const [id, vid] of payload.events.entries()) {
serializedEvents[id] = vid;
}
const serializedPayload = {
...payload,
events: serializedEvents,
tombstones: Array.from(payload.tombstones.entries()),
};
try {
localStorage.setItem(
EVENTS_CACHE_STORAGE_KEY,
JSON.stringify(serializedPayload),
);
} catch (err) {
devLogger.warn("[nostr] Failed to persist events cache:", err);
}
}
}
File diff suppressed because it is too large. Load diff
+1 -5
View File
@@ -21,16 +21,12 @@ export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([
"nip44.v2.decrypt",
]);
export const DEFAULT_NIP07_CORE_METHODS = Object.freeze([
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
// Core auth + relay metadata
"get_public_key",
"sign_event",
"read_relays",
"write_relays",
]);
export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([
...DEFAULT_NIP07_CORE_METHODS,
...DEFAULT_NIP07_ENCRYPTION_METHODS,
]);
+1 -37
View File
@@ -43,28 +43,6 @@ export const NIP46_ENCRYPTION_ALGORITHMS = Object.freeze([
"nip04",
]);
function isSecureRelayContext() {
if (typeof window === "undefined") {
return false;
}
const protocol = window?.location?.protocol;
return protocol === "https:";
}
function resolveRelayProtocol(protocol, { enforceTls = false } = {}) {
if (!protocol) {
return null;
}
const normalized = protocol.toLowerCase();
if (normalized === "wss:") {
return "wss:";
}
if (normalized === "ws:") {
return enforceTls ? "wss:" : "ws:";
}
return null;
}
/**
* resolves the appropriate storage mechanism (localStorage or polyfill) for NIP-46 sessions.
* @returns {Storage|null} The storage interface or null if unavailable.
@@ -94,7 +72,6 @@ export function sanitizeRelayList(list) {
if (!Array.isArray(list)) {
return sanitized;
}
const enforceTls = isSecureRelayContext();
list.forEach((value) => {
if (typeof value !== "string") {
@@ -113,29 +90,16 @@ export function sanitizeRelayList(list) {
let normalized = trimmed.replace(/\/+$/, "");
try {
const parsed = new URL(normalized);
const parsed = new URL(trimmed);
if (!parsed.hostname) {
return;
}
const resolvedProtocol = resolveRelayProtocol(parsed.protocol, {
enforceTls,
});
if (!resolvedProtocol) {
return;
}
if (parsed.protocol !== resolvedProtocol) {
parsed.protocol = resolvedProtocol;
}
const pathname = parsed.pathname.replace(/\/+$/, "");
normalized = `${parsed.protocol}//${parsed.host}${pathname}${parsed.search || ""}`;
} catch (error) {
// Ignore URL parsing failures and fall back to the trimmed string.
}
if (enforceTls && normalized.startsWith("ws://")) {
return;
}
if (seen.has(normalized)) {
return;
}
+1093
View File
File diff suppressed because it is too large. Load diff
+158
View File
@@ -0,0 +1,158 @@
/**
* Logging utilities for NIP-46 (Nostr Connect) client and diagnostics.
*/
export function summarizeHexForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const normalized = value.trim().toLowerCase();
if (normalized.length <= 12) {
return `${normalized} (len:${normalized.length})`;
}
return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`;
}
export function summarizeSecretForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "<empty>";
}
const trimmed = value.trim();
const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`;
return `${visible} (len:${trimmed.length})`;
}
export function summarizeMetadataForLog(metadata) {
if (!metadata || typeof metadata !== "object") {
return [];
}
return Object.keys(metadata).slice(0, 12);
}
export function summarizeUrlForLog(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
const trimmed = value.trim();
try {
const url = new URL(trimmed);
return {
origin: url.origin,
pathname: url.pathname,
hasQuery: Boolean(url.search),
hasHash: Boolean(url.hash),
length: trimmed.length,
};
} catch (error) {
const length = trimmed.length;
if (length <= 64) {
return `${trimmed} (len:${length})`;
}
return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`;
}
}
export function summarizePayloadPreviewForLog(value) {
if (typeof value !== "string") {
return { type: typeof value };
}
const trimmed = value.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
return {
type: "string",
length: trimmed.length,
preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`,
};
}
export function summarizeRpcParamsForLog(method, params) {
if (!Array.isArray(params)) {
return [];
}
return params.map((param, index) => {
if (typeof param === "string") {
if (method === "connect" && index === 1) {
return { index, secret: summarizeSecretForLog(param) };
}
const trimmed = param.trim();
if (!trimmed) {
return { index, type: "string", length: 0 };
}
if (method === "sign_event") {
return { index, type: "string", length: trimmed.length };
}
if (trimmed.length <= 64) {
return { index, value: trimmed, length: trimmed.length };
}
return {
index,
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`,
};
}
if (param && typeof param === "object") {
return {
index,
type: Array.isArray(param) ? "array" : "object",
keys: Object.keys(param).slice(0, 6),
};
}
return { index, type: typeof param };
});
}
export function summarizeRpcResultForLog(method, result) {
if (typeof result === "string") {
const trimmed = result.trim();
if (!trimmed) {
return { type: "string", length: 0 };
}
if (method === "connect") {
return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) };
}
if (trimmed.length <= 96) {
return { type: "string", length: trimmed.length, value: trimmed };
}
return {
type: "string",
length: trimmed.length,
preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`,
};
}
if (!result) {
return { type: typeof result };
}
if (typeof result === "object") {
return {
type: Array.isArray(result) ? "array" : "object",
keys: Object.keys(result).slice(0, 6),
};
}
return { type: typeof result };
}
export function summarizeRelayPublishResultsForLog(results) {
if (!Array.isArray(results)) {
return [];
}
return results.map((entry) => ({
relay: entry?.relay || "",
success: Boolean(entry?.ok),
reason: entry?.error ? entry.error?.message || String(entry.error) : null,
}));
}
+1 -12
View File
@@ -6,10 +6,6 @@ import {
resolveInfoJsonUrl,
} from "../utils/storagePointer.js";
import { getCachedNostrTools } from "./toolkit.js";
import {
validateEventAgainstSchema,
NOTE_TYPES,
} from "../nostrEventSchemas.js";
function stringFromInput(value) {
if (value === null || value === undefined) {
@@ -938,20 +934,13 @@ export function buildNip71VideoEvent({
tags.push(...pointerTags);
}
const event = {
return {
kind: normalizeNip71Kind(metadata.kind),
pubkey: normalizedPubkey,
created_at: timestamp,
tags,
content: summary,
};
validateEventAgainstSchema(
event.kind === 22 ? NOTE_TYPES.NIP71_SHORT_VIDEO : NOTE_TYPES.NIP71_VIDEO,
event,
);
return event;
}
export function collectNip71PointerRequests(videos = []) {
+5 -8
View File
@@ -7,21 +7,18 @@ import { LRUCache } from "../utils/lruCache.js";
import { CACHE_POLICIES } from "./cachePolicies.js";
import { NOTE_TYPES } from "../nostrEventSchemas.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION];
const reactionCache = new LRUCache({ maxSize: 100 });
function sanitizeRelayList(primary, fallback) {
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
if (Array.isArray(primary) && primary.length) {
return primary;
}
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return sanitizeRelayUrls(RELAY_URLS);
return RELAY_URLS;
}
function normalizeString(value) {
+248
View File
@@ -0,0 +1,248 @@
import { isDevMode } from "../config.js";
import { devLogger } from "../utils/logger.js";
import { withRequestTimeout } from "../utils/asyncUtils.js";
import { normalizeNostrPubkey, sanitizeRelayList } from "./nip46Client.js";
import { DEFAULT_RELAY_URLS, RELAY_URLS } from "./toolkit.js";
import { STANDARD_TIMEOUT_MS } from "../constants.js";
export class RelayBatchFetcher {
constructor(client) {
this.client = client;
}
async fetchListIncrementally({
kind,
pubkey,
dTag,
relayUrls,
fetchFn,
since,
timeoutMs = STANDARD_TIMEOUT_MS,
maxRelays = 8,
} = {}) {
if (!kind || !pubkey) {
throw new Error("fetchListIncrementally requires kind and pubkey");
}
const normalizedPubkey = normalizeNostrPubkey(pubkey);
if (!normalizedPubkey) {
throw new Error("Invalid pubkey for fetchListIncrementally");
}
const relaysToUse = Array.isArray(relayUrls) && relayUrls.length
? relayUrls
: this.client.relays;
const sanitizedRequestedRelays = sanitizeRelayList(relaysToUse);
const healthyCandidates = this.client.getHealthyRelays(sanitizedRequestedRelays);
const healthySet = new Set(healthyCandidates);
const remainingRequested = sanitizedRequestedRelays.filter(
(relayUrl) => !healthySet.has(relayUrl),
);
let relayCandidates = [...healthyCandidates, ...remainingRequested];
if (!healthyCandidates.length && relaysToUse.length) {
const defaultFallback = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
relayCandidates = sanitizedRequestedRelays.length
? sanitizedRequestedRelays
: defaultFallback.length
? defaultFallback
: sanitizeRelayList(Array.from(RELAY_URLS));
devLogger.warn(
"[fetchListIncrementally] Healthy relays exhausted; using fallback relay list for one-off fetch.",
{
requested: relaysToUse,
fallback: relayCandidates,
},
);
}
const readPreferences = new Set(Array.isArray(this.client.readRelays) ? this.client.readRelays : []);
// Sort relays: prefer user's read relays first
const sortedRelays = [...relayCandidates].sort((a, b) => {
const aPreferred = readPreferences.has(a);
const bPreferred = readPreferences.has(b);
const aHealthy = healthySet.has(a);
const bHealthy = healthySet.has(b);
if (aHealthy && !bHealthy) return -1;
if (!aHealthy && bHealthy) return 1;
if (aPreferred && !bPreferred) return -1;
if (!aPreferred && bPreferred) return 1;
return 0;
});
// Cap the number of relays to avoid excessive requests
const relayCap = Number.isFinite(maxRelays)
? Math.max(1, Math.floor(maxRelays))
: 8;
const cappedRelays = sortedRelays.slice(0, relayCap);
const normalizedRelays = sanitizeRelayList(cappedRelays);
if (isDevMode) {
devLogger.log("[fetchListIncrementally] Selected relays:", {
count: normalizedRelays.length,
relays: normalizedRelays,
});
}
const results = [];
const concurrencyLimit = 8;
// We'll use the pool's list method if fetchFn isn't provided,
// but we need to call it per-relay.
const pool = await this.client.ensurePool();
const actualFetchFn = typeof fetchFn === "function"
? fetchFn
: (r, f, timeout) => pool.list([r], [f], { timeout });
const chunks = [];
for (let i = 0; i < normalizedRelays.length; i += concurrencyLimit) {
chunks.push(normalizedRelays.slice(i, i + concurrencyLimit));
}
let anySuccess = false;
const failures = [];
for (const chunk of chunks) {
const promises = chunk.map(async (relayUrl) => {
let lastSeen = this.client.getSyncLastSeen(
kind,
normalizedPubkey,
dTag,
relayUrl,
);
// If an explicit 'since' override is provided, use it instead of storage.
// This allows services to anchor fetching to their own known state.
if (since !== undefined) {
const overrideSince = Number(since);
lastSeen =
Number.isFinite(overrideSince) && overrideSince >= 0
? Math.floor(overrideSince)
: 0;
}
const filter = {
kinds: [kind],
authors: [normalizedPubkey],
};
if (dTag) {
filter["#d"] = [dTag];
}
// Strategy:
// If we have a lastSeen, ask for since = lastSeen + 1.
// If that fails or returns nothing, we don't necessarily fall back
// unless there's an error.
// Wait, requirements say:
// "If metadata is missing for a relay → do a full fetch"
// "If a relay returns an error ... fall back to full fetch"
let doFullFetch = true;
if (lastSeen > 0) {
filter.since = lastSeen + 1;
doFullFetch = false;
}
// Allow callers to override list fetch timeouts without changing non-list query behavior.
const listTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : STANDARD_TIMEOUT_MS;
try {
// Wrap fetch with a timeout to prevent hanging on slow relays
let events = await withRequestTimeout(
actualFetchFn(relayUrl, filter, listTimeoutMs),
listTimeoutMs,
null,
`Fetch from ${relayUrl} timed out`
);
// If incremental returned empty, we assume no updates (success).
// But if we did a full fetch (no since), empty means empty.
if (!events || !Array.isArray(events)) {
events = [];
}
// On success, update lastSeen with max created_at
let maxCreated = 0;
for (const ev of events) {
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
if (maxCreated > 0) {
// Only update if we found something newer or if we did a full fetch
// Actually, if we did incremental and found new stuff, maxCreated > lastSeen.
// If we did full fetch, maxCreated is the latest.
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
}
return { events, ok: true };
} catch (error) {
// Fallback to full fetch if incremental failed or if relay error
if (!doFullFetch) {
try {
delete filter.since;
const events = await withRequestTimeout(
actualFetchFn(relayUrl, filter, listTimeoutMs),
listTimeoutMs,
null,
`Full fetch fallback from ${relayUrl} timed out`
);
// Update lastSeen on success of fallback
let maxCreated = 0;
if (Array.isArray(events)) {
for (const ev of events) {
if (ev.created_at > maxCreated) {
maxCreated = ev.created_at;
}
}
}
if (maxCreated > 0) {
this.client.updateSyncLastSeen(kind, normalizedPubkey, dTag, relayUrl, maxCreated);
}
return { events: events || [], ok: true };
} catch (fallbackError) {
devLogger.warn(`[fetchListIncrementally] Full fetch fallback failed for ${relayUrl}`, fallbackError);
return { error: fallbackError, ok: false };
}
} else {
devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error);
this.client.markRelayUnreachable(relayUrl);
return { error, ok: false };
}
}
});
const chunkResults = await Promise.all(promises);
for (const res of chunkResults) {
if (res.ok) {
anySuccess = true;
if (Array.isArray(res.events)) {
results.push(...res.events);
}
} else {
failures.push(res.error);
}
}
}
if (!anySuccess && normalizedRelays.length > 0) {
const error = new Error("All relays failed to fetch list.");
error.code = "fetch-failed";
error.failures = failures;
throw error;
}
// Deduplicate by ID
const uniqueEvents = new Map();
for (const ev of results) {
if (ev && ev.id) {
uniqueEvents.set(ev.id, ev);
}
}
return Array.from(uniqueEvents.values());
}
}
+25
View File
@@ -0,0 +1,25 @@
// js/nostr/relayConstants.js
// Canonical relay timing and circuit-breaker constants.
// Import from here — do NOT duplicate these values in other modules.
import {
SHORT_TIMEOUT_MS,
LONG_TIMEOUT_MS,
ONE_SECOND_MS,
TEN_MINUTES_MS,
FIVE_MINUTES_MS,
MEDIUM_TIMEOUT_MS,
} from "../constants.js";
export const RELAY_CONNECT_TIMEOUT_MS = SHORT_TIMEOUT_MS;
export const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
export const RELAY_RECONNECT_MAX_DELAY_MS = LONG_TIMEOUT_MS;
export const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
export const RELAY_BACKOFF_BASE_DELAY_MS = ONE_SECOND_MS;
export const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
export const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
export const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = TEN_MINUTES_MS;
export const RELAY_FAILURE_WINDOW_MS = FIVE_MINUTES_MS;
export const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
export const RELAY_SUMMARY_LOG_INTERVAL_MS = MEDIUM_TIMEOUT_MS;
export const RELAY_BACKGROUND_CONCURRENCY = 3;
-11
View File
@@ -487,21 +487,10 @@ export function isSessionActor(nostrClient) {
return !!sa && sa.source !== "nsec";
}
function _closeSessionActorDb() {
if (sessionActorDbPromise) {
return sessionActorDbPromise.then((db) => {
db.close();
sessionActorDbPromise = null;
});
}
return Promise.resolve();
}
export const __testExports = {
arrayBufferToBase64,
base64ToUint8Array,
deriveSessionEncryptionKey,
generateRandomBytes,
isSubtleCryptoAvailable,
_closeSessionActorDb,
};
+12
View File
@@ -0,0 +1,12 @@
// js/nostr/utils.js
export 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}`;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* VideoEventBuffer
*
* Handles buffering and batch processing of incoming video events
* to prevent UI thrashing during high-volume updates (e.g. initial load).
*/
import { isDevMode } from "../config.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { convertEventToVideo } from "./nip71.js";
export class VideoEventBuffer {
/**
* @param {import("./client.js").NostrClient} client - The parent client instance (for state access).
* @param {function(object[]): void} onVideo - The UI callback to fire with batched updates.
*/
constructor(client, onVideo) {
this.client = client;
this.onVideo = onVideo;
this.buffer = [];
this.invalidEvents = [];
this.flushTimerId = null;
this.FLUSH_DEBOUNCE_MS = 75;
// Buffer updates while hidden to avoid UI thrashing
this.pendingVideos = [];
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
}
/**
* Pushes a raw event into the buffer and schedules a debounced flush.
* @param {import("nostr-tools").Event} event - The incoming Nostr event.
*/
push(event) {
this.buffer.push(event);
this.scheduleFlush(false);
}
/**
* Schedules processing of the buffer.
* Uses a debounce timer to wait for the stream to pause (pressure valve).
*
* @param {boolean} [immediate=false] - If true, flushes immediately (e.g. on EOSE).
*/
scheduleFlush(immediate = false) {
if (this.flushTimerId) {
if (!immediate) {
return;
}
clearTimeout(this.flushTimerId);
this.flushTimerId = null;
}
if (immediate) {
this.flush();
return;
}
this.flushTimerId = setTimeout(() => {
this.flushTimerId = null;
this.flush();
}, this.FLUSH_DEBOUNCE_MS);
}
/**
* Processes all buffered events in a single batch.
*
* **Steps:**
* 1. Validates and converts raw events to Video objects.
* 2. Checks for tombstones (deleted videos).
* 3. Updates the client's `activeMap` (Latest Wins).
* 4. Triggers the UI callback *once*.
* 5. Triggers background hydration of NIP-71 metadata.
* 6. Persists state to IndexedDB.
*/
flush() {
if (!this.buffer.length) {
return;
}
const toProcess = this.buffer;
this.buffer = [];
const updatedVideos = [];
for (const evt of toProcess) {
try {
if (evt && evt.id) {
this.client.rawEvents.set(evt.id, evt);
}
const video = convertEventToVideo(evt);
if (video.invalid) {
this.invalidEvents.push({ id: video.id, reason: video.reason });
continue;
}
// Merge any NIP-71 metadata we might already have cached for this video
this.client.mergeNip71MetadataIntoVideo(video);
// Determine the "true" creation time of the root video
this.client.applyRootCreatedAt(video);
const activeKey = this.client.getActiveKey(video);
const wasDeletedEvent = video.deleted === true;
// If this is a deletion event (Kind 5 or deletion marker), record a tombstone
// to prevent older versions from resurrecting.
if (wasDeletedEvent) {
this.client.recordTombstone(activeKey, video.created_at);
} else {
// Otherwise, check if this video is already known to be deleted
this.client.applyTombstoneGuard(video);
}
// Store in allEvents (history preservation)
this.client.allEvents.set(evt.id, video);
this.client.dirtyEventIds.add(evt.id);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
if (activeKey) {
if (wasDeletedEvent) {
this.client.activeMap.delete(activeKey);
} else {
const currentActive = this.client.activeMap.get(activeKey);
if (currentActive?.id === video.id) {
this.client.activeMap.delete(activeKey);
}
}
}
continue;
}
// LATEST-WINS LOGIC
// We only update the UI if the incoming video is newer than what we have.
// This handles the "Edit" case where multiple versions exist on relays.
const prevActive = this.client.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.client.activeMap.set(activeKey, video);
updatedVideos.push(video);
}
} catch (err) {
devLogger.error("[VideoEventBuffer] Error processing event:", err);
}
}
if (updatedVideos.length > 0) {
if (typeof document !== "undefined" && document.hidden) {
this.pendingVideos.push(...updatedVideos);
} else {
// Trigger the callback once per batch to avoid UI thrashing
this.onVideo(updatedVideos);
}
// Fetch NIP-71 metadata (categorization tags) in the background for the whole batch
this.client.populateNip71MetadataForVideos(updatedVideos)
.then(() => {
for (const video of updatedVideos) {
this.client.applyRootCreatedAt(video);
}
})
.catch((error) => {
devLogger.warn(
"[nostr] Failed to hydrate NIP-71 metadata for live video batch:",
error
);
});
}
// Persist processed events after each flush so reloads warm quickly.
this.client.saveLocalData("subscribeVideos:flush");
}
handleVisibilityChange() {
if (typeof document !== "undefined" && !document.hidden) {
if (this.pendingVideos.length > 0) {
const batch = this.pendingVideos;
this.pendingVideos = [];
this.onVideo(batch);
}
}
}
/**
* Called when the subscription reaches End-of-Stored-Events (EOSE).
* Triggers an immediate flush to ensure the UI is fully up-to-date.
*/
handleEose() {
if (isDevMode && this.invalidEvents.length > 0) {
userLogger.warn(
`[subscribeVideos] found ${this.invalidEvents.length} invalid video notes (with reasons):`,
this.invalidEvents
);
}
devLogger.log(
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
);
this.scheduleFlush(true);
}
/**
* Cleans up timers and flushes any remaining events.
* Call when unsubscribing.
*/
cleanup() {
if (this.flushTimerId) {
clearTimeout(this.flushTimerId);
this.flushTimerId = null;
}
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
}
// Ensure any straggling events are flushed before tearing down.
this.flush();
}
}
+491
View File
@@ -0,0 +1,491 @@
import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js";
import { infoHashFromMagnet } from "../magnets.js";
import { resolveStoragePointerValue, getStoragePointerFromTags } from "../utils/storagePointer.js";
import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js";
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
import { inferMimeTypeFromUrl } from "../utils/mime.js";
import { devLogger } from "../utils/logger.js";
export function extractVideoPublishPayload(rawPayload) {
let videoData = rawPayload;
let nip71Metadata = null;
if (rawPayload && typeof rawPayload === "object") {
if (rawPayload.nip71 && typeof rawPayload.nip71 === "object") {
nip71Metadata = rawPayload.nip71;
}
if (
rawPayload.legacyFormData &&
typeof rawPayload.legacyFormData === "object"
) {
videoData = rawPayload.legacyFormData;
} else if (
Object.prototype.hasOwnProperty.call(rawPayload, "legacyFormData")
) {
videoData = rawPayload.legacyFormData || {};
}
}
if (!videoData || typeof videoData !== "object") {
videoData = {};
}
const normalizedVideoData = { ...videoData };
const isNsfw = videoData.isNsfw === true;
normalizedVideoData.isNsfw = isNsfw;
normalizedVideoData.isForKids =
videoData.isForKids === true && !isNsfw;
return { videoData: normalizedVideoData, nip71Metadata };
}
export async function prepareVideoPublishPayload(videoPayload, pubkey, { timestamp = Math.floor(Date.now() / 1000) } = {}) {
const normalizedPubkey = typeof pubkey === "string" ? pubkey.trim() : "";
if (!normalizedPubkey) {
throw new Error("Not logged in to publish video.");
}
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
devLogger.log("Publishing new video with data:", videoData);
if (nip71Metadata) {
devLogger.log("Including NIP-71 metadata:", nip71Metadata);
}
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
const finalMagnet = rawMagnet.trim();
const finalUrl =
typeof videoData.url === "string" ? videoData.url.trim() : "";
const finalThumbnail =
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
const finalDescription =
typeof videoData.description === "string"
? videoData.description.trim()
: "";
const finalTitle =
typeof videoData.title === "string" ? videoData.title.trim() : "";
const providedMimeType =
typeof videoData.mimeType === "string"
? videoData.mimeType.trim().toLowerCase()
: "";
const createdAt = timestamp;
const seriesIdentifierCandidates = [
videoData.videoRootId,
videoData.seriesId,
videoData.seriesIdentifier,
];
let seriesIdentifier = "";
for (const candidate of seriesIdentifierCandidates) {
const normalized = typeof candidate === "string" ? candidate.trim() : "";
if (normalized) {
seriesIdentifier = normalized;
break;
}
}
if (!seriesIdentifier) {
seriesIdentifier = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const videoRootId = seriesIdentifier;
const dTagValue = seriesIdentifier;
const finalEnableComments =
videoData.enableComments === false ? false : true;
const finalIsNsfw = videoData.isNsfw === true;
const finalIsForKids =
videoData.isForKids === true && !finalIsNsfw;
const wantPrivate = videoData.isPrivate === true;
const finalWs =
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
const finalXs =
typeof videoData.xs === "string" ? videoData.xs.trim() : "";
const infoHashCandidates = [videoData.infoHash];
let infoHash = "";
for (const candidate of infoHashCandidates) {
const normalized = infoHashFromMagnet(candidate);
if (normalized) {
infoHash = normalized;
break;
}
}
if (!infoHash && finalMagnet) {
infoHash = infoHashFromMagnet(finalMagnet) || "";
}
const fileSha256 = normalizeHexHash(videoData.fileSha256);
const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256);
const storagePointer = resolveStoragePointerValue({
storagePointer: videoData.storagePointer,
url: finalUrl,
infoHash,
fallbackId: videoRootId,
provider: videoData.storageProvider,
});
const contentObject = {
version: 3,
title: finalTitle,
url: finalUrl,
magnet: finalMagnet,
thumbnail: finalThumbnail,
description: finalDescription,
mode: videoData.mode || "live",
videoRootId,
deleted: false,
isPrivate: videoData.isPrivate ?? false,
isNsfw: finalIsNsfw,
isForKids: finalIsForKids,
enableComments: finalEnableComments,
};
if (infoHash) {
contentObject.infoHash = infoHash;
}
if (fileSha256) {
contentObject.fileSha256 = fileSha256;
}
if (originalFileSha256) {
contentObject.originalFileSha256 = originalFileSha256;
}
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
const nip71Tags = buildNip71MetadataTags(
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
);
const additionalTags = storagePointer
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
const event = buildVideoPostEvent({
pubkey: normalizedPubkey,
created_at: createdAt,
dTagValue,
content: contentObject,
additionalTags,
});
return {
event,
videoData,
nip71Metadata,
finalUrl,
finalMagnet,
finalThumbnail,
finalDescription,
finalTitle,
mimeType: providedMimeType,
fileSha256,
originalFileSha256,
videoRootId,
dTagValue,
createdAt,
contentObject,
wantPrivate,
normalizedPubkey,
videoPayload
};
}
export async function prepareVideoMirrorOptions(params) {
const {
videoData,
videoPayload,
finalUrl,
finalMagnet,
finalThumbnail,
finalDescription,
finalTitle,
mimeType: providedMimeType,
fileSha256: initialFileSha256,
originalFileSha256: initialOriginalFileSha256,
pubkey,
createdAt,
isPrivate
} = params;
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
const mimeTypeSource =
providedMimeType ||
inferredMimeType ||
"application/octet-stream";
const mimeType = mimeTypeSource.toLowerCase();
const fileHashCandidates = [
initialFileSha256,
videoData.fileSha256,
videoData.uploadedFileSha256,
videoPayload?.legacyFormData?.fileSha256,
videoPayload?.fileSha256,
];
let fileSha256 = "";
for (const candidate of fileHashCandidates) {
const normalized = normalizeHexHash(candidate);
if (normalized) {
fileSha256 = normalized;
break;
}
}
const originalHashCandidates = [
initialOriginalFileSha256,
videoData.originalFileSha256,
videoPayload?.legacyFormData?.originalFileSha256,
videoPayload?.originalFileSha256,
];
let originalFileSha256 = "";
for (const candidate of originalHashCandidates) {
const normalized = normalizeHexHash(candidate);
if (normalized) {
originalFileSha256 = normalized;
break;
}
}
const uploadedFile =
videoData?.uploadedFile ||
videoData?.file ||
videoPayload?.legacyFormData?.uploadedFile ||
videoPayload?.legacyFormData?.file ||
videoPayload?.uploadedFile ||
videoPayload?.file ||
null;
const originalFile =
videoData?.originalFile ||
videoPayload?.legacyFormData?.originalFile ||
videoPayload?.originalFile ||
uploadedFile;
if (!fileSha256 && uploadedFile) {
fileSha256 = await computeSha256HexFromValue(uploadedFile);
}
if (!originalFileSha256 && originalFile) {
originalFileSha256 = await computeSha256HexFromValue(originalFile);
}
if (!originalFileSha256 && fileSha256) {
originalFileSha256 = fileSha256;
}
const mirrorOptions = {
url: finalUrl,
magnet: finalMagnet,
thumbnail: finalThumbnail,
description: finalDescription,
title: finalTitle,
mimeType,
isPrivate: isPrivate,
actorPubkey: pubkey,
created_at: createdAt,
};
if (fileSha256) {
mirrorOptions.fileSha256 = fileSha256;
}
if (originalFileSha256) {
mirrorOptions.originalFileSha256 = originalFileSha256;
}
return mirrorOptions;
}
export function prepareVideoEditPayload(params) {
const {
baseEvent,
originalEventStub,
updatedData,
userPubkey,
resolveEventDTag // Injected dependency to resolve D tag
} = params;
const userPubkeyLower = userPubkey.toLowerCase();
const nip71Metadata =
updatedData && typeof updatedData === "object" ? updatedData.nip71 : null;
const oldMagnet =
typeof baseEvent.rawMagnet === "string" && baseEvent.rawMagnet.trim()
? baseEvent.rawMagnet.trim()
: typeof baseEvent.magnet === "string"
? baseEvent.magnet.trim()
: "";
const oldUrl = baseEvent.url || "";
// Determine if the updated note should be private
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
const wantNsfw = updatedData.isNsfw ?? baseEvent.isNsfw ?? false;
const wantForKids = updatedData.isForKids ?? baseEvent.isForKids ?? false;
const finalIsNsfw = wantNsfw === true;
const finalIsForKids = finalIsNsfw ? false : wantForKids === true;
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
const magnetEdited = updatedData.magnetEdited === true;
const newMagnetValue =
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
const finalMagnet = magnetEdited ? newMagnetValue : oldMagnet;
const urlEdited = updatedData.urlEdited === true;
const newUrlValue =
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
const finalUrl = urlEdited ? newUrlValue : oldUrl;
const wsEdited = updatedData.wsEdited === true;
const xsEdited = updatedData.xsEdited === true;
const newWsValue =
typeof updatedData.ws === "string" ? updatedData.ws.trim() : "";
const newXsValue =
typeof updatedData.xs === "string" ? updatedData.xs.trim() : "";
const baseWs =
typeof baseEvent.ws === "string" ? baseEvent.ws.trim() : "";
const baseXs =
typeof baseEvent.xs === "string" ? baseEvent.xs.trim() : "";
const finalWs = wsEdited ? newWsValue : baseWs;
const finalXs = xsEdited ? newXsValue : baseXs;
const finalEnableComments =
typeof updatedData.enableComments === "boolean"
? updatedData.enableComments
: baseEvent.enableComments === false
? false
: true;
const updatedInfoHash = infoHashFromMagnet(updatedData?.infoHash || "");
let infoHash =
updatedInfoHash ||
infoHashFromMagnet(finalMagnet) ||
infoHashFromMagnet(baseEvent.infoHash) ||
"";
const fileSha256 =
normalizeHexHash(updatedData?.fileSha256) ||
normalizeHexHash(baseEvent.fileSha256);
const originalFileSha256 =
normalizeHexHash(updatedData?.originalFileSha256) ||
normalizeHexHash(baseEvent.originalFileSha256);
// Use the existing videoRootId (or fall back to the base event's ID)
const oldRootId = baseEvent.videoRootId || baseEvent.id;
const storagePointer = resolveStoragePointerValue({
storagePointer:
updatedData?.storagePointer ||
baseEvent.storagePointer ||
getStoragePointerFromTags(baseEvent.tags),
url: finalUrl,
infoHash,
fallbackId: oldRootId,
provider: updatedData?.storageProvider || baseEvent.storageProvider,
});
const preservedDTag = resolveEventDTag(baseEvent, originalEventStub);
const fallbackDTag =
(typeof baseEvent.id === "string" && baseEvent.id.trim()) ||
(typeof originalEventStub?.id === "string" && originalEventStub.id.trim()) ||
"";
const finalDTagValue =
(typeof preservedDTag === "string" && preservedDTag.trim()) || fallbackDTag;
if (!finalDTagValue) {
throw new Error("Unable to determine a stable d tag for this edit.");
}
// Build the updated content object
const contentObject = {
videoRootId: oldRootId,
version: updatedData.version ?? baseEvent.version ?? 2,
deleted: false,
isPrivate: wantPrivate,
isNsfw: finalIsNsfw,
isForKids: finalIsForKids,
title: updatedData.title ?? baseEvent.title,
url: finalUrl,
magnet: finalMagnet,
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
mode: updatedData.mode ?? baseEvent.mode ?? "live",
enableComments: finalEnableComments,
};
if (infoHash) {
contentObject.infoHash = infoHash;
}
if (fileSha256) {
contentObject.fileSha256 = fileSha256;
}
if (originalFileSha256) {
contentObject.originalFileSha256 = originalFileSha256;
}
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
let metadataForTags =
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
if (!metadataForTags) {
if (baseEvent?.nip71 && typeof baseEvent.nip71 === "object") {
metadataForTags = baseEvent.nip71;
} else {
const extracted = extractNip71MetadataFromTags(baseEvent);
if (extracted?.metadata) {
metadataForTags = extracted.metadata;
}
}
}
const nip71Tags = buildNip71MetadataTags(metadataForTags);
const additionalTags = storagePointer
? [["s", storagePointer], ...nip71Tags]
: nip71Tags;
const createdAt = Math.floor(Date.now() / 1000);
const event = buildVideoPostEvent({
pubkey: userPubkeyLower,
created_at: createdAt,
dTagValue: finalDTagValue,
content: contentObject,
additionalTags,
});
return {
event,
contentObject,
nip71Metadata,
wantPrivate,
videoRootId: oldRootId,
dTagValue: finalDTagValue,
normalizedPubkey: userPubkeyLower,
userPubkeyLower,
createdAt,
finalUrl,
finalMagnet,
finalThumbnail: contentObject.thumbnail,
finalDescription: contentObject.description,
finalTitle: contentObject.title,
mimeType: updatedData.mimeType || "",
fileSha256,
originalFileSha256,
videoData: updatedData,
videoPayload: updatedData,
};
}
+236
View File
@@ -0,0 +1,236 @@
import { devLogger, userLogger } from "../utils/logger.js";
import { FEATURE_PUBLISH_NIP71 } from "../constants.js";
import { prepareVideoMirrorOptions, extractVideoPublishPayload } from "./videoPayloadBuilder.js";
import {
buildNip71VideoEvent,
buildVideoPointerValue,
stringFromInput
} from "./nip71.js";
/**
* Handles NIP-94 mirroring for a published video.
*
* @param {import("./client.js").NostrClient} client - The client instance.
* @param {import("nostr-tools").Event} signedEvent - The signed video event.
* @param {object} context - The publish context returned by prepareVideoPublishPayload.
*/
export async function handlePublishNip94(client, signedEvent, context) {
const { finalUrl } = context;
if (!finalUrl) {
devLogger.log("Skipping NIP-94 mirror: no hosted URL provided.");
return;
}
const {
videoData,
videoPayload,
finalMagnet,
finalThumbnail,
finalDescription,
finalTitle,
mimeType,
fileSha256,
originalFileSha256,
normalizedPubkey,
createdAt,
contentObject,
} = context;
const mirrorOptions = await prepareVideoMirrorOptions({
videoData,
videoPayload,
finalUrl,
finalMagnet,
finalThumbnail,
finalDescription,
finalTitle,
mimeType,
fileSha256,
originalFileSha256,
pubkey: normalizedPubkey,
createdAt,
isPrivate: contentObject.isPrivate,
});
try {
const mirrorResult = await client.mirrorVideoEvent(
signedEvent.id,
mirrorOptions,
);
if (mirrorResult?.ok) {
devLogger.log("Prepared NIP-94 mirror event:", mirrorResult.event);
devLogger.log("NIP-94 mirror dispatched for hosted URL:", finalUrl);
} else if (mirrorResult) {
devLogger.warn(
"[nostr] NIP-94 mirror rejected:",
mirrorResult.error || "mirror-failed",
mirrorResult.details || null,
);
}
} catch (mirrorError) {
devLogger.warn(
"[nostr] Failed to publish NIP-94 mirror:",
mirrorError,
);
}
}
/**
* Handles NIP-71 metadata publishing.
*
* @param {import("./client.js").NostrClient} client - The client instance.
* @param {import("nostr-tools").Event} signedEvent - The signed video event.
* @param {object} context - The publish context returned by prepareVideoPublishPayload.
*/
export async function handlePublishNip71(client, signedEvent, context) {
const {
videoPayload,
nip71Metadata,
contentObject,
wantPrivate,
normalizedPubkey,
videoRootId,
dTagValue,
} = context;
const userPubkeyLower = normalizedPubkey.toLowerCase();
const nip71EditedFlag =
videoPayload && typeof videoPayload === "object"
? videoPayload.nip71Edited
: null;
const hasMetadataObject =
nip71Metadata && typeof nip71Metadata === "object";
const metadataWasEdited =
nip71EditedFlag === true ||
(nip71EditedFlag == null && hasMetadataObject);
const shouldAttemptNip71 = !wantPrivate && metadataWasEdited;
if (shouldAttemptNip71) {
const metadataLegacyFormData = {
title: contentObject.title,
description: contentObject.description,
url: contentObject.url,
magnet: contentObject.magnet,
thumbnail: contentObject.thumbnail,
mode: contentObject.mode,
isPrivate: wantPrivate,
isNsfw: contentObject.isNsfw,
isForKids: contentObject.isForKids,
};
if (contentObject.ws) {
metadataLegacyFormData.ws = contentObject.ws;
}
if (contentObject.xs) {
metadataLegacyFormData.xs = contentObject.xs;
}
try {
await publishNip71Video(
client,
{
nip71: nip71Metadata,
legacyFormData: metadataLegacyFormData,
},
userPubkeyLower,
{
videoRootId,
dTag: dTagValue,
eventId: signedEvent.id,
},
);
} catch (nip71Error) {
userLogger.warn(
"[nostr] Failed to publish NIP-71 metadata for edit:",
nip71Error,
);
}
}
}
/**
* Publishes a NIP-71 video event.
*
* @param {import("./client.js").NostrClient} client - The client instance.
* @param {object} videoPayload - The video payload.
* @param {string} pubkey - The publisher's pubkey.
* @param {object} pointerOptions - The pointer options.
*/
export async function publishNip71Video(client, videoPayload, pubkey, pointerOptions = {}) {
if (!FEATURE_PUBLISH_NIP71) {
return null;
}
if (!pubkey) {
throw new Error("Not logged in to publish video.");
}
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
if (!nip71Metadata || typeof nip71Metadata !== "object") {
devLogger.log("[nostr] Skipping NIP-71 publish: metadata missing.");
return null;
}
const title = stringFromInput(videoData?.title);
const description = stringFromInput(videoData?.description);
const pointerIdentifiers =
pointerOptions && typeof pointerOptions === "object"
? pointerOptions
: {};
const event = buildNip71VideoEvent({
metadata: nip71Metadata,
pubkey,
title,
summaryFallback: description,
pointerIdentifiers: {
videoRootId: pointerIdentifiers.videoRootId,
dTag: pointerIdentifiers.dTag,
eventId: pointerIdentifiers.eventId,
},
createdAt: Math.floor(Date.now() / 1000),
});
if (!event) {
devLogger.warn("[nostr] Skipping NIP-71 publish: builder produced no event.");
return null;
}
devLogger.log("Prepared NIP-71 video event:", event);
const { signedEvent } = await client.signAndPublishEvent(event, {
context: "NIP-71 video",
logName: "NIP-71 video",
devLogLabel: "NIP-71 video",
rejectionLogLevel: "warn",
resolveActiveSigner: (p) => client.signerManager.resolveActiveSigner(p),
});
const pointerMap = new Map();
if (pointerIdentifiers.videoRootId) {
const pointerValue = buildVideoPointerValue(
pubkey,
pointerIdentifiers.videoRootId
);
if (pointerValue) {
pointerMap.set(pointerValue, {
videoRootId: pointerIdentifiers.videoRootId,
pointerValue,
videoEventIds: new Set(
pointerIdentifiers.eventId ? [pointerIdentifiers.eventId] : []
),
dTags: new Set(pointerIdentifiers.dTag ? [pointerIdentifiers.dTag] : []),
});
}
}
client.processNip71Events([signedEvent], pointerMap);
return signedEvent;
}
+6 -18
View File
@@ -14,7 +14,6 @@ import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
import { logViewCountFailure } from "./countDiagnostics.js";
import { queueSignEvent } from "./signRequestQueue.js";
import { sanitizeRelayList as sanitizeRelayUrls } from "./nip46Client.js";
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
@@ -341,15 +340,13 @@ function flattenListResults(input) {
}
function sanitizeRelayList(primary, fallback) {
const primaryList = sanitizeRelayUrls(Array.isArray(primary) ? primary : []);
if (primaryList.length) {
return primaryList;
if (Array.isArray(primary) && primary.length) {
return primary;
}
const fallbackList = sanitizeRelayUrls(Array.isArray(fallback) ? fallback : []);
if (fallbackList.length) {
return fallbackList;
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return sanitizeRelayUrls(RELAY_URLS);
return RELAY_URLS;
}
export async function listVideoViewEvents(client, pointer, options = {}) {
@@ -566,7 +563,6 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
perRelay: [],
best: null,
fallback: true,
partial: false,
};
}
@@ -592,26 +588,19 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
throw normalizeAbortError();
}
let countAttemptPartial = false;
try {
const result = await client.countEventsAcrossRelays([pointerFilter], {
relays: sanitizeRelayList(relayList, client.relays),
timeoutMs: options?.timeoutMs,
});
countAttemptPartial = Boolean(result?.partial);
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
return { ...result, fallback: false, partial: countAttemptPartial };
return { ...result, fallback: false };
}
} catch (error) {
if (error?.code !== "count-unsupported") {
logViewCountFailure(error);
}
if (error?.code === "count-timeout" || error?.code === "timeout") {
countAttemptPartial = true;
}
}
if (signal?.aborted) {
@@ -658,7 +647,6 @@ export async function countVideoViewEvents(client, pointer, options = {}) {
perRelay: [],
best: null,
fallback: true,
partial: countAttemptPartial,
};
}
+699
View File
@@ -0,0 +1,699 @@
import { devLogger, userLogger } from "./utils/logger.js";
const REMOTE_IMPORT_TIMEOUT = 4500;
const LOCAL_IMPORT_TIMEOUT = 2500;
const NODE_IMPORT_TIMEOUT = 1500;
const SCRIPT_FALLBACK_TIMEOUT = 6000;
const CDN_BUNDLE_URL =
"https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js";
const LOCAL_BUNDLE_RELATIVE_URL = "../vendor/nostr-tools.bundle.min.js";
const moduleUrl =
typeof import.meta !== "undefined" && import.meta?.url ? import.meta.url : null;
const resolveRelativeUrl = (relativePath) => {
if (!relativePath) {
return relativePath;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(relativePath)) {
return relativePath;
}
if (moduleUrl) {
try {
return new URL(relativePath, moduleUrl).href;
} catch (error) {
// Fall through to other resolution strategies
}
}
if (typeof window !== "undefined" && window?.location?.href) {
try {
return new URL(relativePath, window.location.href).href;
} catch (error) {
return relativePath;
}
}
return relativePath;
};
const LOCAL_BUNDLE_URL = resolveRelativeUrl(LOCAL_BUNDLE_RELATIVE_URL);
let bootstrapPromise = null;
const getGlobalScope = () => {
if (typeof window !== "undefined") {
return window;
}
if (typeof globalThis !== "undefined") {
return globalThis;
}
return null;
};
const hasWorkingNip04 = (candidate) =>
!!(
candidate &&
typeof candidate === "object" &&
typeof candidate.encrypt === "function" &&
typeof candidate.decrypt === "function"
);
const hasWorkingNip44 = (candidate) =>
!!(
candidate &&
typeof candidate === "object" &&
((
typeof candidate.encrypt === "function" &&
typeof candidate.decrypt === "function"
) ||
(candidate.v2 &&
typeof candidate.v2 === "object" &&
typeof candidate.v2.encrypt === "function" &&
typeof candidate.v2.decrypt === "function"))
);
const selectNip44Candidate = (moduleLike) => {
if (!moduleLike || typeof moduleLike !== "object") {
return null;
}
if (hasWorkingNip44(moduleLike.nip44)) {
return moduleLike.nip44;
}
if (hasWorkingNip44(moduleLike.default)) {
return moduleLike.default;
}
if (hasWorkingNip44(moduleLike)) {
return moduleLike;
}
return null;
};
const toSerializableError = (error) => {
if (!error) {
return null;
}
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
};
}
if (typeof error === "string") {
return { message: error };
}
try {
return { ...error };
} catch (serializationError) {
return { message: String(error) };
}
};
const withTimeout = (factory, ms, label) =>
new Promise((resolve) => {
const start = Date.now();
let settled = false;
let timeoutId;
const finalize = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
resolve({
label,
duration: Date.now() - start,
...result,
});
};
const runner = Promise.resolve().then(factory);
timeoutId = setTimeout(() => {
finalize({
ok: false,
timedOut: true,
error: new Error(
`[bitvid][nostr-tools] ${label} timed out after ${ms}ms`
),
});
runner.catch(() => {});
}, ms);
runner.then(
(value) => finalize({ ok: true, value, timedOut: false }),
(error) => finalize({ ok: false, error, timedOut: false })
);
});
const discoverLocalEntrypoints = (scope) => {
if (!scope || typeof scope !== "object") {
return [];
}
const entrypoints = [];
const configuredEntrypoints = scope.__BITVID_LOCAL_NOSTR_TOOLS_ENTRY__;
if (typeof configuredEntrypoints === "string") {
entrypoints.push(configuredEntrypoints);
} else if (Array.isArray(configuredEntrypoints)) {
configuredEntrypoints
.filter((candidate) => typeof candidate === "string" && candidate)
.forEach((candidate) => entrypoints.push(candidate));
}
if (scope.document) {
const localScripts = scope.document.querySelectorAll(
"script[data-nostr-tools-entry]"
);
localScripts.forEach((script) => {
const candidate = script.getAttribute("data-nostr-tools-entry");
if (candidate) {
entrypoints.push(candidate);
}
});
}
return Array.from(new Set(entrypoints));
};
const extractToolsFromModule = (moduleLike) => {
if (!moduleLike || typeof moduleLike !== "object") {
return null;
}
const { default: defaultExport, ...named } = moduleLike;
if (defaultExport && typeof defaultExport === "object") {
return { ...defaultExport, ...named };
}
return { ...named };
};
const ensureGlobalReadyPromise = (scope, promise) => {
if (!scope || typeof scope !== "object") {
return;
}
try {
Object.defineProperty(scope, "nostrToolsReady", {
configurable: true,
enumerable: false,
writable: true,
value: promise,
});
} catch (error) {
scope.nostrToolsReady = promise;
}
};
const loadScriptBundle = (scope, url, label) => {
if (!scope?.document) {
return Promise.resolve({
label,
ok: false,
timedOut: false,
duration: 0,
error: new Error("Document is unavailable for script loading."),
});
}
return withTimeout(
() =>
new Promise((resolve, reject) => {
const { document } = scope;
const target = document.head || document.body || document.documentElement;
if (!target) {
reject(new Error("Unable to locate a DOM target for script injection."));
return;
}
const script = document.createElement("script");
script.async = true;
script.src = url;
script.crossOrigin = "anonymous";
const cleanup = () => {
script.onload = null;
script.onerror = null;
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
script.onload = () => {
cleanup();
resolve(scope?.NostrTools || null);
};
script.onerror = (event) => {
cleanup();
if (event?.error instanceof Error) {
reject(event.error);
} else {
reject(new Error(`nostr-tools bundle failed to load: ${url}`));
}
};
target.appendChild(script);
}),
SCRIPT_FALLBACK_TIMEOUT,
label
);
};
const createMockToolkit = () => ({
SimplePool: class MockSimplePool {
sub() {
return { on: () => {}, off: () => {}, unsub: () => {} };
}
list() {
return Promise.resolve([]);
}
publish() {
return Promise.resolve();
}
get() {
return Promise.resolve(null);
}
ensureRelay() {
return Promise.resolve({ connect: () => Promise.resolve() });
}
},
nip19: {
decode: () => ({ type: "npub", data: "mock" }),
npubEncode: () => "npub1mock",
neventEncode: () => "nevent1mock",
naddrEncode: () => "naddr1mock",
nprofileEncode: () => "nprofile1mock",
},
nip04: {
encrypt: async () => "mock_ciphertext",
decrypt: async () => "mock_plaintext",
},
nip44: {
v2: {
encrypt: () => "mock_ciphertext",
decrypt: () => "mock_plaintext",
},
encrypt: () => "mock_ciphertext",
decrypt: () => "mock_plaintext",
},
getPublicKey: () => "f".repeat(64),
generateSecretKey: () => new Uint8Array(32),
generatePrivateKey: () => "f".repeat(64),
finalizeEvent: () => ({ id: "f".repeat(64), sig: "f".repeat(128) }),
verifyEvent: () => true,
});
export function bootstrapNostrTools() {
if (bootstrapPromise) {
return bootstrapPromise;
}
const scope = getGlobalScope();
bootstrapPromise = (async () => {
// Test Environment / Fallback Guard
// If we are clearly in a test runner that doesn't support remote fetching,
// we return a mock immediately to avoid timeouts.
const isTestEnv =
(typeof process !== "undefined" && process.env?.NODE_ENV === "test") ||
(scope && scope.__TEST_MODE__);
if (!scope) {
if (isTestEnv) {
return createMockToolkit();
}
const error = {
ok: false,
reason: "Global scope is unavailable.",
attempts: [],
};
return error;
}
const attempts = [];
const isNodeLike =
typeof process !== "undefined" &&
!!process?.versions?.node &&
(!scope || typeof scope.document === "undefined");
const nodeImports = isNodeLike
? [
withTimeout(
() => import("nostr-tools"),
NODE_IMPORT_TIMEOUT,
"node-main"
),
withTimeout(
() => import("nostr-tools/nip04"),
NODE_IMPORT_TIMEOUT,
"node-nip04"
),
withTimeout(
() => import("nostr-tools/nip44"),
NODE_IMPORT_TIMEOUT,
"node-nip44"
),
]
: [];
const remoteImports = [
withTimeout(
() => import("https://cdn.jsdelivr.net/npm/[email protected]/+esm"),
REMOTE_IMPORT_TIMEOUT,
"jsdelivr-main"
),
withTimeout(
() => import("https://esm.sh/[email protected]"),
REMOTE_IMPORT_TIMEOUT,
"esm-main"
),
withTimeout(
() =>
import(
"https://esm.sh/[email protected]?target=es2022&exports=nip04"
),
REMOTE_IMPORT_TIMEOUT,
"esm-nip04"
),
withTimeout(
() =>
import(
"https://esm.sh/[email protected]/nip44?target=es2022"
),
REMOTE_IMPORT_TIMEOUT,
"esm-nip44"
),
];
const localEntrypoints = discoverLocalEntrypoints(scope);
const localImports = localEntrypoints.map((entry, index) =>
withTimeout(
() => import(entry),
LOCAL_IMPORT_TIMEOUT,
`local-${index}`
)
);
const dynamicResults = await Promise.allSettled([
...nodeImports,
...remoteImports,
...localImports,
]);
const resolvedModules = [];
let resolvedNip04 = null;
let resolvedNip44 = null;
dynamicResults.forEach((settledResult) => {
if (settledResult.status !== "fulfilled") {
return;
}
const result = settledResult.value;
attempts.push({
target: result.label,
ok: result.ok,
timedOut: !!result.timedOut,
duration: result.duration,
error: result.ok ? null : toSerializableError(result.error),
});
if (!result.ok) {
return;
}
if (result.label === "esm-nip04" && hasWorkingNip04(result.value?.nip04)) {
resolvedNip04 = result.value.nip04;
return;
}
if (!resolvedNip44 && result.label === "esm-nip44") {
const nip44Candidate = selectNip44Candidate(result.value);
if (nip44Candidate) {
resolvedNip44 = nip44Candidate;
return;
}
}
const extracted = extractToolsFromModule(result.value);
if (extracted && Object.keys(extracted).length > 0) {
resolvedModules.push({ source: result.label, tools: extracted });
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
resolvedNip04 = extracted.nip04;
}
if (!resolvedNip44) {
const nip44Candidate = selectNip44Candidate(extracted);
if (nip44Candidate) {
resolvedNip44 = nip44Candidate;
}
}
}
});
dynamicResults.forEach((settledResult) => {
if (settledResult.status !== "fulfilled") {
const { reason } = settledResult;
attempts.push({
target: "unknown",
ok: false,
timedOut: false,
duration: 0,
error: toSerializableError(reason),
});
return;
}
const result = settledResult.value;
if (result && !attempts.find((entry) => entry.target === result.label)) {
attempts.push({
target: result.label,
ok: result.ok,
timedOut: !!result.timedOut,
duration: result.duration,
error: result.ok ? null : toSerializableError(result.error),
});
}
});
const needsAdditionalCrypto = !resolvedNip04 || !resolvedNip44;
const allDynamicTimedOut =
attempts.length > 0 &&
attempts.every(
(attempt) =>
attempt &&
attempt.target &&
attempt.target.startsWith("esm")
? attempt.timedOut
: true
);
const shouldAttemptScriptFallback =
!!scope.document &&
(resolvedModules.length === 0 || allDynamicTimedOut || needsAdditionalCrypto);
if (shouldAttemptScriptFallback) {
const scriptSources = [
LOCAL_BUNDLE_URL
? { url: LOCAL_BUNDLE_URL, label: "local-bundle" }
: null,
{ url: CDN_BUNDLE_URL, label: "cdn-bundle" },
].filter(Boolean);
for (const source of scriptSources) {
const scriptLoadResult = await loadScriptBundle(
scope,
source.url,
source.label
);
attempts.push({
target: scriptLoadResult.label,
ok: scriptLoadResult.ok,
timedOut: !!scriptLoadResult.timedOut,
duration: scriptLoadResult.duration,
error: scriptLoadResult.ok
? null
: toSerializableError(scriptLoadResult.error),
});
if (!scriptLoadResult.ok) {
continue;
}
const extracted = extractToolsFromModule(scriptLoadResult.value);
if (extracted && Object.keys(extracted).length > 0) {
resolvedModules.push({ source: source.label, tools: extracted });
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
resolvedNip04 = extracted.nip04;
}
if (!resolvedNip44) {
const nip44Candidate = selectNip44Candidate(extracted);
if (nip44Candidate) {
resolvedNip44 = nip44Candidate;
}
}
break;
}
}
}
const existingGlobalTools = scope?.NostrTools;
if (existingGlobalTools && typeof existingGlobalTools === "object") {
resolvedModules.push({ source: "global", tools: existingGlobalTools });
if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) {
resolvedNip04 = existingGlobalTools.nip04;
}
if (!resolvedNip44) {
const nip44Candidate = selectNip44Candidate(existingGlobalTools);
if (nip44Candidate) {
resolvedNip44 = nip44Candidate;
}
}
}
if (resolvedModules.length === 0 && !resolvedNip04 && !resolvedNip44) {
if (isTestEnv) {
return createMockToolkit();
}
const failure = {
ok: false,
reason: "Failed to resolve any nostr-tools helpers.",
attempts,
};
userLogger.warn("[bitvid] nostr-tools bootstrap failed", failure);
return failure;
}
const toolSources = resolvedModules.map((entry) => entry.tools);
const canonicalTools = Object.assign({}, ...toolSources);
const normalizedGeneratePrivateKey =
typeof canonicalTools.generatePrivateKey === "function"
? canonicalTools.generatePrivateKey
: typeof canonicalTools.generateSecretKey === "function"
? canonicalTools.generateSecretKey
: undefined;
const normalizedGenerateSecretKey =
typeof canonicalTools.generateSecretKey === "function"
? canonicalTools.generateSecretKey
: typeof canonicalTools.generatePrivateKey === "function"
? canonicalTools.generatePrivateKey
: undefined;
if (normalizedGeneratePrivateKey) {
canonicalTools.generatePrivateKey = normalizedGeneratePrivateKey;
}
if (normalizedGenerateSecretKey) {
canonicalTools.generateSecretKey = normalizedGenerateSecretKey;
}
if (resolvedNip04) {
canonicalTools.nip04 = resolvedNip04;
devLogger.info("[bitvid] Initialized nostr nip04 helpers.");
} else {
userLogger.warn(
"[bitvid] NIP-04 helpers unavailable after bootstrap attempts."
);
}
if (resolvedNip44) {
canonicalTools.nip44 = resolvedNip44;
devLogger.info("[bitvid] Initialized nostr nip44 helpers.");
} else {
userLogger.warn(
"[bitvid] NIP-44 helpers unavailable after bootstrap attempts."
);
}
const frozenCanonical = Object.freeze({ ...canonicalTools });
try {
Object.defineProperty(scope, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
configurable: true,
enumerable: false,
writable: false,
value: frozenCanonical,
});
} catch (error) {
scope.__BITVID_CANONICAL_NOSTR_TOOLS__ = frozenCanonical;
}
let activeTools = canonicalTools;
const mergeWithCanonical = (candidate) => {
if (!candidate || typeof candidate !== "object") {
return activeTools;
}
const merged = Object.assign({}, frozenCanonical, candidate);
if (!merged.nip04 && frozenCanonical.nip04) {
merged.nip04 = frozenCanonical.nip04;
}
if (!merged.nip44 && frozenCanonical.nip44) {
merged.nip44 = frozenCanonical.nip44;
}
if (
typeof merged.generatePrivateKey !== "function" &&
typeof merged.generateSecretKey === "function"
) {
merged.generatePrivateKey = merged.generateSecretKey;
}
if (
typeof merged.generateSecretKey !== "function" &&
typeof merged.generatePrivateKey === "function"
) {
merged.generateSecretKey = merged.generatePrivateKey;
}
return merged;
};
const applyActive = (candidate) => {
activeTools = mergeWithCanonical(candidate);
return activeTools;
};
const existingDescriptor =
scope && typeof scope === "object"
? Object.getOwnPropertyDescriptor(scope, "NostrTools")
: null;
const canInstallGuard =
!existingDescriptor || existingDescriptor.configurable === true;
if (canInstallGuard) {
try {
Object.defineProperty(scope, "NostrTools", {
configurable: true,
enumerable: true,
get() {
return activeTools;
},
set(value) {
applyActive(value);
},
});
} catch (error) {
userLogger.warn("[bitvid] Failed to install NostrTools guard.", error);
}
} else {
devLogger.info(
"[bitvid] Skipping NostrTools guard; existing property is non-configurable."
);
}
applyActive(existingGlobalTools);
scope.NostrTools = canonicalTools;
return frozenCanonical;
})()
.catch((error) => ({
ok: false,
reason: "nostr-tools bootstrap threw unexpectedly.",
error: toSerializableError(error),
}));
ensureGlobalReadyPromise(scope, bootstrapPromise);
return bootstrapPromise;
}
const nostrToolsReady = bootstrapNostrTools();
export { nostrToolsReady };
export default nostrToolsReady;
await nostrToolsReady;
+12 -2
View File
@@ -520,7 +520,13 @@ async function processShare({
}
export async function splitAndZap(
{ videoEvent, amountSats, comment = "", walletSettings } = {},
{
videoEvent,
amountSats,
comment = "",
walletSettings,
overrideFee = null,
} = {},
dependencies = {}
) {
if (!videoEvent || typeof videoEvent !== "object") {
@@ -538,7 +544,11 @@ export async function splitAndZap(
throw new Error("This creator has not configured a Lightning address yet.");
}
const platformFee = clampPercent(getOverridePlatformFee());
const platformFee = clampPercent(
typeof overrideFee !== "undefined" && overrideFee !== null
? resolvePlatformFeePercent(overrideFee)
: getOverridePlatformFee()
);
const platformShare = Math.floor((amount * platformFee) / 100);
const creatorShare = amount - platformShare;
-16
View File
@@ -39,10 +39,6 @@ function normalizeRelayUrl(input) {
return null;
}
const enforceTls =
typeof window !== "undefined" &&
window?.location?.protocol === "https:";
let candidate = input.trim();
if (!candidate) {
return null;
@@ -63,18 +59,6 @@ function normalizeRelayUrl(input) {
return null;
}
if (enforceTls && parsed.protocol === "ws:") {
try {
parsed = new URL(candidate.replace(/^ws:/i, "wss:"));
} catch (error) {
return null;
}
}
if (enforceTls && parsed.protocol !== "wss:") {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const port = parsed.port ? `:${parsed.port}` : "";
let pathname = parsed.pathname || "";
+84 -145
View File
@@ -26,7 +26,6 @@ import { profileCache } from "../state/profileCache.js";
import getDefaultAuthProvider, {
providers as defaultAuthProviders,
} from "./authProviders/index.js";
import { fetchProfileMetadata } from "./profileMetadataService.js";
class SimpleEventEmitter {
constructor(logger = null) {
@@ -623,12 +622,8 @@ export default class AuthService {
postLoginError: null,
};
const { detail: postLoginDetail, completionPromise } = this.applyPostLoginState({
deferBlocks: true,
});
detail.postLogin = postLoginDetail;
const postLoginPromise = Promise.resolve(completionPromise)
const postLoginPromise = Promise.resolve()
.then(() => this.applyPostLoginState())
.then((postLogin) => {
detail.postLogin = postLogin;
detail.postLoginError = null;
@@ -682,151 +677,75 @@ export default class AuthService {
return detail;
}
applyPostLoginState({ deferBlocks = false } = {}) {
async applyPostLoginState() {
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
const cachedEntry = activePubkey ? this.getProfileCacheEntry(activePubkey) : null;
const cachedProfile = cachedEntry?.profile || FALLBACK_PROFILE;
const detail = {
pubkey: activePubkey || null,
blocksLoaded: deferBlocks ? null : false,
blocksLoaded: false,
relaysLoaded: false,
profile: cachedProfile,
profile: null,
};
if (!activePubkey) {
return { detail, completionPromise: Promise.resolve(detail) };
return detail;
}
const runOperation = async (operation) => {
try {
const value = await operation.promise;
const resolved = operation.onFulfilled(value);
detail[operation.name] = resolved;
if (typeof operation.emit === "function") {
operation.emit(resolved, null);
}
return resolved;
} catch (error) {
const resolved = operation.onRejected(error);
detail[operation.name] = resolved;
if (typeof operation.emit === "function") {
operation.emit(resolved, error);
}
return resolved;
}
};
const schedule = (callback) => Promise.resolve().then(() => callback());
const completionPromise = (async () => {
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
const relayOp = {
name: "relaysLoaded",
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
onFulfilled: () => true,
onRejected: (error) => {
this.log("[AuthService] Failed to load relay list", error);
return false;
},
emit: (value, error) => {
this.emit("relaysLoaded", {
pubkey: activePubkey,
relaysLoaded: value,
error: error ?? null,
});
},
};
await runOperation(relayOp);
}
const operations = [];
const concurrentOps = [];
if (!deferBlocks) {
const blocksOperation = this.createBlocksLoadOperation(activePubkey, schedule);
if (blocksOperation) {
concurrentOps.push(blocksOperation);
}
}
concurrentOps.push({
name: "profile",
promise: schedule(() => this.loadOwnProfile(activePubkey)),
onFulfilled: (value) => {
const profile = value || FALLBACK_PROFILE;
this.setProfileCacheEntry(activePubkey, profile, {
persist: true,
reason: "post-login",
});
return profile;
},
if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") {
operations.push({
name: "blocksLoaded",
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey)),
onFulfilled: () => true,
onRejected: (error) => {
this.log("[AuthService] Failed to load own profile", error);
const fallbackProfile = cachedProfile || FALLBACK_PROFILE;
this.setProfileCacheEntry(activePubkey, fallbackProfile, {
persist: true,
reason: "post-login-fallback",
});
return fallbackProfile;
this.log("[AuthService] Failed to load block list", error);
return false;
},
});
await Promise.allSettled(concurrentOps.map(runOperation));
return detail;
})();
return { detail, completionPromise };
}
createBlocksLoadOperation(
activePubkey,
schedule = (callback) => Promise.resolve().then(() => callback()),
options = {},
) {
if (!this.userBlocks || typeof this.userBlocks.loadBlocks !== "function") {
return null;
}
return {
name: "blocksLoaded",
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey, options)),
onFulfilled: () => true,
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
operations.push({
name: "relaysLoaded",
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
onFulfilled: () => true,
onRejected: (error) => {
this.log("[AuthService] Failed to load relay list", error);
return false;
},
});
}
operations.push({
name: "profile",
promise: schedule(() => this.loadOwnProfile(activePubkey)),
onFulfilled: (value) => value,
onRejected: (error) => {
this.log("[AuthService] Failed to load block list", error);
return false;
this.log("[AuthService] Failed to load own profile", error);
return null;
},
emit: (value, error) => {
this.emit("blocksLoaded", {
pubkey: activePubkey,
blocksLoaded: value,
error: error ?? null,
});
},
};
}
});
async loadBlocksForPubkey(pubkey, options = {}) {
const normalized = this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return false;
}
const settled = await Promise.allSettled(
operations.map((operation) => operation.promise)
);
const operation = this.createBlocksLoadOperation(normalized, undefined, options);
if (!operation) {
return false;
}
settled.forEach((result, index) => {
const operation = operations[index];
if (!operation) {
return;
}
try {
const value = await operation.promise;
const resolved = operation.onFulfilled(value);
operation.emit?.(resolved, null);
return resolved;
} catch (error) {
const resolved = operation.onRejected(error);
operation.emit?.(resolved, error);
return resolved;
}
if (result.status === "fulfilled") {
detail[operation.name] = operation.onFulfilled(result.value);
} else {
detail[operation.name] = operation.onRejected(result.reason);
}
});
return detail;
}
async logout() {
@@ -844,14 +763,6 @@ export default class AuthService {
setCurrentUserNpub(null);
setActiveProfilePubkey(null, { persist: true });
try {
if (profileCache && typeof profileCache.reset === "function") {
profileCache.reset();
}
} catch (error) {
this.log("[AuthService] profileCache.reset threw", error);
}
try {
resetModerationSettings({ persist: true });
} catch (error) {
@@ -1212,16 +1123,44 @@ export default class AuthService {
}
try {
const result = await fetchProfileMetadata(normalized, {
nostr: this.nostrClient,
});
const results = await Promise.all(
this.nostrClient.relays.map((relayUrl) =>
this.nostrClient.pool.list([relayUrl], [
{ kinds: [0], authors: [normalized], limit: 1 },
])
)
);
if (result?.event && typeof this.nostrClient?.handleEvent === "function") {
this.nostrClient.handleEvent(result.event);
const events = results.flat();
let newest = null;
for (const event of events) {
if (!event || event.pubkey !== normalized || !event.content) {
continue;
}
if (!newest || event.created_at > newest.created_at) {
newest = event;
}
}
if (result?.profile) {
return result.profile;
if (newest) {
if (this.nostrClient && typeof this.nostrClient.handleEvent === "function") {
this.nostrClient.handleEvent(newest);
}
if (newest.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
return profile;
}
}
} catch (error) {
this.log("[AuthService] Failed to fetch profile", error);
+53
View File
@@ -0,0 +1,53 @@
export const FALLBACK_PROFILE = {
name: "Unknown",
picture: "assets/svg/default-profile.svg",
about: "",
website: "",
banner: "",
lud16: "",
lud06: "",
};
export const FAST_PROFILE_RELAY_LIMIT = 3;
export const FAST_PROFILE_TIMEOUT_MS = 2500;
export const BACKGROUND_PROFILE_TIMEOUT_MS = 6000;
export function normalizeProviderId(providerId) {
return typeof providerId === "string" && providerId.trim()
? providerId.trim()
: "nip07";
}
export function normalizeAuthType(authTypeCandidate, providerId, providerResult) {
const candidates = [];
if (typeof authTypeCandidate === "string") {
candidates.push(authTypeCandidate);
}
if (providerResult && typeof providerResult === "object") {
const resultAuthType = providerResult.authType;
if (typeof resultAuthType === "string") {
candidates.push(resultAuthType);
}
const resultProviderId = providerResult.providerId;
if (typeof resultProviderId === "string") {
candidates.push(resultProviderId);
}
}
if (typeof providerId === "string") {
candidates.push(providerId);
}
for (const candidate of candidates) {
const trimmed = candidate.trim();
if (trimmed) {
return trimmed;
}
}
return "nip07";
}
+4 -11
View File
@@ -4,8 +4,6 @@ import { COMMENT_EVENT_KIND } from "../nostr/commentEvents.js";
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
const COUNT_PARTIAL_TITLE =
"Some relays timed out. Comment counts may be partial.";
function toPositiveInteger(value, fallback) {
const numeric = Number(value);
@@ -101,11 +99,10 @@ export default class DiscussionCountService {
}
const total = Number(result?.total);
const isPartial = Boolean(result?.partial);
const normalized =
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
this.videoDiscussionCountCache.set(video.id, normalized);
this.updateDiscussionCountElement(container, normalized, { partial: isPartial });
this.updateDiscussionCountElement(container, normalized);
return result;
})
.catch((error) => {
@@ -207,7 +204,7 @@ export default class DiscussionCountService {
element.removeAttribute("title");
}
updateDiscussionCountElement(element, count, { partial = false } = {}) {
updateDiscussionCountElement(element, count) {
if (!element) {
return;
}
@@ -220,13 +217,9 @@ export default class DiscussionCountService {
const numeric = Number(count);
const safeValue =
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
element.dataset.countState = partial ? "partial" : "ready";
element.dataset.countState = "ready";
valueEl.textContent = safeValue.toLocaleString();
if (partial) {
element.title = COUNT_PARTIAL_TITLE;
} else {
element.removeAttribute("title");
}
element.removeAttribute("title");
}
markDiscussionCountError(element, { unsupported = false } = {}) {
+5 -48
View File
@@ -1,9 +1,4 @@
import {
DEFAULT_RELAY_URLS,
ensureNostrTools,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
} from "../nostr/toolkit.js";
import { ensureNostrTools, resolveSimplePoolConstructor, shimLegacySimplePoolMethods } from "../nostr/toolkit.js";
import { sanitizeRelayList } from "../nostr/nip46Client.js";
import { publishEventToRelay } from "../nostrPublish.js";
import logger from "../utils/logger.js";
@@ -15,7 +10,6 @@ const DEFAULT_BACKOFF = {
};
const MAX_SEEN_EVENT_IDS = 5000;
const DM_RELAY_LIST_TIMEOUT_MS = 5000;
export const DM_RELAY_WARNING_FALLBACK = "dm-relays-fallback";
function normalizeRelayList(relays) {
@@ -61,29 +55,6 @@ function extractRelayHintsFromEvent(event) {
);
}
function withTimeout(promise, timeoutMs, errorCode) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return promise;
}
let timeoutId = null;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error("DM relay discovery timed out.");
if (errorCode) {
error.code = errorCode;
}
reject(error);
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
export async function resolveDmRelaySelection({
pubkey,
relayHints = [],
@@ -103,14 +74,11 @@ export async function resolveDmRelaySelection({
const discoveryList = sanitizeRelayList(
Array.isArray(discoveryRelays) ? discoveryRelays : [],
);
const candidateFallbackList = sanitizeRelayList(
const fallbackList = sanitizeRelayList(
Array.isArray(fallbackRelays) && fallbackRelays.length
? fallbackRelays
: discoveryRelays,
);
const defaultFallbackList = sanitizeRelayList(Array.from(DEFAULT_RELAY_URLS));
const fallbackList =
candidateFallbackList.length > 0 ? candidateFallbackList : defaultFallbackList;
if (
normalizedPubkey &&
@@ -119,13 +87,9 @@ export async function resolveDmRelaySelection({
discoveryList.length
) {
try {
const events = await withTimeout(
pool.list(discoveryList, [
{ kinds: [10050], authors: [normalizedPubkey], limit: 1 },
]),
DM_RELAY_LIST_TIMEOUT_MS,
"dm-relay-discovery-timeout",
);
const events = await pool.list(discoveryList, [
{ kinds: [10050], authors: [normalizedPubkey], limit: 1 },
]);
const sorted = Array.isArray(events)
? events
.filter((entry) => entry && entry.pubkey === normalizedPubkey)
@@ -139,13 +103,6 @@ export async function resolveDmRelaySelection({
}
} catch (error) {
resolvedLogger.dev.warn("[dmNostrService] Failed to fetch DM relay hints.", error);
if (fallbackList.length) {
return {
relays: fallbackList,
source: "fallback",
warning: DM_RELAY_WARNING_FALLBACK,
};
}
}
}
-5
View File
@@ -46,11 +46,6 @@ export class DmNotificationManager {
return this.focusedConversationId;
}
reset() {
this.conversationCache.clear();
this.focusedConversationId = "";
}
setFocusedConversation(conversationId, isFocused = true) {
if (typeof conversationId !== "string") {
return;
+9 -25
View File
@@ -6,9 +6,6 @@ import { buildVideoAddressPointer } from "../utils/videoPointer.js";
const DEFAULT_IDF_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
const DEFAULT_HISTORY_REFRESH_INTERVAL_MS = 60 * 1000;
const DEFAULT_REFRESH_DEBOUNCE_MS = 200;
const YIELD_CHUNK_SIZE = 500;
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));
function normalizeAddressKey(value) {
if (typeof value !== "string") {
@@ -102,8 +99,7 @@ export async function buildWatchHistoryTagCounts({
return counts;
}
for (let i = 0; i < items.length; i++) {
const entry = items[i];
for (const entry of items) {
const pointer = normalizePointerInput(entry?.pointer || entry);
let video = entry?.video || entry?.metadata?.video || null;
@@ -127,16 +123,12 @@ export async function buildWatchHistoryTagCounts({
for (const tag of tags) {
counts.set(tag, (counts.get(tag) || 0) + 1);
}
if (i > 0 && i % YIELD_CHUNK_SIZE === 0) {
await yieldToMain();
}
}
return counts;
}
export async function buildTagIdf({ videos } = {}) {
export function buildTagIdf({ videos } = {}) {
const idf = new Map();
const list = Array.isArray(videos) ? videos : [];
if (!list.length) {
@@ -146,31 +138,23 @@ export async function buildTagIdf({ videos } = {}) {
const docFrequency = new Map();
const totalDocs = list.length;
for (let i = 0; i < list.length; i++) {
const video = list[i];
for (const video of list) {
const tags = collectVideoTags(video);
if (tags.size) {
for (const tag of tags) {
docFrequency.set(tag, (docFrequency.get(tag) || 0) + 1);
}
if (!tags.size) {
continue;
}
if (i > 0 && i % YIELD_CHUNK_SIZE === 0) {
await yieldToMain();
for (const tag of tags) {
docFrequency.set(tag, (docFrequency.get(tag) || 0) + 1);
}
}
let processedCount = 0;
for (const [tag, df] of docFrequency.entries()) {
const ratio = (totalDocs + 1) / (df + 1);
const value = 1 + Math.log(ratio);
if (Number.isFinite(value) && value > 0) {
idf.set(tag, value);
}
processedCount++;
if (processedCount % YIELD_CHUNK_SIZE === 0) {
await yieldToMain();
}
}
return idf;
@@ -326,7 +310,7 @@ export default class ExploreDataService {
}
}
async refreshTagIdf({ force = false, videos, reason } = {}) {
refreshTagIdf({ force = false, videos, reason } = {}) {
const now = Date.now();
if (!force && this.tagIdfUpdatedAt) {
const elapsed = now - this.tagIdfUpdatedAt;
@@ -342,7 +326,7 @@ export default class ExploreDataService {
: [];
try {
this.tagIdf = await buildTagIdf({ videos: sourceVideos });
this.tagIdf = buildTagIdf({ videos: sourceVideos });
this.tagIdfUpdatedAt = now;
} catch (error) {
if (this.logger?.warn) {
+22 -229
View File
@@ -15,17 +15,11 @@ import {
assertAnyRelayAccepted,
} from "../nostrPublish.js";
import { userLogger, devLogger } from "../utils/logger.js";
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 { relayManager } from "../relayManager.js";
import {
DEFAULT_NIP07_ENCRYPTION_METHODS,
runNip07WithRetry,
NIP07_PRIORITY,
} from "../nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./relaySubscriptionService.js";
import { runNip07WithRetry, NIP07_PRIORITY } from "../nostr/nip07Permissions.js";
const LOG_PREFIX = "[HashtagPreferences]";
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
@@ -99,6 +93,14 @@ function normalizeHexPubkey(pubkey) {
return null;
}
function sanitizeRelayList(candidate) {
return Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
}
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
@@ -236,24 +238,19 @@ class HashtagPreferencesService {
this.eventId = null;
this.eventCreatedAt = null;
this.loaded = false;
this.backgroundLoading = false;
this.preferencesVersion = DEFAULT_VERSION;
this.decryptRetryTimeoutId = null;
this.loadPromise = null;
this.loadingPubkey = null;
this.subscriptionKey = null;
this.lastLoadError = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
if (detail.pubkey) {
this.load(detail.pubkey, { allowPermissionPrompt: false });
this.load(detail.pubkey);
}
} else if (event === "runtimeCleared" && detail.pubkey === this.activePubkey) {
this.reset();
if (this.activePubkey) {
this.load(this.activePubkey, { allowPermissionPrompt: false });
this.load(this.activePubkey);
}
}
});
@@ -264,74 +261,19 @@ class HashtagPreferencesService {
}
reset() {
this.stopHashtagPreferencesSubscription();
this.interests = new Set();
this.disinterests = new Set();
this.eventId = null;
this.eventCreatedAt = null;
this.loaded = false;
this.backgroundLoading = false;
this.preferencesVersion = DEFAULT_VERSION;
this.lastLoadError = null;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
}
this.loadPromise = null;
this.loadingPubkey = null;
this.emitChange("reset");
}
stopHashtagPreferencesSubscription() {
if (this.subscriptionKey) {
relaySubscriptionService.stopSubscription(this.subscriptionKey, "reset");
this.subscriptionKey = null;
}
}
ensureHashtagPreferencesSubscription(pubkey, relays) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized || !nostrClient?.pool) {
return null;
}
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const canonicalKind = schema?.kind ?? 30015;
const legacyKind = 30005;
const kinds = canonicalKind === legacyKind
? [canonicalKind]
: [canonicalKind, legacyKind];
const filters = kinds.map((kind) => ({
kinds: [kind],
authors: [normalized],
"#d": [HASHTAG_IDENTIFIER],
}));
const key = `hashtags:${normalized}`;
this.subscriptionKey = key;
return relaySubscriptionService.ensureSubscription({
key,
pool: nostrClient.pool,
relays,
filters,
label: "hashtag-preferences",
onEvent: (event) => this.handleHashtagPreferencesEvent(event),
});
}
handleHashtagPreferencesEvent(event) {
const normalized = normalizeHexPubkey(event?.pubkey);
if (!normalized || normalized !== this.activePubkey) {
return;
}
this.load(normalized, { allowPermissionPrompt: false }).catch((error) => {
devLogger.warn(`${LOG_PREFIX} Failed to refresh after hashtag event`, error);
});
}
saveToCache() {
const interests = this.getInterests();
const disinterests = this.getDisinterests();
@@ -483,7 +425,7 @@ class HashtagPreferencesService {
}
}
scheduleDecryptRetry(pubkey, error, options = {}) {
scheduleDecryptRetry(pubkey, error) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return;
@@ -496,7 +438,7 @@ class HashtagPreferencesService {
if (this.activePubkey && this.activePubkey !== normalized) {
return;
}
this.load(normalized, options).catch((retryError) => {
this.load(normalized).catch((retryError) => {
userLogger.warn(`${LOG_PREFIX} Decryption retry failed`, retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
@@ -506,51 +448,14 @@ class HashtagPreferencesService {
);
}
async load(pubkey, options = {}) {
async load(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
if (this.loadPromise && this.loadingPubkey === normalized) {
devLogger.log(`${LOG_PREFIX} Reusing in-flight preferences load.`, {
pubkey: normalized,
});
return this.loadPromise;
}
if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) {
devLogger.log(`${LOG_PREFIX} Waiting for existing preferences load to settle.`, {
previous: this.loadingPubkey,
next: normalized,
});
await this.loadPromise.catch(() => {});
}
const loadPromise = this.loadInternal(normalized, options);
this.loadPromise = loadPromise;
this.loadingPubkey = normalized;
try {
return await loadPromise;
} finally {
if (this.loadPromise === loadPromise) {
this.loadPromise = null;
this.loadingPubkey = null;
}
}
}
async loadInternal(pubkey, options = {}) {
const normalized = normalizeHexPubkey(pubkey);
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
this.lastLoadError = null;
let wasLoadedForUser =
this.activePubkey &&
this.activePubkey === normalized &&
(this.interests.size > 0 || this.disinterests.size > 0 || this.loaded);
this.activePubkey = normalized;
if (allowPermissionPrompt) {
this.backgroundLoading = false;
}
if (!normalized) {
this.reset();
@@ -561,12 +466,7 @@ class HashtagPreferencesService {
// Try cache first
if (this.loadFromCache(normalized)) {
wasLoadedForUser = true;
if (!allowPermissionPrompt && !this.backgroundLoading) {
this.backgroundLoading = true;
this.emitChange("background-loading", { background: true });
}
}
const wasBackgroundLoading = this.backgroundLoading;
if (
!nostrClient ||
@@ -581,18 +481,10 @@ class HashtagPreferencesService {
userLogger.warn(
`${LOG_PREFIX} Keeping existing preferences despite client unavailability.`,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.reset();
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
@@ -603,31 +495,12 @@ class HashtagPreferencesService {
userLogger.warn(
`${LOG_PREFIX} Keeping existing preferences; no relays available for refresh.`,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.reset();
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") {
try {
await nostrClient.ensurePool();
} catch (error) {
devLogger.warn(
`${LOG_PREFIX} Failed to initialize relay pool for subscription`,
error,
);
}
}
this.ensureHashtagPreferencesSubscription(normalized, relays);
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
const canonicalKind = schema?.kind ?? 30015;
@@ -680,10 +553,6 @@ class HashtagPreferencesService {
`${LOG_PREFIX} Failed to load hashtag preferences (keeping local state if any).`,
fetchError,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
@@ -694,19 +563,11 @@ class HashtagPreferencesService {
userLogger.warn(
`${LOG_PREFIX} Keeping existing preferences despite empty relay response.`,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.reset();
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
@@ -739,18 +600,10 @@ class HashtagPreferencesService {
if (!latest) {
if (wasLoadedForUser) {
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.reset();
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
@@ -763,18 +616,12 @@ class HashtagPreferencesService {
devLogger.log(
`${LOG_PREFIX} Ignoring stale/concurrent preferences event (remote: ${latestCreatedAt}, local: ${currentCreatedAt}).`,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
let decryptResult;
try {
const decryptPromise = this.decryptEvent(latest, normalized, {
allowPermissionPrompt,
});
const decryptPromise = this.decryptEvent(latest, normalized);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
@@ -793,7 +640,6 @@ class HashtagPreferencesService {
}
if (!decryptResult.ok) {
this.lastLoadError = decryptResult.error || null;
userLogger.warn(
`${LOG_PREFIX} Failed to decrypt hashtag preferences`,
decryptResult.error,
@@ -803,15 +649,7 @@ class HashtagPreferencesService {
if (!wasLoadedForUser && !this.loaded) {
this.loaded = true;
}
this.scheduleDecryptRetry(normalized, decryptResult.error, {
allowPermissionPrompt,
});
return;
}
if (
!allowPermissionPrompt &&
decryptResult.error?.code === "hashtag-preferences-permission-required"
) {
this.scheduleDecryptRetry(normalized, decryptResult.error);
return;
}
@@ -821,19 +659,11 @@ class HashtagPreferencesService {
userLogger.warn(
`${LOG_PREFIX} Preserving cached preferences despite decryption failure.`,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
this.reset();
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitChange("background-loaded", { background: false });
}
return;
}
@@ -851,9 +681,6 @@ class HashtagPreferencesService {
? latest.created_at
: null;
this.loaded = true;
if (this.backgroundLoading) {
this.backgroundLoading = false;
}
this.saveToCache();
this.emitChange("sync", { scheme: decryptResult.scheme });
} catch (error) {
@@ -866,7 +693,7 @@ class HashtagPreferencesService {
}
}
async decryptEvent(event, userPubkey, options = {}) {
async decryptEvent(event, userPubkey) {
const ciphertext = typeof event?.content === "string" ? event.content : "";
if (!ciphertext) {
const error = new Error("Preference event missing ciphertext content.");
@@ -874,13 +701,8 @@ class HashtagPreferencesService {
return { ok: false, error };
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
let signer = getActiveSigner();
if (
!signer &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
allowPermissionPrompt
) {
if (!signer && typeof nostrClient?.ensureActiveSignerForPubkey === "function") {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
@@ -894,36 +716,13 @@ class HashtagPreferencesService {
(!signerHasNip44 && requiresNip44) ||
(!signerHasNip04 && requiresNip04)
) {
if (!allowPermissionPrompt) {
const error = new Error(
"Decrypt permissions are required to read hashtag preferences.",
);
error.code = "hashtag-preferences-permission-required";
return { ok: false, error };
}
try {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error(
"Extension encryption permissions are required to read hashtag preferences.",
);
error.code = "hashtag-preferences-permission-required";
return { ok: false, error };
}
await requestDefaultExtensionPermissions();
} catch (error) {
userLogger.warn(
`${LOG_PREFIX} Extension permissions request failed while loading preferences`,
error,
);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.code = "hashtag-preferences-permission-required";
return { ok: false, error: wrapped };
}
}
@@ -967,11 +766,7 @@ class HashtagPreferencesService {
);
}
const nostrApi = allowPermissionPrompt
? typeof window !== "undefined"
? window?.nostr
: null
: null;
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
@@ -1125,12 +920,10 @@ class HashtagPreferencesService {
}
if (signer.type === "extension") {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
const error = new Error(
"The active signer must allow encryption before publishing preferences.",
"The active signer must allow encryption and signing before publishing preferences.",
);
error.code = "hashtag-preferences-extension-denied";
error.cause = permissionResult?.error || null;
+512
View File
@@ -0,0 +1,512 @@
import logger from "../utils/logger.js";
/**
* Trust threshold for disabling autoplay on reported videos.
* If >= 1 trusted user reports content, autoplay is disabled.
*/
export const AUTOPLAY_TRUST_THRESHOLD = 1;
/**
* Trust threshold for blurring content.
* If >= 1 trusted user reports content, it is blurred.
*/
export const BLUR_TRUST_THRESHOLD = 1;
/**
* Time window for considering trusted mute list entries valid.
* Mutes older than 60 days are ignored to prevent stale lists from affecting scores.
*/
export const TRUSTED_MUTE_WINDOW_DAYS = 60;
export const TRUSTED_MUTE_WINDOW_SECONDS = TRUSTED_MUTE_WINDOW_DAYS * 24 * 60 * 60;
export function normalizeUserLogger(candidate) {
if (
candidate &&
typeof candidate === "object" &&
typeof candidate.info === "function"
) {
return candidate;
}
return logger.user;
}
export class SimpleEventEmitter {
constructor(logHandler = null) {
this.listeners = new Map();
this.logHandler = typeof logHandler === "function" ? logHandler : null;
}
on(eventName, handler) {
if (typeof eventName !== "string" || typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logHandler) {
try {
this.logHandler(`moderationService listener for "${eventName}" threw`, error);
} catch (logError) {
logger.user.warn("[moderationService] listener logger threw", logError);
}
}
}
}
}
}
export function normalizeLogger(candidate) {
if (typeof candidate === "function") {
return candidate;
}
if (candidate && typeof candidate.log === "function") {
return (...args) => candidate.log(...args);
}
if (
candidate &&
typeof candidate.dev === "object" &&
typeof candidate.dev.log === "function"
) {
return (...args) => candidate.dev.log(...args);
}
return () => {};
}
export function normalizeHex(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed && /^[0-9a-f]{40,64}$/i.test(trimmed) ? trimmed : "";
}
export function normalizeEventId(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed && /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed : "";
}
export function normalizeReportType(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim().toLowerCase();
return trimmed ? trimmed : "";
}
export function isRelayHint(value) {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim().toLowerCase();
return (
trimmed.startsWith("wss://") ||
trimmed.startsWith("ws://") ||
trimmed.startsWith("https://") ||
trimmed.startsWith("http://")
);
}
export function normalizeMuteCategory(value) {
return normalizeReportType(value);
}
export function extractMuteCategoryFromTag(tag) {
if (!Array.isArray(tag)) {
return "";
}
const direct = normalizeMuteCategory(tag[3]);
if (direct) {
return direct;
}
const fallback = typeof tag[2] === "string" && !isRelayHint(tag[2])
? normalizeMuteCategory(tag[2])
: "";
return fallback;
}
export function cloneSummary(summary) {
if (!summary || typeof summary !== "object") {
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
}
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
const clonedTypes = {};
for (const [key, value] of Object.entries(types)) {
if (!value || typeof value !== "object") {
continue;
}
clonedTypes[key] = {
trusted: Number.isFinite(value.trusted) ? value.trusted : 0,
total: Number.isFinite(value.total) ? value.total : 0,
latest: Number.isFinite(value.latest) ? value.latest : 0,
};
}
return {
eventId: typeof summary.eventId === "string" ? summary.eventId : "",
totalTrusted: Number.isFinite(summary.totalTrusted) ? summary.totalTrusted : 0,
types: clonedTypes,
updatedAt: Number.isFinite(summary.updatedAt) ? summary.updatedAt : 0,
};
}
export function getNostrTools() {
if (typeof window !== "undefined" && window?.NostrTools) {
return window.NostrTools;
}
if (typeof globalThis !== "undefined" && globalThis?.NostrTools) {
return globalThis.NostrTools;
}
return null;
}
export function bytesToHex(bytes) {
if (!bytes || typeof bytes.length !== "number") {
return "";
}
let hex = "";
for (let index = 0; index < bytes.length; index += 1) {
const value = bytes[index];
if (typeof value !== "number") {
return "";
}
const normalized = value & 0xff;
hex += normalized.toString(16).padStart(2, "0");
}
return hex;
}
/**
* @internal
* Internal implementation of bech32 encoding/decoding.
* This ensures the service can normalize `npub` inputs even if the global
* `NostrTools` object is unavailable or version-mismatched.
*/
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_CHARSET_MAP = (() => {
const map = new Map();
for (let index = 0; index < BECH32_CHARSET.length; index += 1) {
map.set(BECH32_CHARSET[index], index);
}
return map;
})();
/** @internal */
export function bech32Polymod(values) {
let chk = 1;
const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
for (const value of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let bit = 0; bit < generators.length; bit += 1) {
if ((top >>> bit) & 1) {
chk ^= generators[bit];
}
}
}
return chk;
}
export function bech32HrpExpand(hrp) {
const expanded = [];
for (let index = 0; index < hrp.length; index += 1) {
const code = hrp.charCodeAt(index);
expanded.push(code >>> 5);
}
expanded.push(0);
for (let index = 0; index < hrp.length; index += 1) {
const code = hrp.charCodeAt(index);
expanded.push(code & 31);
}
return expanded;
}
export function bech32VerifyChecksum(hrp, data) {
return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1;
}
export function bech32Decode(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const lower = trimmed.toLowerCase();
const upper = trimmed.toUpperCase();
if (trimmed !== lower && trimmed !== upper) {
return null;
}
const normalized = lower;
const separatorIndex = normalized.lastIndexOf("1");
if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) {
return null;
}
const hrp = normalized.slice(0, separatorIndex);
const data = [];
for (let index = separatorIndex + 1; index < normalized.length; index += 1) {
const char = normalized[index];
if (!BECH32_CHARSET_MAP.has(char)) {
return null;
}
data.push(BECH32_CHARSET_MAP.get(char));
}
if (!bech32VerifyChecksum(hrp, data)) {
return null;
}
return { hrp, words: data.slice(0, -6) };
}
export function convertBits(data, fromBits, toBits, pad = true) {
const result = [];
let acc = 0;
let bits = 0;
const maxv = (1 << toBits) - 1;
const maxAcc = (1 << (fromBits + toBits - 1)) - 1;
for (const value of data) {
if (value < 0 || value >> fromBits) {
return null;
}
acc = ((acc << fromBits) | value) & maxAcc;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.push((acc >>> bits) & maxv);
}
}
if (pad) {
if (bits) {
result.push((acc << (toBits - bits)) & maxv);
}
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
return null;
}
return result;
}
export function fallbackDecodeNpubToHex(value) {
const decoded = bech32Decode(value);
if (!decoded || decoded.hrp !== "npub") {
return "";
}
const bytes = convertBits(decoded.words, 5, 8, false);
if (!bytes || !bytes.length) {
return "";
}
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function decodeToHex(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
const decoded = decoder(trimmed);
if (!decoded || decoded.type !== "npub") {
return fallbackDecodeNpubToHex(trimmed);
}
const data = decoded.data;
if (typeof data === "string") {
const normalized = normalizeHex(data);
return normalized || fallbackDecodeNpubToHex(trimmed);
}
return bytesToHex(data);
}
return fallbackDecodeNpubToHex(trimmed);
} catch (error) {
return fallbackDecodeNpubToHex(trimmed);
}
}
export function encodeToNpub(hex) {
const normalized = normalizeHex(hex);
if (!normalized) {
return "";
}
try {
const tools = getNostrTools();
const encoder = tools?.nip19?.npubEncode;
if (typeof encoder !== "function") {
return "";
}
return encoder(normalized) || "";
} catch (error) {
return "";
}
}
export function normalizeToHex(candidate) {
const direct = normalizeHex(candidate);
if (direct) {
return direct;
}
return decodeToHex(candidate);
}
export function createEmptyAdminSnapshot() {
return {
whitelist: new Set(),
whitelistHex: new Set(),
blacklist: new Set(),
blacklistHex: new Set(),
};
}
export function resolveRelayList(client, { write = false } = {}) {
if (!client) {
return [];
}
const source = write ? client.writeRelays : client.relays;
const fallback = client.relays;
const raw = Array.isArray(source) && source.length ? source : fallback;
if (!Array.isArray(raw)) {
return [];
}
const urls = [];
const seen = new Set();
for (const entry of raw) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
urls.push(trimmed);
}
return urls;
}
export function findReportedEventId(event) {
if (!event || !Array.isArray(event.tags)) {
return "";
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] !== "e") {
continue;
}
const candidate = normalizeEventId(tag[1]);
if (candidate) {
return candidate;
}
}
return "";
}
export function extractReportType(event, targetEventId = "") {
if (!event || !Array.isArray(event.tags)) {
return "";
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const [marker, value] = tag;
if (marker === "report" || marker === "type") {
const normalized = normalizeReportType(value);
if (normalized) {
return normalized;
}
}
}
if (targetEventId) {
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "e") {
continue;
}
if (normalizeEventId(tag[1]) !== targetEventId) {
continue;
}
const typeCandidate = normalizeReportType(tag[2] || "");
if (typeCandidate) {
return typeCandidate;
}
}
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "t") {
continue;
}
const normalized = normalizeReportType(tag[1] || "");
if (normalized) {
return normalized;
}
}
return "";
}
export function ensureNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function resolveStorage() {
if (typeof localStorage !== "undefined") {
return localStorage;
}
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
if (typeof globalThis !== "undefined" && globalThis.localStorage) {
return globalThis.localStorage;
}
return null;
}
+52 -168
View File
@@ -176,30 +176,6 @@ function sanitizeSnapshotTimestamp(value) {
return floored;
}
function resolveDirectMessageCreatedAt(message) {
const createdAt = sanitizeSnapshotTimestamp(
message?.message?.created_at ??
message?.event?.created_at ??
message?.created_at ??
message?.timestamp,
);
if (createdAt) {
return createdAt;
}
return Math.floor(Date.now() / 1000);
}
function resolveDirectMessageSortValue(message) {
return sanitizeSnapshotTimestamp(
message?.created_at ??
message?.timestamp ??
message?.message?.created_at ??
message?.event?.created_at,
);
}
const SNAPSHOT_PREVIEW_LIMIT = 160;
function sanitizeSnapshotPreview(value) {
@@ -264,7 +240,11 @@ function resolvePreviewCacheKey(message) {
: typeof message?.remotePubkey === "string"
? message.remotePubkey.trim()
: "";
const timestamp = resolveDirectMessageSortValue(message);
const timestamp = Number.isFinite(message?.timestamp)
? Math.floor(message.timestamp)
: Number.isFinite(message?.message?.created_at)
? Math.floor(message.message.created_at)
: 0;
if (remote && timestamp) {
return `snapshot:${remote}:${timestamp}`;
@@ -421,7 +401,11 @@ function normalizeDirectMessageRecord(message, actorPubkey = "") {
receiverPubkey = senderPubkey === actor ? remote : actor;
}
const createdAt = resolveDirectMessageCreatedAt(message);
const createdAt = sanitizeSnapshotTimestamp(
message?.message?.created_at ??
message?.event?.created_at ??
message?.timestamp,
);
const kind =
Number.isFinite(message?.message?.kind)
@@ -475,7 +459,7 @@ function buildSnapshotFromMessages(messages, actorPubkey = "", resolvePreview =
continue;
}
const timestamp = resolveDirectMessageSortValue(message);
const timestamp = sanitizeSnapshotTimestamp(message.timestamp);
const preview =
typeof resolvePreview === "function"
? resolvePreview(message)
@@ -496,60 +480,6 @@ function buildSnapshotFromMessages(messages, actorPubkey = "", resolvePreview =
);
}
function normalizeDirectMessagePayload(message) {
if (!message || message.ok !== true) {
return null;
}
const eventId = typeof message?.event?.id === "string" ? message.event.id : "";
if (!eventId) {
return null;
}
const createdAt = resolveDirectMessageCreatedAt(message);
return {
...message,
created_at: createdAt,
timestamp: createdAt,
};
}
function mergeDirectMessageCache(existingMessages, incomingMessages) {
const mergedIndex = new Map();
const upsert = (message) => {
const normalized = normalizeDirectMessagePayload(message);
if (!normalized) {
return;
}
const eventId = normalized.event.id;
const existing = mergedIndex.get(eventId);
if (
!existing ||
normalized.created_at >= resolveDirectMessageSortValue(existing)
) {
mergedIndex.set(eventId, normalized);
}
};
for (const message of Array.isArray(existingMessages) ? existingMessages : []) {
upsert(message);
}
for (const message of Array.isArray(incomingMessages) ? incomingMessages : []) {
upsert(message);
}
const mergedMessages = Array.from(mergedIndex.values());
mergedMessages.sort(
(a, b) => resolveDirectMessageSortValue(b) - resolveDirectMessageSortValue(a),
);
return { messages: mergedMessages, index: mergedIndex };
}
function hydrateMessagesFromSnapshot(snapshot, actorPubkey = "") {
const normalizedActor = normalizeHexPubkey(actorPubkey);
const hydrated = [];
@@ -629,7 +559,6 @@ export class NostrService {
this.dmMessageIndex = new Map();
this.dmSubscription = null;
this.dmActorPubkey = null;
this.dmSubscriptionRelays = null;
this.dmHydratedFromSnapshot = false;
this.dmNotificationManager = new DmNotificationManager({
logger: userLogger,
@@ -770,13 +699,6 @@ export class NostrService {
if (this.dmPreviewCache) {
this.dmPreviewCache.clear();
}
if (this.dmNotificationManager?.reset) {
try {
this.dmNotificationManager.reset();
} catch (error) {
userLogger.warn("[nostrService] Failed to reset DM notifications cache", error);
}
}
const activeActor = normalizeHexPubkey(
typeof actorPubkey === "string" && actorPubkey
? actorPubkey
@@ -871,10 +793,17 @@ export class NostrService {
return;
}
const normalized = normalizeDirectMessagePayload(message);
if (!normalized) {
return;
}
const normalized = {
...message,
timestamp:
Number.isFinite(message?.timestamp)
? message.timestamp
: Number.isFinite(message?.message?.created_at)
? message.message.created_at
: Number.isFinite(message?.event?.created_at)
? message.event.created_at
: Date.now() / 1000,
};
this.dmMessageIndex.set(eventId, normalized);
@@ -899,9 +828,7 @@ export class NostrService {
this.dmMessages.push(normalized);
}
this.dmMessages.sort(
(a, b) => resolveDirectMessageSortValue(b) - resolveDirectMessageSortValue(a),
);
this.dmMessages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
if (actor) {
this.dmActorPubkey = actor;
}
@@ -964,14 +891,8 @@ export class NostrService {
return this.getDirectMessages();
}
if (force) {
this.dmMessages = [];
this.dmMessageIndex = new Map();
}
const merged = mergeDirectMessageCache(this.dmMessages, hydrated);
this.dmMessageIndex = merged.index;
this.dmMessages = merged.messages;
this.dmMessages = hydrated;
this.dmMessageIndex = new Map();
this.dmHydratedFromSnapshot = hydrated.length > 0;
const snapshot = this.getDirectMessages();
@@ -1006,8 +927,6 @@ export class NostrService {
: Number.isFinite(decryptLimitCandidate) && decryptLimitCandidate > 0
? Math.floor(decryptLimitCandidate)
: null;
const sinceCandidate = Number(options.since);
const untilCandidate = Number(options.until);
if (resolvedDecryptLimit && resolvedDecryptLimit > resolvedLimit) {
resolvedDecryptLimit = resolvedLimit;
@@ -1032,27 +951,6 @@ export class NostrService {
this.dmActorPubkey = normalizedActor;
let resolvedSince =
Number.isFinite(sinceCandidate) && sinceCandidate >= 0
? Math.floor(sinceCandidate)
: null;
const resolvedUntil =
Number.isFinite(untilCandidate) && untilCandidate >= 0
? Math.floor(untilCandidate)
: null;
if (resolvedSince === null && initialLoad) {
let latestTimestamp = 0;
for (const message of Array.isArray(this.dmMessages) ? this.dmMessages : []) {
const timestamp = resolveDirectMessageSortValue(message);
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
}
}
if (latestTimestamp > 0) {
resolvedSince = Math.max(latestTimestamp - 60, 0);
}
}
let messages = [];
try {
messages = await this.nostrClient.listDirectMessages(normalizedActor, {
@@ -1060,8 +958,6 @@ export class NostrService {
...options,
limit: resolvedLimit,
decryptLimit: resolvedDecryptLimit,
...(resolvedSince !== null ? { since: resolvedSince } : {}),
...(resolvedUntil !== null ? { until: resolvedUntil } : {}),
onMessage: (message) => {
this.applyDirectMessage(message, { reason: "load-incremental" });
},
@@ -1072,19 +968,37 @@ export class NostrService {
}
const collected = [];
const index = new Map();
for (const message of Array.isArray(messages) ? messages : []) {
const normalized = normalizeDirectMessagePayload(message);
if (!normalized) {
if (!message || message.ok !== true) {
continue;
}
const eventId = message?.event?.id;
if (typeof eventId !== "string" || !eventId) {
continue;
}
const normalized = {
...message,
timestamp:
Number.isFinite(message?.timestamp)
? message.timestamp
: Number.isFinite(message?.message?.created_at)
? message.message.created_at
: Number.isFinite(message?.event?.created_at)
? message.event.created_at
: Date.now() / 1000,
};
index.set(eventId, normalized);
collected.push(normalized);
}
const merged = mergeDirectMessageCache(this.dmMessages, collected);
this.dmMessageIndex = merged.index;
this.dmMessages = merged.messages;
collected.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0));
this.dmMessageIndex = index;
this.dmMessages = collected;
const snapshot = this.getDirectMessages();
@@ -1118,38 +1032,16 @@ export class NostrService {
}
async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) {
if (this.dmSubscription) {
return this.dmSubscription;
}
const activeActor = actorPubkey || this.resolveActiveDmActor();
const normalizedActor = normalizeHexPubkey(activeActor);
if (!normalizedActor) {
return null;
}
if (this.dmSubscription) {
if (this.dmActorPubkey === normalizedActor) {
devLogger.log(
"[nostrService] Reusing existing DM subscription for active actor.",
{
actorPubkey: normalizedActor,
relays: Array.isArray(this.dmSubscriptionRelays)
? this.dmSubscriptionRelays
: [],
},
);
return this.dmSubscription;
}
devLogger.log(
"[nostrService] Replacing existing DM subscription for new actor.",
{
previous: this.dmActorPubkey,
next: normalizedActor,
previousRelays: Array.isArray(this.dmSubscriptionRelays)
? this.dmSubscriptionRelays
: [],
},
);
this.stopDirectMessageSubscription();
}
let resolvedRelays = Array.isArray(relays) ? relays : null;
let relaySelection = null;
@@ -1250,13 +1142,6 @@ export class NostrService {
this.dmSubscription = subscription;
this.dmActorPubkey = normalizedActor;
this.dmSubscriptionRelays = Array.isArray(resolvedRelays)
? resolvedRelays.slice()
: [];
devLogger.log("[nostrService] DM subscription activated.", {
actorPubkey: normalizedActor,
relays: resolvedRelays,
});
this.emit("directMessages:subscribed", { subscription });
if (relaySelection?.warning === DM_RELAY_WARNING_FALLBACK) {
this.emit("directMessages:relayWarning", {
@@ -1288,7 +1173,6 @@ export class NostrService {
}
}
this.dmSubscription = null;
this.dmSubscriptionRelays = null;
}
async persistDirectMessageSnapshot(actorPubkey = null, messages = null) {
+11 -278
View File
@@ -62,160 +62,6 @@ class SimpleEventEmitter {
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
const AUTH_STATUS_CODES = new Set([401, 403]);
const SSL_ERROR_PATTERN = /(ssl|cert|certificate)/i;
const CORS_ERROR_PATTERN = /cors/i;
const PROBE_CACHE_TTL_MS = 45000;
const getHostedUrlFailureDetails = (probeResult = {}) => {
const outcome = probeResult?.outcome || "error";
const status = Number.isFinite(probeResult?.status)
? probeResult.status
: null;
const error = probeResult?.error;
const isAuthStatus = status !== null && AUTH_STATUS_CODES.has(status);
if (outcome === "timeout") {
return {
category: "external",
message:
"Hosted URL timed out. We’ll try WebTorrent if available.",
status,
};
}
if (outcome === "bad") {
if (isAuthStatus) {
return {
category: "auth",
message:
"Hosted URL requires authorization or a signed request. Please log in or re-sign.",
status,
};
}
if (status === 404) {
return {
category: "external",
message: "Hosted URL not found (404).",
status,
};
}
if (status === 403) {
return {
category: "external",
message: "Hosted URL blocked (403).",
status,
};
}
if (status && status >= 500) {
return {
category: "external",
message: `Hosted URL unavailable (HTTP ${status}).`,
status,
};
}
if (status) {
return {
category: "external",
message: `Hosted URL failed (HTTP ${status}).`,
status,
};
}
}
if (outcome === "opaque" || outcome === "unknown") {
return {
category: "external",
message:
"Hosted URL blocked by browser security (CORS/SSL). We’ll try WebTorrent if available.",
status,
};
}
if (outcome === "error") {
const message = error?.message || "";
if (SSL_ERROR_PATTERN.test(message)) {
return {
category: "external",
message:
"Hosted URL blocked due to SSL certificate issues.",
status,
};
}
if (CORS_ERROR_PATTERN.test(message)) {
return {
category: "external",
message:
"Hosted URL blocked by CORS. We’ll try WebTorrent if available.",
status,
};
}
return {
category: "external",
message:
"Hosted URL failed to load due to network or security restrictions.",
status,
};
}
return {
category: "external",
message: "Hosted URL failed to load.",
status,
};
};
const getHostedVideoErrorMessage = (videoElement) => {
if (!videoElement?.error) {
return "";
}
const error = videoElement.error;
const code = error.code;
const mediaErrorCodes =
typeof MediaError !== "undefined"
? MediaError
: {
MEDIA_ERR_ABORTED: 1,
MEDIA_ERR_NETWORK: 2,
MEDIA_ERR_DECODE: 3,
MEDIA_ERR_SRC_NOT_SUPPORTED: 4,
};
switch (code) {
case mediaErrorCodes.MEDIA_ERR_ABORTED:
return "Hosted playback was interrupted.";
case mediaErrorCodes.MEDIA_ERR_NETWORK:
return "Hosted playback failed due to network/CORS restrictions.";
case mediaErrorCodes.MEDIA_ERR_DECODE:
return "Hosted playback failed to decode the video.";
case mediaErrorCodes.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "Hosted playback failed: source not supported or blocked.";
default:
return "Hosted playback failed to load.";
}
};
const getTorrentErrorMessage = (error) => {
if (!error) {
return "WebTorrent could not start. Please try again.";
}
const message = error.message || "";
if (message.includes(DEFAULT_UNSUPPORTED_BTITH_MESSAGE)) {
return DEFAULT_UNSUPPORTED_BTITH_MESSAGE;
}
if (/tracker|announce|peer/i.test(message)) {
return "WebTorrent could not reach any peers or trackers.";
}
if (/permission|blocked|denied/i.test(message)) {
return "WebTorrent was blocked by the browser or network.";
}
return "WebTorrent could not start. Please try again.";
};
export class PlaybackService {
constructor({
@@ -242,9 +88,6 @@ export class PlaybackService {
this.analyticsCallbacks = analyticsCallbacks || {};
this.playbackStartTimeout = playbackStartTimeout;
this.currentSession = null;
this.probeCache = new Map();
this.probeInFlight = new Map();
this.probeCacheTtlMs = PROBE_CACHE_TTL_MS;
}
log(...args) {
@@ -274,76 +117,6 @@ export class PlaybackService {
}
}
getProbeCacheKey({ url, magnet }) {
const trimmedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
return `${trimmedUrl}::${trimmedMagnet}`;
}
pruneProbeCache(now = Date.now()) {
for (const [key, entry] of this.probeCache.entries()) {
if (!entry || !Number.isFinite(entry.expiresAt) || entry.expiresAt <= now) {
this.probeCache.delete(key);
}
}
}
getCachedProbeResult(cacheKey, now = Date.now()) {
this.pruneProbeCache(now);
const entry = this.probeCache.get(cacheKey);
if (!entry || entry.expiresAt <= now) {
return null;
}
return entry.result;
}
storeProbeResult(cacheKey, result, now = Date.now()) {
const ttl =
Number.isFinite(this.probeCacheTtlMs) && this.probeCacheTtlMs > 0
? this.probeCacheTtlMs
: PROBE_CACHE_TTL_MS;
this.probeCache.set(cacheKey, { result, expiresAt: now + ttl });
}
async probeHostedUrl({ url, magnet, probeUrl } = {}) {
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
if (!sanitizedUrl) {
return { outcome: "invalid" };
}
const cacheKey = this.getProbeCacheKey({
url: sanitizedUrl,
magnet,
});
const cachedResult = this.getCachedProbeResult(cacheKey);
if (cachedResult) {
return cachedResult;
}
const inFlight = this.probeInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const probePromise = (async () => {
try {
const result =
typeof probeUrl === "function"
? await probeUrl(sanitizedUrl)
: { outcome: "error" };
this.storeProbeResult(cacheKey, result);
return result;
} catch (error) {
const result = { outcome: "error", error };
this.storeProbeResult(cacheKey, result);
return result;
} finally {
this.probeInFlight.delete(cacheKey);
}
})();
this.probeInFlight.set(cacheKey, probePromise);
return probePromise;
}
prepareVideoElement(videoElement) {
if (!videoElement) {
return;
@@ -829,19 +602,10 @@ class PlaybackSession extends SimpleEventEmitter {
throw new Error("No torrent playback handler provided.");
}
let torrentInstance;
try {
torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
});
} catch (err) {
const errorMessage = getTorrentErrorMessage(err);
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
throw new Error(errorMessage, { cause: err });
}
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
});
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
this.emit("sourcechange", { source: "torrent" });
@@ -916,11 +680,8 @@ class PlaybackSession extends SimpleEventEmitter {
{ once: true }
);
const probeResult = await this.service.probeHostedUrl({
url: httpsUrl,
magnet: this.trimmedMagnet,
probeUrl,
});
const probeResult =
typeof probeUrl === "function" ? await probeUrl(httpsUrl) : null;
const probeOutcome = probeResult?.outcome || "error";
const probeStatus = probeResult?.status || "unknown";
const shouldAttemptHosted =
@@ -965,11 +726,7 @@ class PlaybackSession extends SimpleEventEmitter {
attachWatchdogs({ stallMs: 0 });
return;
}
const errorMessage = getHostedVideoErrorMessage(activeVideoEl);
if (errorMessage) {
this.emit("status", { message: errorMessage });
}
outcomeResolver({ status: "fallback", reason, errorMessage });
outcomeResolver({ status: "fallback", reason });
},
});
};
@@ -1035,25 +792,12 @@ class PlaybackSession extends SimpleEventEmitter {
// Fallback triggered
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
return {
status: "fallback",
reason: fallbackReason,
errorMessage: playbackOutcome?.errorMessage,
};
}
const failureDetails = getHostedUrlFailureDetails(probeResult);
if (failureDetails?.message) {
this.emit("status", { message: failureDetails.message });
return { status: "fallback", reason: fallbackReason };
}
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
return {
status: "fallback",
reason: "probe-failed",
errorMessage: failureDetails?.message,
};
return { status: "fallback", reason: "probe-failed" };
};
// --- Timeout Wrapper ---
@@ -1085,8 +829,6 @@ class PlaybackSession extends SimpleEventEmitter {
const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout;
let hostedErrorMessage = "";
if (tryUrlFirst) {
if (httpsUrl) {
// Wrap URL attempt in timeout
@@ -1100,10 +842,6 @@ class PlaybackSession extends SimpleEventEmitter {
return urlResult;
}
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
@@ -1167,18 +905,13 @@ class PlaybackSession extends SimpleEventEmitter {
if (urlResult && urlResult.source === "url") {
return urlResult;
}
if (typeof urlResult?.errorMessage === "string") {
hostedErrorMessage = urlResult.errorMessage;
}
}
}
const message =
hostedErrorMessage ||
(this.magnetProvided && !this.magnetForPlayback
this.magnetProvided && !this.magnetForPlayback
? unsupportedBtihMessage
: "No playable source found.");
: "No playable source found.";
this.emit("status", { message });
this.service.handleAnalyticsEvent("sourcechange", { source: null });
this.emit("sourcechange", { source: null });
+345
View File
@@ -0,0 +1,345 @@
import { emit } from "../embedDiagnostics.js";
/**
* PlaybackStrategyService orchestrates the decision-making process for video playback.
* It determines whether to reuse an existing session, manages the lifecycle of the
* playback session, and coordinates between the PlaybackService (mechanism) and
* the Application (state/UI).
*/
export class PlaybackStrategyService {
constructor({ playbackService, logger }) {
this.playbackService = playbackService;
this.logger = logger || console;
this.activePlaybackSession = null;
this.activePlaybackResultPromise = null;
this.playSource = null;
}
log(...args) {
if (this.logger && typeof this.logger.log === "function") {
const prefix = "[PlaybackStrategy]";
if (typeof args[0] === "string") {
this.logger.log(`${prefix} ${args[0]}`, ...args.slice(1));
} else {
this.logger.log(prefix, ...args);
}
}
}
/**
* Orchestrates the playback flow.
*
* @param {object} options - Playback options (url, magnet, trigger, forcedSource).
* @param {object} context - Application context providing UI and State access.
* @returns {Promise<object>} The result of the playback attempt.
*/
async play(options = {}, context = {}) {
const { url = "", magnet = "", trigger, forcedSource } = options || {};
emit("playback-decision", {
method: forcedSource || (magnet ? "webtorrent" : "url"), // heuristic
details: {
url: Boolean(url),
magnet: Boolean(magnet),
forcedSource,
},
});
const hasTrigger = Object.prototype.hasOwnProperty.call(options || {}, "trigger");
if (hasTrigger && typeof context.setLastModalTrigger === "function") {
context.setLastModalTrigger(trigger);
}
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const previousSource = this.playSource || null;
const requestSignature = JSON.stringify({
url: sanitizedUrl,
magnet: trimmedMagnet,
forcedSource,
});
const modalVideoIsConnected = (() => {
const modalVideo = context.getModalVideo ? context.getModalVideo() : null;
if (!modalVideo) {
return false;
}
if (typeof modalVideo.isConnected === "boolean") {
return modalVideo.isConnected;
}
const ownerDocument = modalVideo.ownerDocument ||
(typeof document !== "undefined" ? document : null);
if (ownerDocument?.contains) {
try {
return ownerDocument.contains(modalVideo);
} catch (error) {
this.log("Failed to determine modal video connection state", error);
}
}
return true;
})();
const shouldReuseActiveSession =
modalVideoIsConnected &&
this.activePlaybackSession &&
typeof this.activePlaybackSession.matchesRequestSignature === "function" &&
this.activePlaybackSession.matchesRequestSignature(requestSignature);
if (shouldReuseActiveSession) {
this.log("Duplicate playback request detected; reusing active session.");
if (this.activePlaybackResultPromise) {
return this.activePlaybackResultPromise;
}
if (typeof this.activePlaybackSession.getResult === "function") {
return this.activePlaybackSession.getResult();
}
return { source: null };
}
if (typeof context.waitForCleanup === "function") {
await context.waitForCleanup();
}
if (typeof context.cancelPendingViewLogging === "function") {
context.cancelPendingViewLogging();
}
if (
previousSource === "torrent" &&
sanitizedUrl &&
this.playbackService &&
this.playbackService.torrentClient &&
typeof this.playbackService.torrentClient.cleanup === "function"
) {
try {
this.log("Previous playback used WebTorrent; cleaning up before preparing hosted session.");
await this.playbackService.torrentClient.cleanup();
} catch (error) {
this.log("Pre-playback torrent cleanup threw:", error);
}
}
let modalVideoEl = context.getModalVideo ? context.getModalVideo() : null;
const modalVideoFromController =
context.getVideoModalElement ? context.getVideoModalElement() : null;
if (modalVideoFromController && modalVideoFromController !== modalVideoEl) {
modalVideoEl = modalVideoFromController;
if (context.setModalVideo) {
context.setModalVideo(modalVideoEl);
}
}
const modalVideoConnected = Boolean(modalVideoEl && modalVideoEl.isConnected);
if (!modalVideoEl || !modalVideoConnected) {
try {
if (typeof context.ensureVideoModalReady === "function") {
const { videoElement } = await context.ensureVideoModalReady({
ensureVideoElement: true,
});
modalVideoEl = videoElement;
if (context.setModalVideo) {
context.setModalVideo(modalVideoEl);
}
}
} catch (error) {
this.log("Failed to load video modal before playback:", error);
if (typeof context.showError === "function") {
context.showError("Could not prepare the video player. Please try again.");
}
return { source: null, error };
}
}
if (!modalVideoEl) {
const error = new Error("Video element is not ready for playback.");
this.log("Video element missing after modal load attempt.");
if (typeof context.showError === "function") {
context.showError("Video player is not ready yet. Please try again.");
}
return { source: null, error };
}
if (context.cleanupVideoModalPoster) {
try {
context.cleanupVideoModalPoster();
} catch (err) {
this.log("video modal poster cleanup threw:", err);
}
}
let refreshedModal = null;
if (typeof context.teardownVideoElement === "function") {
refreshedModal = context.teardownVideoElement(modalVideoEl, {
replaceNode: true,
});
}
if (refreshedModal) {
if (context.setVideoModalElement) {
context.setVideoModalElement(refreshedModal);
}
if (context.setModalVideo) {
context.setModalVideo(refreshedModal);
}
modalVideoEl = refreshedModal; // Ensure we use the new element
if (typeof context.applyModalLoadingPoster === "function") {
context.applyModalLoadingPoster();
}
} else {
if (typeof context.applyModalLoadingPoster === "function") {
context.applyModalLoadingPoster();
}
}
// Double check that modalVideoEl is current (it might have been replaced)
modalVideoEl = context.getModalVideo ? context.getModalVideo() : modalVideoEl;
const session = this.playbackService.createSession({
url: sanitizedUrl,
magnet: trimmedMagnet,
requestSignature,
videoElement: modalVideoEl,
waitForCleanup: context.waitForCleanup,
cancelPendingViewLogging: context.cancelPendingViewLogging,
clearActiveIntervals: context.clearActiveIntervals,
showModalWithPoster: context.showModalWithPoster,
teardownVideoElement: context.teardownVideoElement,
probeUrl: context.probeUrl,
playViaWebTorrent: context.playViaWebTorrent,
autoplay: context.autoplay,
unsupportedBtihMessage: context.unsupportedBtihMessage,
forcedSource,
});
this.activePlaybackSession = session;
if (typeof context.setActivePlaybackSession === "function") {
context.setActivePlaybackSession(session);
}
this.activePlaybackResultPromise = null;
if (typeof context.setActivePlaybackResultPromise === "function") {
context.setActivePlaybackResultPromise(null);
}
if (typeof context.resetTorrentStats === "function") {
context.resetTorrentStats();
}
this.playSource = null;
if (typeof context.setPlaySource === "function") {
context.setPlaySource(null);
}
const playbackConfig = session.getPlaybackConfig();
const magnetForPlayback = session.getMagnetForPlayback();
const fallbackMagnet = session.getFallbackMagnet();
// const magnetProvided = session.getMagnetProvided();
if (context.updateCurrentVideoMetadata) {
context.updateCurrentVideoMetadata({
magnet: magnetForPlayback,
normalizedMagnet: magnetForPlayback,
normalizedMagnetFallback: fallbackMagnet,
legacyInfoHash: playbackConfig?.infoHash || null,
torrentSupported: !!magnetForPlayback
});
}
// this.currentMagnetUri = magnetForPlayback || null; // Logic is in context if needed
const unsubscribers = [];
const subscribe = (eventName, handler) => {
const off = session.on(eventName, handler);
unsubscribers.push(off);
};
subscribe("status", ({ message } = {}) => {
if (typeof context.updateVideoModalStatus === "function") {
context.updateVideoModalStatus(typeof message === "string" ? message : "");
}
});
subscribe("video-prepared", ({ videoElement } = {}) => {
if (videoElement && videoElement !== (context.getModalVideo ? context.getModalVideo() : null)) {
if (context.setModalVideo) {
context.setModalVideo(videoElement);
}
}
});
subscribe("view-logging-request", ({ videoElement } = {}) => {
if (videoElement && typeof context.preparePlaybackLogging === "function") {
context.preparePlaybackLogging(videoElement);
}
});
subscribe("poster-remove", ({ reason } = {}) => {
if (typeof context.forceRemoveModalPoster === "function") {
context.forceRemoveModalPoster(reason || "playback");
}
});
subscribe("sourcechange", ({ source } = {}) => {
this.playSource = source || null;
if (typeof context.setPlaySource === "function") {
context.setPlaySource(this.playSource);
}
const usingTorrent = source === "torrent";
if (typeof context.setTorrentStatsVisibility === "function") {
context.setTorrentStatsVisibility(usingTorrent);
}
});
subscribe("error", ({ error, message } = {}) => {
const displayMessage =
typeof message === "string"
? message
: error && error.message
? `Playback error: ${error.message}`
: "Playback error";
if (typeof context.showError === "function") {
context.showError(displayMessage);
}
});
subscribe("finished", () => {
if (this.activePlaybackSession === session) {
this.activePlaybackSession = null;
if (typeof context.setActivePlaybackSession === "function") {
context.setActivePlaybackSession(null);
}
this.activePlaybackResultPromise = null;
if (typeof context.setActivePlaybackResultPromise === "function") {
context.setActivePlaybackResultPromise(null);
}
}
while (unsubscribers.length) {
const off = unsubscribers.pop();
if (typeof off === "function") {
try {
off();
} catch (err) {
this.log("Listener cleanup error:", err);
}
}
}
});
const startPromise = session.start();
this.activePlaybackResultPromise = startPromise;
if (typeof context.setActivePlaybackResultPromise === "function") {
context.setActivePlaybackResultPromise(startPromise);
}
const result = await startPromise;
if (!result || result.error) {
return result;
}
emit("playback-started", {
method: result.source,
details: { startedAt: Date.now() },
});
return result;
}
}
export default PlaybackStrategyService;
+2 -1
View File
@@ -1,9 +1,10 @@
import { nostrClient } from "../nostrClientFacade.js";
import { devLogger } from "../utils/logger.js";
import { relaySubscriptionService } from "./relaySubscriptionService.js";
import { HEX64_REGEX } from "../utils/hex.js";
const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const inFlight = new Map();
function normalizePubkey(pubkey) {
+2 -31
View File
@@ -1,8 +1,6 @@
import { devLogger, userLogger } from "../utils/logger.js";
const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in";
const PERSISTENT_FAILURE_THRESHOLD = 3;
const USER_LOG_COOLDOWN_MS = 5 * 60 * 1000;
function resolveLogger(logger) {
if (logger && logger.dev && logger.user) {
@@ -92,10 +90,8 @@ class RelayHealthService {
connected: false,
lastLatencyMs: null,
errorCount: 0,
consecutiveFailures: 0,
lastCheckedAt: null,
lastErrorAt: null,
lastUserLogAt: null,
});
}
@@ -133,47 +129,22 @@ class RelayHealthService {
const state = this.ensureRelayState(relayUrl);
state.connected = true;
state.lastCheckedAt = Date.now();
state.consecutiveFailures = 0;
if (Number.isFinite(latencyMs)) {
state.lastLatencyMs = Math.max(0, Math.round(latencyMs));
}
if (this.nostrClient && typeof this.nostrClient.clearRelayBackoff === "function") {
this.nostrClient.clearRelayBackoff(relayUrl);
}
}
recordRelayFailure(relayUrl, error) {
const state = this.ensureRelayState(relayUrl);
const now = Date.now();
state.connected = false;
state.errorCount += 1;
state.consecutiveFailures += 1;
state.lastCheckedAt = now;
state.lastErrorAt = now;
if (this.nostrClient && typeof this.nostrClient.markRelayUnreachable === "function") {
this.nostrClient.markRelayUnreachable(relayUrl, 60000, {
reason: "relay-health-failed",
});
}
state.lastCheckedAt = Date.now();
state.lastErrorAt = Date.now();
this.logger.dev.warn("[relayHealth] Relay check failed.", {
relayUrl,
error,
});
if (
state.consecutiveFailures >= PERSISTENT_FAILURE_THRESHOLD &&
(!state.lastUserLogAt || now - state.lastUserLogAt >= USER_LOG_COOLDOWN_MS)
) {
state.lastUserLogAt = now;
this.logger.user.warn("[relayHealth] Relay unreachable after repeated failures.", {
relayUrl,
errorCount: state.consecutiveFailures,
lastError: error,
});
}
}
async checkRelay(relayUrl) {
+37 -59
View File
@@ -26,7 +26,7 @@ function sanitizeProfileString(value) {
class ProfileCache {
constructor() {
this.activePubkey = null;
this.memoryCache = new Map(); // For runtime decrypted data: pubkey:section -> data
this.memoryCache = new Map(); // For runtime decrypted data: pubkey -> Map(section -> data)
this.listeners = new Set();
}
@@ -166,39 +166,21 @@ class ProfileCache {
return null;
}
const existingEntry = this.getProfileData(normalizedPubkey, "profile");
const existingProfile = existingEntry?.profile || {};
const existingName = sanitizeProfileString(existingProfile.name);
const existingPicture = sanitizeProfileMediaUrl(existingProfile.picture);
const existingAbout = sanitizeProfileString(existingProfile.about);
const existingWebsite = sanitizeProfileString(existingProfile.website);
const existingBanner = sanitizeProfileMediaUrl(existingProfile.banner);
const existingLud16 = sanitizeProfileString(existingProfile.lud16);
const existingLud06 = sanitizeProfileString(existingProfile.lud06);
const existingLightningAddress = sanitizeProfileString(
existingProfile.lightningAddress,
);
const normalized = {
name:
sanitizeProfileString(profile.name || profile.display_name) ||
existingName ||
"Unknown",
picture:
sanitizeProfileMediaUrl(profile.picture || profile.image) ||
existingPicture ||
"assets/svg/default-profile.svg",
};
const about =
sanitizeProfileString(profile.about || profile.aboutMe) || existingAbout;
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website =
sanitizeProfileString(profile.website || profile.url) || existingWebsite;
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
@@ -209,19 +191,18 @@ class ProfileCache {
profile.background ||
profile.cover ||
profile.cover_image ||
profile.coverImage,
profile.coverImage
);
const resolvedBanner = banner || existingBanner;
if (resolvedBanner) {
normalized.banner = resolvedBanner;
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16) || existingLud16;
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06) || existingLud06;
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
@@ -231,11 +212,8 @@ class ProfileCache {
lud16,
lud06,
].filter(Boolean);
const resolvedLightningAddress = lightningCandidates.length
? lightningCandidates[0]
: existingLightningAddress;
if (resolvedLightningAddress) {
normalized.lightningAddress = resolvedLightningAddress;
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
const entry = {
@@ -262,9 +240,11 @@ class ProfileCache {
getProfileData(pubkey, section) {
// 1. Check memory cache (runtime data)
const memKey = `${pubkey}:${section}`;
if (this.memoryCache.has(memKey)) {
return this.memoryCache.get(memKey);
if (this.memoryCache.has(pubkey)) {
const userCache = this.memoryCache.get(pubkey);
if (userCache.has(section)) {
return userCache.get(section);
}
}
// 2. Load from persistence
@@ -295,7 +275,10 @@ class ProfileCache {
}
// Populate memory cache to avoid repeat parsing
this.memoryCache.set(memKey, parsed);
if (!this.memoryCache.has(pubkey)) {
this.memoryCache.set(pubkey, new Map());
}
this.memoryCache.get(pubkey).set(section, parsed);
return parsed;
}
} catch (error) {
@@ -324,39 +307,42 @@ class ProfileCache {
}
// Update memory cache
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
if (!this.memoryCache.has(pubkey)) {
this.memoryCache.set(pubkey, new Map());
}
this.memoryCache.get(pubkey).set(section, data);
this.emit("update", { pubkey, section, data });
this.emit("partition-updated", { pubkey, key: section });
}
setMemoryDataForPubkey(pubkey, section, data) {
const memKey = `${pubkey}:${section}`;
this.memoryCache.set(memKey, data);
if (!this.memoryCache.has(pubkey)) {
this.memoryCache.set(pubkey, new Map());
}
this.memoryCache.get(pubkey).set(section, data);
this.emit("update", { pubkey, section, data });
}
setMemoryData(section, data) {
if (!this.activePubkey) return;
const key = `${this.activePubkey}:${section}`;
this.memoryCache.set(key, data);
if (!this.memoryCache.has(this.activePubkey)) {
this.memoryCache.set(this.activePubkey, new Map());
}
this.memoryCache.get(this.activePubkey).set(section, data);
}
getMemoryData(section) {
if (!this.activePubkey) return null;
const key = `${this.activePubkey}:${section}`;
return this.memoryCache.get(key);
if (this.memoryCache.has(this.activePubkey)) {
return this.memoryCache.get(this.activePubkey).get(section);
}
return undefined;
}
clearMemoryCache(pubkey) {
// Clear all entries starting with pubkey:
const prefix = `${pubkey}:`;
for (const key of this.memoryCache.keys()) {
if (key.startsWith(prefix)) {
this.memoryCache.delete(key);
}
}
this.memoryCache.delete(pubkey);
}
clearSignerRuntime(pubkey) {
@@ -368,14 +354,6 @@ class ProfileCache {
this.emit("runtimeCleared", { pubkey: normalized });
}
reset() {
const previousPubkey = this.activePubkey;
this.activePubkey = null;
this.memoryCache.clear();
this.emit("profileChanged", { pubkey: null, previousPubkey: previousPubkey || null });
this.emit("reset", { previousPubkey: previousPubkey || null });
}
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
+32 -177
View File
@@ -5,7 +5,7 @@ import {
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js";
import { normalizeNostrPubkey, sanitizeRelayList } from "./nostr/nip46Client.js";
import { normalizeNostrPubkey } from "./nostr/nip46Client.js";
import {
listVideoViewEvents,
subscribeVideoViewEvents,
@@ -31,16 +31,11 @@ import { devLogger, userLogger } from "./utils/logger.js";
import moderationService from "./services/moderationService.js";
import nostrService from "./services/nostrService.js";
import { profileCache } from "./state/profileCache.js";
import {
DEFAULT_NIP07_ENCRYPTION_METHODS,
runNip07WithRetry,
NIP07_PRIORITY,
} from "./nostr/nip07Permissions.js";
import { relaySubscriptionService } from "./services/relaySubscriptionService.js";
import { runNip07WithRetry, NIP07_PRIORITY } from "./nostr/nip07Permissions.js";
const SUBSCRIPTION_SET_KIND =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
const DECRYPT_TIMEOUT_MS = 15000;
const DECRYPT_TIMEOUT_MS = 90000;
const DECRYPT_RETRY_DELAY_MS = 10000;
function normalizeHexPubkey(value) {
@@ -334,12 +329,10 @@ class SubscriptionsManager {
this.currentUserPubkey = null;
this.loaded = false;
this.loadingPromise = null;
this.backgroundLoading = false;
this.subscriptionListView = null;
this.lastRunOptions = null;
this.lastResult = null;
this.lastContainerId = null;
this.lastLoadError = null;
this.unsubscribeFromNostrUpdates = null;
this.pendingRefreshPromise = null;
this.scheduledRefreshDetail = null;
@@ -348,7 +341,6 @@ class SubscriptionsManager {
this.emitter = new TinyEventEmitter();
this.decryptRetryTimeoutId = null;
this.ensureNostrServiceListener();
this.subscriptionKey = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
@@ -358,9 +350,7 @@ class SubscriptionsManager {
} else if (event === "runtimeCleared" && detail.pubkey === this.currentUserPubkey) {
this.reset();
if (this.currentUserPubkey) {
this.loadSubscriptions(this.currentUserPubkey, {
allowPermissionPrompt: false,
});
this.loadSubscriptions(this.currentUserPubkey);
}
}
});
@@ -369,13 +359,12 @@ class SubscriptionsManager {
/**
* Decrypt the subscription list from kind=30000 (d="subscriptions").
*/
async loadSubscriptions(userPubkey, options = {}) {
async loadSubscriptions(userPubkey) {
if (!userPubkey) {
userLogger.warn("[SubscriptionsManager] No pubkey => cannot load subs.");
return;
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
// 1. Attempt to load from cache first
@@ -390,25 +379,14 @@ class SubscriptionsManager {
this.loaded = true;
// Trigger background update
if (!allowPermissionPrompt && !this.backgroundLoading) {
this.backgroundLoading = true;
this.emitter.emit("change", {
action: "background-loading",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
background: true,
});
} else if (allowPermissionPrompt) {
this.backgroundLoading = false;
}
this.updateFromRelays(userPubkey, { allowPermissionPrompt }).catch((err) => {
this.updateFromRelays(userPubkey).catch((err) => {
devLogger.warn("[SubscriptionsManager] Background update failed:", err);
});
return;
}
// 2. If no cache, must wait for relays
await this.updateFromRelays(userPubkey, { allowPermissionPrompt });
await this.updateFromRelays(userPubkey);
}
saveToCache(userPubkey) {
@@ -424,7 +402,7 @@ class SubscriptionsManager {
return this.emitter.on(eventName, handler);
}
scheduleDecryptRetry(userPubkey, error, options = {}) {
scheduleDecryptRetry(userPubkey, error) {
const normalized = normalizeHexPubkey(userPubkey) || userPubkey;
if (!normalized) {
return;
@@ -437,7 +415,7 @@ class SubscriptionsManager {
if (this.currentUserPubkey && this.currentUserPubkey !== normalized) {
return;
}
this.updateFromRelays(normalized, options).catch((retryError) => {
this.updateFromRelays(normalized).catch((retryError) => {
userLogger.warn("[SubscriptionsManager] Decryption retry failed:", retryError);
});
}, DECRYPT_RETRY_DELAY_MS);
@@ -447,14 +425,11 @@ class SubscriptionsManager {
);
}
async updateFromRelays(userPubkey, options = {}) {
async updateFromRelays(userPubkey) {
if (!userPubkey) return;
try {
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const wasBackgroundLoading = this.backgroundLoading;
const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey;
this.lastLoadError = null;
const readRelays = relayManager.getReadRelayUrls();
const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS);
@@ -462,26 +437,8 @@ class SubscriptionsManager {
devLogger.warn(
"[SubscriptionsManager] No relay URLs available while loading subscriptions.",
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
if (!nostrClient?.pool && typeof nostrClient?.ensurePool === "function") {
try {
await nostrClient.ensurePool();
} catch (error) {
devLogger.warn(
"[SubscriptionsManager] Failed to initialize relay pool for subscription",
error,
);
}
}
this.ensureSubscriptionListSubscription(normalizedUserPubkey, relayUrls);
// Use incremental fetch helper
const cachedSnapshot = parseCachedSubscriptionSnapshot(
@@ -493,7 +450,7 @@ class SubscriptionsManager {
pubkey: normalizedUserPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0),
since: shouldForceFullFetch ? 0 : undefined,
timeoutMs: 12000,
});
@@ -516,7 +473,7 @@ class SubscriptionsManager {
pubkey: normalizedSessionActorPubkey,
dTag: SUBSCRIPTION_LIST_IDENTIFIER,
relayUrls,
since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0),
since: shouldForceSessionFetch ? 0 : undefined,
timeoutMs: 12000,
});
if (sessionEvents.length) {
@@ -559,13 +516,6 @@ class SubscriptionsManager {
// This means no updates. We keep what we have.
devLogger.log("[SubscriptionsManager] No updates from relays.");
}
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
return;
}
@@ -584,9 +534,7 @@ class SubscriptionsManager {
let decryptResult;
try {
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey, {
allowPermissionPrompt,
});
const decryptPromise = this.decryptSubscriptionEvent(newest, userPubkey);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() => {
@@ -605,33 +553,17 @@ class SubscriptionsManager {
}
if (!decryptResult.ok) {
this.lastLoadError = decryptResult.error || null;
if (decryptResult.error?.code === "subscriptions-decrypt-timeout") {
if (!this.loaded && !cachedSnapshot.hasSnapshot) {
this.loaded = true;
}
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, {
allowPermissionPrompt,
});
return;
}
if (
!allowPermissionPrompt &&
decryptResult.error?.code === "subscriptions-permission-required"
) {
this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error);
return;
}
userLogger.error(
"[SubscriptionsManager] Failed to decrypt subscription list:",
decryptResult.error,
);
if (wasBackgroundLoading) {
this.backgroundLoading = false;
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
if (!this.loaded) {
this.subscribedPubkeys.clear();
this.subsEventId = null;
@@ -651,9 +583,6 @@ class SubscriptionsManager {
this.subscribedPubkeys = newSet;
this.currentUserPubkey = normalizedUserPubkey;
this.loaded = true;
if (wasBackgroundLoading) {
this.backgroundLoading = false;
}
// Update persistent cache
this.saveToCache(normalizedUserPubkey);
@@ -681,31 +610,22 @@ class SubscriptionsManager {
error
);
});
} else if (wasBackgroundLoading) {
this.emitter.emit("change", {
action: "background-loaded",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
}
} catch (err) {
this.lastLoadError = err || null;
userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err);
}
}
reset() {
this.stopSubscriptionListSubscription();
this.subscribedPubkeys.clear();
this.subsEventId = null;
this.subsEventCreatedAt = null;
this.currentUserPubkey = null;
this.loaded = false;
this.backgroundLoading = false;
this.lastRunOptions = null;
this.lastResult = null;
this.hasRenderedOnce = false;
this.lastLoadError = null;
if (this.decryptRetryTimeoutId) {
clearTimeout(this.decryptRetryTimeoutId);
this.decryptRetryTimeoutId = null;
@@ -713,56 +633,7 @@ class SubscriptionsManager {
this.emitter.emit("change", { action: "reset", subscribedPubkeys: [] });
}
stopSubscriptionListSubscription() {
if (this.subscriptionKey) {
relaySubscriptionService.stopSubscription(this.subscriptionKey, "reset");
this.subscriptionKey = null;
}
}
ensureSubscriptionListSubscription(userPubkey, relays) {
const normalized = normalizeHexPubkey(userPubkey);
if (!normalized || !nostrClient?.pool) {
return null;
}
const key = `subscriptions:${normalized}`;
this.subscriptionKey = key;
const filters = [
{
kinds: [SUBSCRIPTION_SET_KIND],
authors: [normalized],
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
},
];
return relaySubscriptionService.ensureSubscription({
key,
pool: nostrClient.pool,
relays,
filters,
label: "subscription-list",
onEvent: (event) => this.handleSubscriptionListEvent(event),
});
}
handleSubscriptionListEvent(event) {
const normalized = normalizeHexPubkey(event?.pubkey);
if (!normalized || normalized !== this.currentUserPubkey) {
return;
}
this.updateFromRelays(normalized, { allowPermissionPrompt: false })
.catch((error) => {
devLogger.warn(
"[SubscriptionsManager] Failed to refresh after subscription list event",
error,
);
});
}
async ensureLoaded(actorHex, options = {}) {
async ensureLoaded(actorHex) {
devLogger.log("[SubscriptionsManager] ensureLoaded start", actorHex);
const normalizedActor = normalizeHexPubkey(actorHex) || actorHex;
if (!normalizedActor) {
@@ -784,7 +655,7 @@ class SubscriptionsManager {
return;
}
const loader = this.loadSubscriptions(normalizedActor, options);
const loader = this.loadSubscriptions(normalizedActor);
this.loadingPromise = loader;
try {
@@ -838,7 +709,7 @@ class SubscriptionsManager {
return Array.from(this.subscribedPubkeys);
}
async decryptSubscriptionEvent(event, userPubkey, options = {}) {
async decryptSubscriptionEvent(event, userPubkey) {
const ciphertext = typeof event?.content === "string" ? event.content : "";
if (!ciphertext) {
const error = new Error("Subscription event is missing ciphertext content.");
@@ -846,7 +717,6 @@ class SubscriptionsManager {
return { ok: false, error };
}
const allowPermissionPrompt = options?.allowPermissionPrompt !== false;
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44;
@@ -861,8 +731,7 @@ class SubscriptionsManager {
if (
!signer ||
(!signerHasRequiredDecryptors(signer) &&
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
allowPermissionPrompt)
typeof nostrClient?.ensureActiveSignerForPubkey === "function")
) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
@@ -870,11 +739,7 @@ class SubscriptionsManager {
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
const nostrApi = allowPermissionPrompt
? typeof window !== "undefined"
? window?.nostr
: null
: null;
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
const windowHasNip04 = typeof nostrApi?.nip04?.decrypt === "function";
const windowHasNip44 =
(nostrApi?.nip44 && typeof nostrApi.nip44.decrypt === "function") ||
@@ -884,17 +749,8 @@ class SubscriptionsManager {
(!signerHasNip44 && !windowHasNip44 && requiresNip44) ||
(!signerHasNip04 && !windowHasNip04 && requiresNip04)
) {
if (!allowPermissionPrompt) {
const error = new Error(
"Decrypt permissions are required to read subscriptions."
);
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
try {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
const error =
permissionResult?.error instanceof Error
@@ -942,19 +798,13 @@ class SubscriptionsManager {
}
if (nostrApi) {
const decrypterOptions = {
priority: NIP07_PRIORITY.HIGH,
timeoutMs: 12000,
retryMultiplier: 1,
};
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor("nip04", (payload) =>
runNip07WithRetry(
() => nostrApi.nip04.decrypt(userPubkey, payload),
{
label: "nip04.decrypt",
...decrypterOptions,
priority: NIP07_PRIORITY.HIGH,
},
),
);
@@ -971,7 +821,7 @@ class SubscriptionsManager {
() => nip44.decrypt(userPubkey, payload),
{
label: "nip44.decrypt",
...decrypterOptions,
priority: NIP07_PRIORITY.HIGH,
},
),
);
@@ -985,7 +835,7 @@ class SubscriptionsManager {
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
...decrypterOptions,
priority: NIP07_PRIORITY.HIGH,
},
),
);
@@ -995,7 +845,7 @@ class SubscriptionsManager {
() => nip44v2.decrypt(userPubkey, payload),
{
label: "nip44.v2.decrypt",
...decrypterOptions,
priority: NIP07_PRIORITY.HIGH,
},
),
);
@@ -1129,9 +979,7 @@ class SubscriptionsManager {
}
if (signer.type === "extension") {
const permissionResult = await requestDefaultExtensionPermissions(
DEFAULT_NIP07_ENCRYPTION_METHODS,
);
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult.ok) {
userLogger.warn(
"[SubscriptionsManager] Signer permissions denied while updating subscriptions.",
@@ -1240,6 +1088,13 @@ class SubscriptionsManager {
throw signErr;
}
const sanitizeRelayList = (candidate) =>
Array.isArray(candidate)
? candidate
.map((url) => (typeof url === "string" ? url.trim() : ""))
.filter(Boolean)
: [];
const writeRelays = sanitizeRelayList(nostrClient.writeRelays);
const fallbackRelays = writeRelays.length
? writeRelays
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more