Compare commits

..
206 Commits
Author SHA1 Message Date
thePR0M3TH3AN e77db9d07d Merge pull request #263 from PR0M3TH3AN/unstable
Unstable
2025-10-02 21:27:33 -04:00
thePR0M3TH3AN 245f6bd0d1 Merge pull request #262 from PR0M3TH3AN/codex/add-detailed-logging-for-watch-list-process
Add detailed logging for watch history flow
2025-10-02 18:46:51 -04:00
thePR0M3TH3AN 762dbe5dcc Fix watch history sync when logged in 2025-10-02 18:45:44 -04:00
thePR0M3TH3AN c3ed4bc6ea Scope watch history sync to logged users 2025-10-02 18:26:43 -04:00
thePR0M3TH3AN b1a493f113 Enable local watch history fallback when sync disabled 2025-10-02 17:07:21 -04:00
thePR0M3TH3AN 84b70475d9 Ensure watch history logging executes when service available 2025-10-02 16:53:09 -04:00
thePR0M3TH3AN a4eb4f2367 Add detailed logging for watch history flow 2025-10-02 16:44:13 -04:00
thePR0M3TH3AN 19ac142d31 Merge pull request #261 from PR0M3TH3AN/codex/extend-publishwatchhistorysnapshot-for-relay-status
Handle partial watch history snapshot publish retries
2025-10-02 16:31:45 -04:00
thePR0M3TH3AN 7fad136f4a Fix duplicate remove destructuring in watch history renderer 2025-10-02 16:29:23 -04:00
thePR0M3TH3AN 041aa58398 Handle partial watch history publish retries 2025-10-02 16:14:20 -04:00
thePR0M3TH3AN 212f48c0e3 Merge pull request #260 from PR0M3TH3AN/codex/refactor-watch-history-batching-logic
Clarify watch history batching window
2025-10-02 15:59:33 -04:00
thePR0M3TH3AN f6729b8e35 Fix watch history batch window 2025-10-02 15:59:06 -04:00
thePR0M3TH3AN 7ca48bf6de Merge pull request #259 from PR0M3TH3AN/codex/add-tests-for-watch-history-functionality
Add comprehensive watch history test coverage
2025-10-02 15:42:03 -04:00
thePR0M3TH3AN 9112aeadcc Add comprehensive watch history test coverage 2025-10-02 15:41:41 -04:00
thePR0M3TH3AN 54b5358854 Merge pull request #258 from PR0M3TH3AN/codex/update-watch-history-documentation-for-v2-pipeline
Document watch history v2 pipeline
2025-10-02 15:27:39 -04:00
thePR0M3TH3AN 09fc89bda6 Document watch history v2 pipeline 2025-10-02 15:26:49 -04:00
thePR0M3TH3AN 02bd23ccaf Merge pull request #257 from PR0M3TH3AN/codex/add-feature_watch_history_v2-flag-implementation
Gate watch history v2 behind runtime flag
2025-10-02 15:20:30 -04:00
thePR0M3TH3AN 6cfaf38ce1 Gate watch history v2 behind runtime flag 2025-10-02 15:19:55 -04:00
thePR0M3TH3AN b4f7ed1e54 Merge pull request #256 from PR0M3TH3AN/codex/update-card-template-for-history-removal-functionality
Add watch history removal actions and metadata synchronization
2025-10-02 15:07:31 -04:00
thePR0M3TH3AN 138094f57d Add watch history removal actions and metadata sync 2025-10-02 15:06:13 -04:00
thePR0M3TH3AN 6e4323d9f6 Merge pull request #255 from PR0M3TH3AN/codex/extend-profile-modal-with-new-controls
Enhance profile watch history controls
2025-10-02 14:42:54 -04:00
thePR0M3TH3AN a1a4a3bbe1 Enhance profile watch history controls 2025-10-02 14:39:23 -04:00
thePR0M3TH3AN 8a0280902f Merge pull request #254 from PR0M3TH3AN/codex/refactor-historyview.js-and-implement-features
Refactor watch history renderer for batching and privacy controls
2025-10-02 14:26:28 -04:00
thePR0M3TH3AN 9945c024fc Refactor watch history renderer 2025-10-02 14:16:48 -04:00
thePR0M3TH3AN 23377591ce Merge pull request #253 from PR0M3TH3AN/codex/create-watchhistoryservice-with-session-management
Add watch history service and integrate playback flush
2025-10-02 14:01:49 -04:00
thePR0M3TH3AN 97a8ff814e feat: add watch history service and integrate playback flush 2025-10-02 13:58:42 -04:00
thePR0M3TH3AN 2cc0697b17 Merge pull request #252 from PR0M3TH3AN/codex/implement-watch-history-features-in-nostr.js
Reintroduce watch history snapshot publishing and caching
2025-10-02 13:39:56 -04:00
thePR0M3TH3AN 15a5bbe499 Implement watch history publishing and caching 2025-10-02 13:38:09 -04:00
thePR0M3TH3AN 4fec220b7b Merge pull request #251 from PR0M3TH3AN/codex/add-safedecode-utility-and-refactor-usage
Refactor decode helpers into shared utility
2025-10-02 12:52:25 -04:00
thePR0M3TH3AN c2c7f6445d refactor: centralize safe decode helpers 2025-10-02 12:48:24 -04:00
thePR0M3TH3AN fb0c2a31ea Merge pull request #250 from PR0M3TH3AN/codex/introduce-utility-for-card-observer-management
Refactor health observers to share card observer utility
2025-10-02 12:41:26 -04:00
thePR0M3TH3AN bf63808007 Refactor health observers to share card observer utility 2025-10-02 12:39:11 -04:00
thePR0M3TH3AN aff2a81bf5 Merge pull request #249 from PR0M3TH3AN/codex/create-shared-module-for-magnet-helpers
Refactor magnet helpers into shared module
2025-10-02 12:32:46 -04:00
thePR0M3TH3AN 7f586a38c4 Refactor magnet helpers into shared module 2025-10-02 12:30:12 -04:00
thePR0M3TH3AN 632ee88b90 Merge pull request #248 from PR0M3TH3AN/codex/remove-watch-history-system-logic
chore: remove watch history logic
2025-10-02 12:07:12 -04:00
thePR0M3TH3AN 0320492c54 fix: restore session actor for view events 2025-10-02 12:01:09 -04:00
thePR0M3TH3AN 57655aa304 fix: restore view counter support 2025-10-02 11:51:22 -04:00
thePR0M3TH3AN 48410b1abb chore: remove watch history logic 2025-10-02 11:43:15 -04:00
thePR0M3TH3AN 32ab19e915 Merge pull request #247 from PR0M3TH3AN/codex/fix-persistence-for-view-count-and-watch-history
Ensure view events persist across sessions
2025-10-02 10:53:09 -04:00
thePR0M3TH3AN 10adeddd82 Add durable identifiers to view events 2025-10-02 10:51:41 -04:00
thePR0M3TH3AN 43bda18a52 Merge pull request #246 from PR0M3TH3AN/codex/fix-watch-history-fetching-and-logging
Improve watch history fetch parity and logging
2025-10-02 10:40:09 -04:00
thePR0M3TH3AN d8f6da2b2c Improve watch history fetch parity and logging 2025-10-02 10:38:29 -04:00
thePR0M3TH3AN 6d1bbea045 Merge pull request #245 from PR0M3TH3AN/codex/add-logging-for-watch-history-updates-36ejak
Decouple watch history updates from view logging timers
2025-10-02 10:22:04 -04:00
thePR0M3TH3AN 385e7c6476 Merge branch 'unstable' into codex/add-logging-for-watch-history-updates-36ejak 2025-10-02 10:21:49 -04:00
thePR0M3TH3AN 8bdfd672aa Log relay acceptance for watch history publishes 2025-10-02 10:06:12 -04:00
thePR0M3TH3AN e97d1bb084 Surface watch history publish logs in production 2025-10-02 09:49:22 -04:00
thePR0M3TH3AN 7149255004 Decouple view logging and watch history timers 2025-10-02 09:40:39 -04:00
thePR0M3TH3AN 602fa753b1 Merge pull request #244 from PR0M3TH3AN/codex/add-logging-for-watch-history-updates
Add logging for watch history snapshot updates
2025-10-02 09:29:51 -04:00
thePR0M3TH3AN 6e838aedd9 Document watch history logging interpretation 2025-10-02 09:28:44 -04:00
thePR0M3TH3AN d938d01a4c Add dev logging for watch history updates 2025-10-02 09:17:28 -04:00
thePR0M3TH3AN d17047c6ea Merge pull request #243 from PR0M3TH3AN/codex/fix-watch-history-not-displaying-videos
Stop registering deprecated relay publish handlers
2025-10-02 09:08:11 -04:00
thePR0M3TH3AN bf088532dc Fix watch history publish errors against nostr-tools 1.8 2025-10-02 09:02:55 -04:00
thePR0M3TH3AN d9388db649 Merge pull request #242 from PR0M3TH3AN/codex/sync-watch-history-between-profile-and-sidebar
Cache watch history UI state across views
2025-10-02 08:50:10 -04:00
thePR0M3TH3AN a22b904ddc Cache watch history state across views 2025-10-02 08:46:22 -04:00
thePR0M3TH3AN 628a671277 Merge pull request #241 from PR0M3TH3AN/codex/refactor-publisheventtorelay-into-module
Ensure relay publish operations fail when unacknowledged
2025-10-02 08:01:43 -04:00
thePR0M3TH3AN 92cc05f1f6 Refactor relay publish acknowledgements 2025-10-02 08:00:21 -04:00
thePR0M3TH3AN 9aa5a360d6 Merge pull request #240 from PR0M3TH3AN/codex/fix-fetchwatchhistory-json-parsing-issues
Handle legacy watch history payloads without JSON parse errors
2025-10-01 22:22:33 -04:00
thePR0M3TH3AN 6684da3e5f Handle legacy watch history content more gracefully 2025-10-01 22:18:14 -04:00
thePR0M3TH3AN 7fcc123cb4 Merge pull request #239 from PR0M3TH3AN/codex/refactor-access-control-initialization-logic
Delay access control refresh until nostr pool ready
2025-10-01 22:11:30 -04:00
thePR0M3TH3AN ff30678afc Delay access control refresh until nostr pool ready 2025-10-01 22:07:31 -04:00
thePR0M3TH3AN da328db9c9 Merge pull request #238 from PR0M3TH3AN/codex/update-watch_history_kind-and-related-schemas
Migrate watch history events to kind 30079
2025-10-01 22:02:00 -04:00
thePR0M3TH3AN 38924abdcc chore: migrate watch history kind to 30079 2025-10-01 21:58:31 -04:00
thePR0M3TH3AN 4e6383ddeb Merge pull request #237 from PR0M3TH3AN/codex/update-index.html-to-use-nostr-tools
Use nostr-tools 1.8.3 via ESM import
2025-10-01 21:49:11 -04:00
thePR0M3TH3AN 61aa55e70b Replace bundled nostr-tools with esm module 2025-10-01 21:45:09 -04:00
thePR0M3TH3AN e92bfee9c4 Merge pull request #236 from PR0M3TH3AN/revert-235-codex/update-publisheventtorelay-and-add-tests
Revert "Guard watch history publish when relays omit seen listener"
2025-10-01 21:30:40 -04:00
thePR0M3TH3AN b6dc562a3e Revert "Guard watch history publish when relays omit seen listener" 2025-10-01 21:30:18 -04:00
thePR0M3TH3AN c842c4dbb3 Merge pull request #235 from PR0M3TH3AN/codex/update-publisheventtorelay-and-add-tests
Guard watch history publish when relays omit seen listener
2025-10-01 21:28:39 -04:00
thePR0M3TH3AN 672c7cb052 Guard watch history publish against missing seen listener 2025-10-01 21:25:38 -04:00
thePR0M3TH3AN cea8fcf6de Merge pull request #234 from PR0M3TH3AN/codex/increase-watch-history-fetch-limits-and-tests
Increase watch history fetch capacity for large snapshots
2025-10-01 21:05:31 -04:00
thePR0M3TH3AN 8ff22e1e81 Increase watch history fetch limit and expand tests 2025-10-01 21:02:55 -04:00
thePR0M3TH3AN 061bda8aec Merge pull request #233 from PR0M3TH3AN/codex/handle-missing-signer-in-decryptwatchhistoryevent
Handle watch history decrypt fallback when NIP-07 signer missing
2025-10-01 20:54:02 -04:00
thePR0M3TH3AN 61c8b3a8ee Handle missing signer when decrypting watch history 2025-10-01 20:50:08 -04:00
thePR0M3TH3AN db0461e9c7 Merge pull request #232 from PR0M3TH3AN/codex/update-publishwatchhistorysnapshot-behavior
Gracefully handle watch history snapshots without NIP-07 signer
2025-10-01 20:38:09 -04:00
thePR0M3TH3AN a1505b7538 Handle watch history snapshot publish without NIP-07 signer 2025-10-01 20:37:47 -04:00
thePR0M3TH3AN 297fbb2450 Merge pull request #231 from PR0M3TH3AN/codex/update-wss-tracker-urls-in-constants
Refresh default WSS trackers
2025-10-01 20:04:58 -04:00
thePR0M3TH3AN 7510ad2574 Update default WSS trackers 2025-10-01 20:02:44 -04:00
thePR0M3TH3AN 1c99d4c2f3 Merge pull request #230 from PR0M3TH3AN/codex/add-unsupported-relays-handling-in-nostrclient
Handle unsupported relays for Nostr COUNT requests
2025-10-01 19:57:30 -04:00
thePR0M3TH3AN 1298443c00 Handle unsupported COUNT relays 2025-10-01 19:56:36 -04:00
thePR0M3TH3AN b9ac6dd9ca Merge pull request #229 from PR0M3TH3AN/codex/implement-nip-04-encryption-handling
Handle plaintext watch history chunks without nip04 decrypt
2025-10-01 19:50:54 -04:00
thePR0M3TH3AN c87d9d96a4 Guard admin list relay publish listeners 2025-10-01 19:47:52 -04:00
thePR0M3TH3AN ea25e0a92a Guard relay publish listeners 2025-10-01 19:34:26 -04:00
thePR0M3TH3AN 9416efdf1a Handle plaintext watch history chunks 2025-10-01 19:20:11 -04:00
thePR0M3TH3AN f473fc69e3 Merge pull request #228 from PR0M3TH3AN/codex/sort-watched-videos-by-recency
Ensure watch history sorts by most recent view
2025-10-01 19:01:24 -04:00
thePR0M3TH3AN bea08e3b3c Sort watch history by most recent view 2025-10-01 19:00:54 -04:00
thePR0M3TH3AN b44373a4b6 Merge pull request #227 from PR0M3TH3AN/codex/revert-watch-history-functionality
Restore canonical watch history identifier and prefetch triggers
2025-10-01 18:52:01 -04:00
thePR0M3TH3AN ecd3f4026e Restore canonical watch history snapshot format 2025-10-01 18:47:57 -04:00
thePR0M3TH3AN af7738b4d8 Merge pull request #226 from PR0M3TH3AN/codex/revise-documentation-for-latency-expectations
Document watch history pipeline split
2025-10-01 18:26:08 -04:00
thePR0M3TH3AN 28def68012 Document watch history pipeline split 2025-10-01 18:25:35 -04:00
thePR0M3TH3AN 95b2f23d66 Merge pull request #225 from PR0M3TH3AN/codex/update-view-counter-tests-and-assertions
Update view counter test expectations
2025-10-01 18:22:40 -04:00
thePR0M3TH3AN 63274d4c43 Update view counter test for publish return 2025-10-01 18:21:47 -04:00
thePR0M3TH3AN b0774de823 Merge pull request #224 from PR0M3TH3AN/codex/refactor-playback-view-logging-behavior
Refine playback view logging error handling
2025-10-01 18:17:38 -04:00
thePR0M3TH3AN 8708431083 Refine playback view logging error handling 2025-10-01 18:17:17 -04:00
thePR0M3TH3AN d378b4ca17 Merge pull request #223 from PR0M3TH3AN/codex/update-recordvideoview-function
Adjust view recording response
2025-10-01 18:14:38 -04:00
thePR0M3TH3AN eae0ea47b0 Adjust view recording response 2025-10-01 18:14:11 -04:00
thePR0M3TH3AN 2bb49dd404 Merge pull request #222 from PR0M3TH3AN/codex/add-default-json-content-to-publishviewevent
Add default JSON payload to view events
2025-10-01 17:53:44 -04:00
thePR0M3TH3AN cf99059564 Add default view event payload and cover recordVideoView 2025-10-01 17:48:05 -04:00
thePR0M3TH3AN c06233947f Merge pull request #221 from PR0M3TH3AN/codex/update-countvideoviewevents-fallback-logic
Limit view count fallback window
2025-10-01 17:39:59 -04:00
thePR0M3TH3AN d1a9b8c79d Limit view count fallback window 2025-10-01 17:37:53 -04:00
thePR0M3TH3AN eaa81f56ab Merge pull request #220 from PR0M3TH3AN/codex/refactor-publishwatchhistorysnapshot-method
Refine watch history snapshot publishing for chunk failures
2025-10-01 17:30:23 -04:00
thePR0M3TH3AN a025dd210f Refine watch history snapshot publishing 2025-10-01 17:07:39 -04:00
thePR0M3TH3AN 1d977f5016 Merge pull request #219 from PR0M3TH3AN/codex/fix-video-view-counting-issue
Ensure playback uses fallback pointer for view logging
2025-10-01 16:34:33 -04:00
thePR0M3TH3AN 6b1602d170 Ensure playback uses fallback pointer for view logging 2025-10-01 16:30:39 -04:00
thePR0M3TH3AN 8f004e5947 Merge pull request #218 from PR0M3TH3AN/codex/add-idempotency-guard-to-publishviewevent
Add client guard for view event publishing
2025-10-01 16:03:29 -04:00
thePR0M3TH3AN 09c8e275c4 Add client-side guard for view event publishing 2025-10-01 15:53:23 -04:00
thePR0M3TH3AN 2599bdaabc Merge pull request #217 from PR0M3TH3AN/codex/update-view-event-schema-and-related-code
Stop default view events from adding legacy tags
2025-10-01 15:42:46 -04:00
thePR0M3TH3AN ff452ebe77 Stop default view events from adding legacy tags 2025-10-01 15:40:10 -04:00
thePR0M3TH3AN 7a0cf00810 Merge pull request #216 from PR0M3TH3AN/codex/update-publishwatchhistorysnapshot-and-fetchwatchhistory
Refine watch history snapshot publishing and refresh
2025-10-01 15:34:27 -04:00
thePR0M3TH3AN c70a107d2f Refine watch history snapshot publishing and refresh 2025-10-01 15:30:33 -04:00
thePR0M3TH3AN c8a79316d0 Merge pull request #215 from PR0M3TH3AN/codex/update-documentation-and-schemas-for-watch-history
Add dedicated watch history index events
2025-10-01 15:11:21 -04:00
thePR0M3TH3AN cd09dbc68d Add dedicated watch history index events 2025-10-01 14:59:04 -04:00
thePR0M3TH3AN b944560a08 Merge pull request #213 from PR0M3TH3AN/codex/fix-fetching-spinner-animation-issues
Fix stuck recent videos spinner and align watch history loader
2025-10-01 14:27:55 -04:00
thePR0M3TH3AN b2ceb197f2 Fix sidebar loading spinners for videos and history 2025-10-01 14:25:37 -04:00
thePR0M3TH3AN 1749351fa2 Merge pull request #212 from PR0M3TH3AN/codex/complete-relay-management-implementation
Implement relay list management for profiles
2025-10-01 14:16:04 -04:00
thePR0M3TH3AN 9f2e06744c Add relay list management for profile relays 2025-10-01 14:13:11 -04:00
thePR0M3TH3AN 9e509bb782 update 2025-10-01 13:53:00 -04:00
thePR0M3TH3AN 5f4f025a1b Merge pull request #211 from PR0M3TH3AN/codex/update-sidebar-watch-history-styling-and-loading
Align sidebar loading indicators and history heading
2025-10-01 13:50:59 -04:00
thePR0M3TH3AN fa583f0685 Show spinner while recent videos load 2025-10-01 13:49:43 -04:00
thePR0M3TH3AN 284bab6cff Align sidebar loading indicators and history heading 2025-10-01 13:42:50 -04:00
thePR0M3TH3AN 259bd3b281 Merge pull request #210 from PR0M3TH3AN/codex/centralize-nostr-notes-construction
Centralize Nostr event schema definitions
2025-10-01 13:31:51 -04:00
thePR0M3TH3AN 0e29b07d5b refactor: centralize nostr event schemas 2025-10-01 13:21:59 -04:00
thePR0M3TH3AN b6e1219d09 Merge pull request #208 from PR0M3TH3AN/revert-205-codex/fix-console-warnings-and-errors
Revert "Handle nostr readiness and count fallback"
2025-10-01 12:53:42 -04:00
thePR0M3TH3AN 713e4eeab6 Revert "Handle nostr readiness and count fallback" 2025-10-01 12:52:42 -04:00
thePR0M3TH3AN 2f1193dbf2 Merge pull request #205 from PR0M3TH3AN/codex/fix-console-warnings-and-errors
Handle nostr readiness and count fallback
2025-10-01 11:56:18 -04:00
thePR0M3TH3AN 434f5c3fa5 Handle nostr readiness and count fallback 2025-10-01 11:55:31 -04:00
thePR0M3TH3AN 48ad71ae80 Merge pull request #204 from PR0M3TH3AN/codex/investigate-view-counter-not-updating
Improve Nostr view event dedupe metadata and logging
2025-10-01 11:24:26 -04:00
thePR0M3TH3AN c5e95c2143 Improve view event dedupe tagging and logging 2025-10-01 11:24:00 -04:00
thePR0M3TH3AN 9e2fb61182 Merge pull request #203 from PR0M3TH3AN/codex/fix-watch-history-reset-on-user-login
Ensure watch history reinitializes when account changes
2025-10-01 11:15:18 -04:00
thePR0M3TH3AN ca4c53e081 Refresh watch history when auth changes 2025-10-01 11:14:55 -04:00
thePR0M3TH3AN 234f1f1a4c Merge pull request #202 from PR0M3TH3AN/codex/update-docs-with-session-key-flow-details
docs: outline view event session flow
2025-10-01 10:19:56 -04:00
thePR0M3TH3AN eae6b7b925 docs: outline view event session flow 2025-10-01 10:19:42 -04:00
thePR0M3TH3AN bc9ddaa12a Merge pull request #201 from PR0M3TH3AN/codex/update-event-counting-logic
Fix COUNT aggregation to avoid duplicate view totals
2025-10-01 10:16:54 -04:00
thePR0M3TH3AN a20dc838dc Adjust relay count aggregation for view totals 2025-10-01 10:16:22 -04:00
thePR0M3TH3AN 87ab3f6435 Merge pull request #200 from PR0M3TH3AN/codex/fix-video-view-counter-persistence
Fix Nostr view counters by indexing pointer tags
2025-10-01 06:17:39 -04:00
thePR0M3TH3AN 8d47b32193 Fix Nostr view counter filters 2025-10-01 06:17:24 -04:00
thePR0M3TH3AN 41a0d15e7a Merge pull request #199 from PR0M3TH3AN/codex/fix-video-view-count-on-cold-start
Wait for app initialization before handling hash views
2025-09-30 23:54:07 -04:00
thePR0M3TH3AN 9309ac11ea Wait for app initialization before handling hash views 2025-09-30 23:53:42 -04:00
thePR0M3TH3AN 9c7884d4b7 Merge pull request #198 from PR0M3TH3AN/codex/adjust-discussion-count-rendering-logic
Gate discussion badges on available counts
2025-09-30 23:22:24 -04:00
thePR0M3TH3AN a9be08be1d Gate discussion badges on available counts 2025-09-30 23:21:59 -04:00
thePR0M3TH3AN 03950f4555 Merge pull request #197 from PR0M3TH3AN/codex/extend-view-counter-tests-for-ttl
Add view counter cache TTL hydration test
2025-09-30 23:18:58 -04:00
thePR0M3TH3AN e543de8bb4 Add TTL hydration regression test for view counter 2025-09-30 23:18:36 -04:00
thePR0M3TH3AN 78b5677d58 Merge pull request #196 from PR0M3TH3AN/codex/clear-logged-view-pointer-keys-on-logout
Fix per-identity view logging
2025-09-30 23:11:17 -04:00
thePR0M3TH3AN 5d4c0d494b Fix per-identity view logging 2025-09-30 23:11:02 -04:00
thePR0M3TH3AN 7de5a0302e Merge pull request #195 from PR0M3TH3AN/codex/add-tests-for-view-counter-functionality
Add regression tests for view counter behaviors
2025-09-30 22:53:22 -04:00
thePR0M3TH3AN 825d3c7ce4 Add view counter regression tests 2025-09-30 22:51:40 -04:00
thePR0M3TH3AN 72d4c3ff30 Merge pull request #194 from PR0M3TH3AN/codex/implement-view-counter-api-integration
Add live view counters to video list and modal
2025-09-30 22:42:57 -04:00
thePR0M3TH3AN 5a243eea50 Add live view counters to video list and modal 2025-09-30 22:40:04 -04:00
thePR0M3TH3AN faa72e4032 Merge pull request #193 from PR0M3TH3AN/codex/implement-view-counter-module-with-caching
Add client-side view counter module
2025-09-30 22:26:05 -04:00
thePR0M3TH3AN 0de482efc0 Add client-side view counter module 2025-09-30 22:25:39 -04:00
thePR0M3TH3AN 8db49e7be2 Merge pull request #192 from PR0M3TH3AN/codex/add-video-view-event-handling-functions
Add Nostr video view event helpers
2025-09-30 22:21:23 -04:00
thePR0M3TH3AN d3d1e0c2e7 Add helpers for video view events 2025-09-30 22:20:59 -04:00
thePR0M3TH3AN cffdc02d40 Merge pull request #190 from PR0M3TH3AN/codex/add-config-constants-for-view-count
Add view counter configuration knobs
2025-09-30 22:13:24 -04:00
thePR0M3TH3AN cdff3efe23 Merge pull request #191 from PR0M3TH3AN/unstable
Added watch list.
2025-09-30 22:12:37 -04:00
thePR0M3TH3AN 65198c3abe Add view counter configuration knobs 2025-09-30 22:10:05 -04:00
thePR0M3TH3AN b7f24c1070 Merge pull request #189 from PR0M3TH3AN/codex/extend-nostr-client-with-count-helper
Add Nostr COUNT helper and surface discussion counts
2025-09-30 22:07:20 -04:00
thePR0M3TH3AN 9682875ed9 Add Nostr COUNT helper and surface discussion counts 2025-09-30 22:03:40 -04:00
thePR0M3TH3AN 6b2c052899 Merge pull request #188 from PR0M3TH3AN/codex/update-publishviewevent-for-session-actor
Mark session actor view events
2025-09-30 21:44:13 -04:00
thePR0M3TH3AN d32994e50f Mark session actor view events 2025-09-30 21:43:17 -04:00
thePR0M3TH3AN 859ca9a59a Merge pull request #187 from PR0M3TH3AN/codex/remove-buttons-from-watch-history-cards
Remove watch history card menus and add loading feedback
2025-09-30 21:31:47 -04:00
thePR0M3TH3AN 177cd191e3 Simplify watch history controls and add loading spinners 2025-09-30 21:29:23 -04:00
thePR0M3TH3AN 31e6bacd66 Merge pull request #186 from PR0M3TH3AN/codex/integrate-accesscontrol-in-historyview
Filter watch history against blocklists
2025-09-30 21:21:15 -04:00
thePR0M3TH3AN 6b6c97a52d Filter watch history by blocklists 2025-09-30 21:19:18 -04:00
thePR0M3TH3AN f1dc7d012e Merge pull request #185 from PR0M3TH3AN/codex/rework-watch-history-grid-and-ui-components
Revamp watch history cards
2025-09-30 21:12:27 -04:00
thePR0M3TH3AN 7af3a536c3 Revamp watch history cards 2025-09-30 21:11:02 -04:00
thePR0M3TH3AN fdbb24049d Merge pull request #184 from PR0M3TH3AN/codex/extend-watch-history-to-include-watchedat
Preserve watch history timestamps and removal support
2025-09-30 21:01:15 -04:00
thePR0M3TH3AN ad3f9cb200 Add watchedAt metadata to watch history pointers 2025-09-30 20:59:36 -04:00
thePR0M3TH3AN 5f88e85835 Merge pull request #183 from PR0M3TH3AN/codex/update-classifywatchhistorypointer-logic
Fix watch history chunk classification for address pointers
2025-09-30 20:10:32 -04:00
thePR0M3TH3AN 6039ad2fd7 Improve watch history caching and reuse 2025-09-30 20:09:23 -04:00
thePR0M3TH3AN 5e81bc9aef Fix watch history pointer classification 2025-09-30 19:52:25 -04:00
thePR0M3TH3AN e127c6f58e Merge pull request #182 from PR0M3TH3AN/codex/wrap-methods-in-timeout-handling
Add shared timeout wrapper for NIP-07 login flows
2025-09-30 16:24:12 -04:00
thePR0M3TH3AN 8d7d7a86cf Fix NIP-07 timeout helper to preserve user gestures 2025-09-30 16:22:50 -04:00
thePR0M3TH3AN fd62159e61 Guard NIP-07 login calls with shared timeout 2025-09-30 16:16:56 -04:00
thePR0M3TH3AN 46a8491bbc Merge pull request #181 from PR0M3TH3AN/codex/add-status-message-to-profile-modal
Add status messaging to profile watch history loading state
2025-09-30 16:13:17 -04:00
thePR0M3TH3AN ff14d10d9c Add profile history status message 2025-09-30 16:10:41 -04:00
thePR0M3TH3AN 65c402f845 Merge pull request #180 from PR0M3TH3AN/codex/update-watch-history-snapshot-handling
Refine watch history chunk addressing and fetch flow
2025-09-30 16:07:47 -04:00
thePR0M3TH3AN 97bf2e2852 Refine watch history chunk publishing and fetching 2025-09-30 16:05:42 -04:00
thePR0M3TH3AN 8c9f2cbfaa Merge pull request #179 from PR0M3TH3AN/codex/update-history-view-for-accessibility-enhancements
Improve history view loading status messaging
2025-09-30 15:26:03 -04:00
thePR0M3TH3AN 0f98e597ed Improve history view loading status messaging 2025-09-30 15:21:54 -04:00
thePR0M3TH3AN 65cfad60bb Merge pull request #178 from PR0M3TH3AN/codex/enhance-publishwatchhistorysnapshot-function
Ensure watch history snapshots have monotonic timestamps
2025-09-30 15:04:38 -04:00
thePR0M3TH3AN 32f04b94cb Ensure watch history snapshots advance created_at 2025-09-30 15:01:55 -04:00
thePR0M3TH3AN 61e8cc88fe Merge pull request #177 from PR0M3TH3AN/codex/update-watch-history-cache-ttl-settings
Extend watch history cache TTL and cover expiration in tests
2025-09-30 14:45:25 -04:00
thePR0M3TH3AN 1bcb770556 Extend watch history cache TTL and add tests 2025-09-30 14:42:21 -04:00
thePR0M3TH3AN 418239f977 Merge pull request #176 from PR0M3TH3AN/codex/update-watch-history-snapshot-handling
Chunk watch history payloads and rebuild fetch pipeline
2025-09-30 14:35:07 -04:00
thePR0M3TH3AN 034c4c3761 Chunk watch history payloads and rebuild fetch pipeline 2025-09-30 14:34:41 -04:00
thePR0M3TH3AN dfa260c2d0 Merge pull request #175 from PR0M3TH3AN/codex/add-watch-history-renderer-in-historyview.js
Add watch history renderer that preserves playback order
2025-09-30 14:05:34 -04:00
thePR0M3TH3AN b3274c9e68 Preserve watch history ordering 2025-09-30 14:02:55 -04:00
thePR0M3TH3AN 9aaa2db932 Merge pull request #174 from PR0M3TH3AN/codex/update-publishwatchhistorysnapshot-and-decryptwatchhistoryev
Add pointer tag fallback for watch history snapshots
2025-09-30 13:55:24 -04:00
thePR0M3TH3AN 712e9207e3 Add watch history pointer tags and fallback tests 2025-09-30 13:53:20 -04:00
thePR0M3TH3AN b4b2baef32 Merge pull request #173 from PR0M3TH3AN/codex/update-ensureinitialload-and-loadnextbatch
Fix watch history ensureInitialLoad reset pagination state
2025-09-30 13:20:53 -04:00
thePR0M3TH3AN 70917449d9 Style watch history grids like video feed 2025-09-30 13:17:33 -04:00
thePR0M3TH3AN 2351f9e126 Refresh watch history when snapshots change 2025-09-30 13:04:56 -04:00
thePR0M3TH3AN befa1ad5ae Encrypt watch history snapshots and decrypt on fetch 2025-09-30 12:46:07 -04:00
thePR0M3TH3AN 789e0eff26 Add detailed logging to watch history renderer 2025-09-30 12:24:23 -04:00
thePR0M3TH3AN 8a6e169471 Fix watch history ensureInitialLoad reset 2025-09-30 12:11:43 -04:00
thePR0M3TH3AN 53a8800eca Merge pull request #172 from PR0M3TH3AN/codex/add-profile-modal-history-tab-xus26x
Add modal watch history pane and shared renderer
2025-09-30 12:05:50 -04:00
thePR0M3TH3AN 1ff80e521a Harden watch history persistence 2025-09-30 11:53:59 -04:00
thePR0M3TH3AN 1e3da7aebc Fix watch history session actor initialization 2025-09-30 11:38:00 -04:00
thePR0M3TH3AN cd96e3a2b4 Ensure card clicks open video modal without navigation 2025-09-30 11:31:05 -04:00
thePR0M3TH3AN 80fcb22f89 Add profile watch history pane to modal 2025-09-30 10:59:33 -04:00
thePR0M3TH3AN c9857839f5 Merge pull request #171 from PR0M3TH3AN/codex/add-history-view-and-sidebar-link
Add watch history view and navigation link
2025-09-30 10:38:43 -04:00
thePR0M3TH3AN ab607dc7b8 Add watch history view 2025-09-30 10:36:41 -04:00
thePR0M3TH3AN 7260dfe1e0 Merge pull request #170 from PR0M3TH3AN/codex/implement-nostrclient-integration-for-video-playback-teq4jw
Log confirmed video views to Nostr watch history
2025-09-30 10:24:49 -04:00
thePR0M3TH3AN b56cd0c95f Add playback view logging via Nostr helpers 2025-09-30 10:22:54 -04:00
thePR0M3TH3AN a418fb2195 Merge pull request #168 from PR0M3TH3AN/codex/implement-video-watch-history-features
Implement watch history session support
2025-09-30 09:56:43 -04:00
thePR0M3TH3AN 511a14087e Add watch history session utilities 2025-09-30 09:55:01 -04:00
thePR0M3TH3AN be43f35ffc Merge pull request #167 from PR0M3TH3AN/codex/add-exported-constants-to-instance-config
Document watch history configuration defaults
2025-09-30 09:32:44 -04:00
thePR0M3TH3AN 57d114aa52 Document watch history configuration defaults 2025-09-30 09:28:29 -04:00
50 changed files with 16268 additions and 9760 deletions

No files matched your search

+9
View File
@@ -81,4 +81,13 @@ Document the run in PR descriptions so QA can cross-reference results.
* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
## Next
Please read these documents next.
docs/nostr-event-schemas.md
js/nostrEventSchemas.js
And if you need to create new nostr kinds please keep the logic centralized there in nostrEventSchemas.js and the nostr-event-schemas.md up to date.
**End of AGENTS.md**
+7
View File
@@ -107,6 +107,13 @@ latest styles.
- **Magnet helpers**:
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
### Relay compatibility
Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
### Adding Features
1. **Fork the repository** and create a new branch for your feature.
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
width="100%"
height="100%"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
>
<g transform="matrix(1,0,0,1,12,12)">
<g transform="matrix(1,0,0,1,-12,-12)">
<path
d="M12,2C6.486,2 2,6.486 2,12C2,17.514 6.486,22 12,22C17.514,22 22,17.514 22,12C22,6.486 17.514,2 12,2ZM12,4C16.411,4 20,7.589 20,12C20,16.411 16.411,20 12,20C7.589,20 4,16.411 4,12C4,7.589 7.589,4 12,4ZM11,7C10.448,7 10,7.448 10,8L10,13C10,13.266 10.105,13.52 10.293,13.707L13.293,16.707C13.683,17.098 14.317,17.098 14.707,16.707C15.098,16.317 15.098,15.683 14.707,15.293L12,12.586L12,8C12,7.448 11.552,7 11,7Z"
style="fill:rgb(92,111,138);fill-rule:nonzero;"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+134
View File
@@ -88,6 +88,15 @@
>
Blocked
</button>
<button
id="profileNavHistory"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneHistory"
aria-selected="false"
>
Watch history
</button>
<button
id="profileNavAdmin"
type="button"
@@ -272,6 +281,131 @@
</div>
<ul id="blockedList" class="space-y-3"></ul>
</section>
<section
id="profilePaneHistory"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavHistory"
>
<p class="text-sm text-gray-400">
Your recently watched videos appear here so you can jump back into favorites.
</p>
<div
id="profileHistoryFeatureBanner"
class="hidden rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100"
role="status"
aria-live="polite"
></div>
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div
class="flex flex-wrap gap-2"
role="group"
aria-label="Watch history actions"
>
<button
id="profileHistoryClear"
type="button"
class="inline-flex items-center gap-2 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Clear local cache
</button>
<button
id="profileHistoryRepublish"
type="button"
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Republish now
</button>
</div>
<div class="flex items-start gap-3">
<div class="flex flex-col">
<span
id="profileHistoryMetadataLabel"
class="text-sm font-medium text-gray-200"
>
Store titles/thumbnails locally
</span>
<span
id="profileHistoryMetadataDescription"
class="text-xs text-gray-400"
>
When disabled, BitVid skips saving rich metadata to this device.
</span>
</div>
<button
id="profileHistoryMetadataToggle"
type="button"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer items-center rounded-full border border-gray-600 bg-gray-700 transition focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
role="switch"
aria-checked="true"
aria-labelledby="profileHistoryMetadataLabel"
aria-describedby="profileHistoryMetadataDescription"
>
<span class="sr-only">Toggle local metadata storage</span>
<span
id="profileHistoryMetadataThumb"
class="inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white transition"
aria-hidden="true"
></span>
</button>
</div>
</div>
<div
id="profileHistoryToastRegion"
class="space-y-2"
aria-live="polite"
aria-atomic="false"
></div>
<div
id="profileHistorySessionWarning"
class="hidden inline-flex items-center gap-2 rounded-full border border-amber-500/50 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-100"
role="status"
aria-live="polite"
>
Session actor in use. Connect a NIP-07 extension to publish as your main profile.
</div>
<div id="profileHistoryLoading" class="py-6">
<div id="profileHistoryStatus">
<div
class="sidebar-loading-wrapper"
role="status"
aria-live="polite"
>
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text"
>Fetching watch history from relays…</span
>
</div>
</div>
</div>
</div>
<div
id="profileHistoryError"
class="hidden rounded-md border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-100"
role="alert"
aria-live="assertive"
></div>
<div
id="profileHistoryEmpty"
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
>
You haven’t watched any videos yet.
</div>
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
<div
id="profileHistoryGrid"
class="watch-history-grid watch-history-grid--modal hidden"
role="list"
></div>
<div id="profileHistorySentinel" class="h-1 w-full"></div>
</div>
</div>
</section>
<section
id="profilePaneAdmin"
class="profile-pane hidden space-y-6"
+11
View File
@@ -34,6 +34,17 @@
/>
<span class="sidebar-text">Explore</span>
</a>
<a
href="#view=history"
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
>
<img
src="assets/svg/history-icon.svg"
alt="History"
class="w-6 h-6 mr-3 flex-shrink-0"
/>
<span class="sidebar-text">History</span>
</a>
<a
id="subscriptionsLink"
href="#view=subscriptions"
+4 -1
View File
@@ -133,7 +133,10 @@
<div
class="flex items-center justify-between text-sm text-gray-400 mb-4"
>
<span id="videoTimestamp">just now</span>
<div class="flex items-center gap-2 modal-meta">
<span id="videoTimestamp">just now</span>
<span id="videoViewCount" class="modal-view-count">– views</span>
</div>
<div id="modalStatus" class="text-gray-300">
Initializing... Just give it a sec.
</div>
+133
View File
@@ -44,3 +44,136 @@ export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode";
* default state when no preference has been stored in localStorage yet.
*/
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
/**
* Nostr kind used when persisting watch history events.
*
* BitVid’s roadmap standardizes on kind 30079 so that watch events, view logs,
* and media metadata stay in the same family of documents. During the rollout
* from the legacy 30078 payloads, clients query both kinds so historical data
* keeps syncing; remove the compatibility fetch once all writers emit the new
* kind. Operators that want to experiment with a separate list kind (for
* example, a NIP-51 collection) can flip this number so long as their relays
* accept the chosen kind.
*/
export const WATCH_HISTORY_KIND = 30079;
/**
* Identifier applied to the watch-history list when storing it on relays.
*
* This becomes the value of the `d` tag so clients can find the correct list
* without guessing. Customize the slug if you need isolation between multiple
* BitVid deployments that share relays, but keep it stable once clients begin
* syncing history.
*/
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history";
/**
* Legacy identifiers that BitVid clients should continue honoring when
* fetching historical watch-history snapshots.
*/
export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([
"watch-history:v2:index",
]);
/**
* Whether to enable the V2 encrypted watch-history service.
*
* The runtime flag that controls this feature defaults to `false` so that new
* deployments stick with the analytics-only view flow until operators opt in.
* To enable V2, set `window.__BITVID_RUNTIME_FLAGS__.FEATURE_WATCH_HISTORY_V2 = true`
* in a bootstrap script (or override the value before the app loads). When the
* flag stays off, BitVid still emits legacy view events and will honor
* existing watch-history reads per plan §12, but the sync UI will surface a
* disabled banner instead of querying relays.
*/
/**
* Maximum number of watch-history entries to retain per user.
*
* The roadmap targets a rolling window of 1,500 items so the UI can highlight
* recently played videos without ballooning relay storage. Adjust this cap up
* or down depending on your retention and privacy policies; smaller values
* reduce storage pressure while larger ones surface a deeper backlog.
*/
export const WATCH_HISTORY_MAX_ITEMS = 1500;
/**
* Whether clients should resolve watch-history entries in batches.
*
* When enabled, BitVid fetches video metadata in grouped queries instead of
* issuing one request per entry. Operators with relays that struggle under
* bursty loads can disable batching at the cost of additional round trips.
*/
export const WATCH_HISTORY_BATCH_RESOLVE = true;
/**
* Maximum number of canonical items each batched watch-history response should
* include.
*
* Leave this as `null` (the default) to return the full
* `WATCH_HISTORY_MAX_ITEMS` window, even when batching is enabled. Deployments
* that page through histories in smaller slices can set a positive integer to
* cap the resolver output so pagination and API responses stay aligned.
*/
export const WATCH_HISTORY_BATCH_PAGE_SIZE = null;
/**
* Maximum size of the JSON payload for each watch-history chunk, measured
* before encryption.
*
* BitVid splits large histories across multiple events to stay within relay
* limits. This cap keeps individual chunks under roughly 60 KB so that
* encrypted payloads remain relay-friendly even after base64 expansion.
* Lower the number if your relays enforce tighter limits.
*/
export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000;
/**
* Number of watch-history events to request when syncing from relays.
*
* Chunked histories can span several events. This fetch limit should exceed
* the maximum number of chunks you expect a client to publish in one snapshot
* so the UI can stitch the full list back together.
*/
export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 64;
/**
* How long clients should cache watch-history snapshots in localStorage.
*
* A 24-hour window keeps recently played videos available across reloads
* without requiring a fresh relay sync on every visit. Increase the value for
* longer-lived caches or decrease it if your deployment needs tighter
* retention guarantees.
*/
export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* How long the view counter should treat repeat plays as duplicates.
*
* BitVid de-duplicates view events that occur within this rolling window so
* quick refreshes or stalled replays do not inflate the totals. The default of
* 24 hours mirrors common analytics tooling, but you can tighten or relax the
* window depending on how aggressively you want to filter repeat traffic.
*/
export const VIEW_COUNT_DEDUPE_WINDOW_SECONDS = 86_400;
/**
* How far back the view counter should hydrate historical events during
* backfills.
*
* When a new analytics worker starts up, it can walk older Nostr events to
* reconstruct totals. Limiting the backfill horizon keeps catch-up jobs
* bounded—90 days covers recent trends without hammering relays for year-old
* history. Increase the number if you need deeper analytics or shrink it for
* lighter start-up workloads.
*/
export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90;
/**
* How long clients can trust cached view totals before re-fetching.
*
* Cached results smooth out traffic spikes and reduce relay load. Five minutes
* strikes a balance between responsiveness and efficiency; lower the TTL for
* fresher dashboards or raise it if your analytics traffic is heavy.
*/
export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000;
+322
View File
@@ -104,6 +104,31 @@ header img {
padding: 1.5rem 0;
}
/* Dedicated watch-history layout stacks cards vertically */
.watch-history-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
padding: 1.5rem 0;
}
/* Profile modal uses the same vertical stack but without outer padding */
.watch-history-grid.watch-history-grid--modal {
padding: 0;
gap: 1.25rem;
}
@media (max-width: 640px) {
.watch-history-grid {
gap: 1.25rem;
}
.watch-history-grid.watch-history-grid--modal {
gap: 1.125rem;
}
}
/* Video Cards */
.video-card {
background-color: var(--color-card);
@@ -131,6 +156,249 @@ header img {
animation: video-card-fade-in 220ms ease-out forwards;
}
/* Watch history cards */
.watch-history-card {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: stretch;
background-color: var(--color-card);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 0.0625rem solid transparent;
box-shadow: var(--shadow-md);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.watch-history-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.watch-history-card--private {
border-color: rgba(234, 179, 8, 0.75);
}
.watch-history-card__primary {
display: flex;
gap: 1rem;
flex: 1 1 18rem;
min-width: 0;
}
.watch-history-card__thumbnail {
display: block;
flex: 0 0 14rem;
max-width: 14rem;
text-decoration: none;
}
.watch-history-card__thumbnailInner {
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
background-color: #0f172a;
}
.watch-history-card__thumbnailInner img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.watch-history-card__details {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
min-width: 0;
}
.watch-history-card__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watch-history-card__title:hover,
.watch-history-card__title:focus {
color: #60a5fa;
}
.watch-history-card__created {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
}
.watch-history-card__meta {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;
flex: 0 0 16rem;
min-width: 14rem;
}
.watch-history-card__watched {
margin: 0;
font-size: 0.875rem;
color: #d1d5db;
}
.watch-history-card__creator {
display: flex;
align-items: center;
gap: 0.75rem;
}
.watch-history-card__creatorAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #1f2937;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__creatorAvatar:hover,
.watch-history-card__creatorAvatar:focus {
box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
outline: none;
}
.watch-history-card__creatorAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 9999px;
}
.watch-history-card__creatorName {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #e5e7eb;
text-align: left;
cursor: pointer;
}
.watch-history-card__creatorName:hover,
.watch-history-card__creatorName:focus {
color: #60a5fa;
outline: none;
}
.watch-history-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.watch-history-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 0.0625rem solid transparent;
background-color: #1f2937;
color: #e5e7eb;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease,
border-color 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__action:hover,
.watch-history-card__action:focus {
background-color: #111827;
color: #ffffff;
border-color: #374151;
outline: none;
}
.watch-history-card__action--primary {
background-color: #2563eb;
color: #ffffff;
}
.watch-history-card__action--primary:hover,
.watch-history-card__action--primary:focus {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.watch-history-card__action--danger {
background-color: #7f1d1d;
color: #ffffff;
}
.watch-history-card__action--danger:hover,
.watch-history-card__action--danger:focus {
background-color: #991b1b;
border-color: #991b1b;
}
@media (max-width: 1024px) {
.watch-history-card__meta {
flex: 1 1 18rem;
}
}
@media (max-width: 768px) {
.watch-history-card {
gap: 1.25rem;
padding: 1rem;
}
.watch-history-card__thumbnail {
flex-basis: 11rem;
max-width: 11rem;
}
}
@media (max-width: 640px) {
.watch-history-card {
flex-direction: column;
}
.watch-history-card__primary,
.watch-history-card__meta {
flex: 1 1 auto;
min-width: 0;
}
.watch-history-card__thumbnail {
width: 100%;
max-width: 100%;
}
.watch-history-card__meta {
gap: 0.75rem;
}
}
@keyframes video-card-fade-in {
from {
opacity: 0;
@@ -617,6 +885,27 @@ header img {
box-shadow: inset 0 1px 0 rgba(15, 23, 42, 0.35);
}
.modal-meta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.modal-view-count {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
line-height: 1.25rem;
color: #9ca3af;
white-space: nowrap;
}
.view-count-text {
white-space: nowrap;
color: #6b7280;
}
html.modal-open {
overflow: hidden;
}
@@ -855,6 +1144,39 @@ button:not(.icon-button):focus {
display: flex;
}
.status-spinner--inline {
animation: spin 1s linear infinite;
border: 0.125rem solid rgba(96, 165, 250, 0.35);
border-radius: 9999px;
border-top-color: #60a5fa;
display: inline-block;
height: 1rem;
width: 1rem;
}
.sidebar-loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 3rem 0;
width: 100%;
grid-column: 1 / -1;
}
.sidebar-loading-indicator {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: #6b7280;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.01em;
}
.sidebar-loading-text {
white-space: nowrap;
}
.status-banner {
align-items: center;
background-color: rgba(31, 41, 55, 0.9);
+30
View File
@@ -0,0 +1,30 @@
# Nostr Analytics Knobs
BitVid's view counter emits Nostr events so operators can track engagement without duplicating storage. Tune the following exports in `config/instance-config.js` to match your retention and performance goals:
- `VIEW_COUNT_DEDUPE_WINDOW_SECONDS` (default: `86_400`): repeat plays from the same viewer inside this window are treated as duplicates so stalled reloads do not inflate totals. Shorten the window to count more aggressive replays, or extend it if you want conservative numbers.
- `VIEW_COUNT_BACKFILL_MAX_DAYS` (default: `90`): controls how far back hydrators should walk history when a new analytics worker boots. Longer windows deliver deeper trend lines at the cost of heavier relay scans.
- `VIEW_COUNT_CACHE_TTL_MS` (default: `5 * 60 * 1000`): defines how long cached aggregates remain trustworthy before clients refresh them. Lower values surface spikes faster, while higher ones smooth traffic for relay-friendly dashboards.
Most operators can ship with the defaults—24-hour deduplication, a 90-day backfill horizon, and five-minute cache TTLs match what we run in production. Deviate only if your relays have unusual load constraints or your reporting needs stricter fidelity.
## View Event Lifecycle
### Anonymous session keys
Playback telemetry runs even when a viewer has not connected a Nostr account. The client first calls [`ensureSessionActor`](../js/nostr.js#L2011-L2105) to mint or restore an ephemeral keypair stored in `localStorage`. That session actor signs view events until the user authenticates, ensuring relays still see consistent pubkeys without blocking anonymous playback.
### Publish latency expectations
When a player crosses the 12-second watch threshold, BitVid invokes [`publishViewEvent`](../js/nostr.js#L2159-L2323) through the view-only [`recordVideoView`](../js/nostr.js#L4516-L4555) contract. The helper considers the publish successful as soon as any configured relay acknowledges the event, which in practice keeps latency under a second on public relays. The UI optimistically increments totals by feeding the signed event into [`ingestLocalViewEvent`](../js/viewCounter.js#L608-L640), so cards and modals reflect the new view immediately even while other relays finish syncing.
The watch history pipeline has been retired; `recordVideoView` now focuses solely on analytics publishing.
To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts.
Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history.
### Hydration, COUNT, and UI sync
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
+83
View File
@@ -0,0 +1,83 @@
# Nostr event schemas
BitVid now centralizes every note that it publishes to Nostr in
[`js/nostrEventSchemas.js`](../js/nostrEventSchemas.js). The module defines the
kind, required tags, and content format for each note type so troubleshooting
no longer requires hunting through the codebase. It also exposes helpers for
building events and for overriding the schema at runtime when you need to
experiment.
## Runtime helpers
```js
import {
NOTE_TYPES,
getNostrEventSchema,
setNostrEventSchemaOverrides,
buildVideoPostEvent,
} from "./nostrEventSchemas.js";
// Inspect the current schema
console.log(getNostrEventSchema(NOTE_TYPES.VIDEO_POST));
// Temporarily override the kind while debugging
setNostrEventSchemaOverrides({
[NOTE_TYPES.VIDEO_POST]: { kind: 30000 },
});
// Build an event with the active schema
const event = buildVideoPostEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
dTagValue: "debug-video",
content: { version: 3, title: "Test", videoRootId: "debug" },
});
```
In the browser you can call the same helpers from DevTools via
`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`.
When managing relay metadata, use `buildRelayListEvent` so the resulting
replaceable event follows NIP-65 (`kind:10002`) with `"r"` tags describing the
read/write split.
## Event catalogue
| Note | Kind (default) | Tags | Content format |
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) |
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
If you introduce a new Nostr feature, add its schema to
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
builders inherit the same debugging knobs.
### Watch history identifiers
The encrypted watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2`
runtime flag. When the flag is disabled, clients continue emitting view events
but skip publishing snapshots; the UI still resolves legacy `watch-history:v2:index`
lists so operators can stage the rollout per deployment.【F:config/instance-config.js†L69-L94】【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L948-L985】
Active identifiers include the default `WATCH_HISTORY_LIST_IDENTIFIER`
(`"watch-history"`) and the legacy aliases enumerated in
`WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS`. Chunk events derive their `d` tag from
`<snapshotId>:<index>`, advertise `['snapshot', <id>]`, and carry `['chunk', <index>, <total>]`
plus an optional leading `['head','1']` marker so relays can prioritize the first
ciphertext. All chunk content is encrypted with NIP-04 and stores only pointer
entries; richer metadata remains on-device via the
[`WatchHistoryService`](../js/watchHistoryService.js) APIs, which default to
pointer-only writes and local-only metadata caches.【F:config/instance-config.js†L60-L78】【F:js/nostrEventSchemas.js†L157-L189】【F:js/nostr.js†L2329-L2369】【F:js/watchHistoryService.js†L331-L376】
Refer to the [`WatchHistoryService`](../js/watchHistoryService.js) for queue
management hooks, manual snapshot helpers, and metadata toggle controls that
complement these schema definitions.【F:js/watchHistoryService.js†L695-L776】【F:js/watchHistoryService.js†L1040-L1093】
+3
View File
@@ -20,5 +20,8 @@ Run this checklist before publishing releases or merging changes that touch uplo
6. **Saved Profile Metadata Refresh**
- In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars.
- Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh.
7. **View Logging Per Identity**
- Play the same video until a view event is logged, then log out (or switch to a different pubkey) and repeat the playback.
- Confirm two distinct view events appear (e.g., via relay logs or UI counters) and the aggregated view count increments twice.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+73
View File
@@ -0,0 +1,73 @@
# Watch history logging (v2 pipeline)
BitVid's watch history sync is orchestrated by the
[`WatchHistoryService`](../js/watchHistoryService.js). The service collects
per-video pointer data during playback, batches it in session storage, and emits
encrypted snapshots to relays only when a publish is triggered. Relay payloads
stick to pointer identifiers so title, thumbnail, and profile context remain on
the client unless an operator opts into local metadata caching.
## Snapshot composition
Snapshots publish two event types to the shared `WATCH_HISTORY_KIND` stream:
1. **Chunk events** — Each chunk carries a deterministic identifier in its
`d` tag, advertises its `snapshot` and `chunk` position, and is tagged with
`['encrypted','nip04']`. The first chunk is additionally marked as
`['head','1']` and may append `"a"` tags that reference previous chunk
addresses, allowing readers to reconstruct the full set even when relays
deduplicate aggressively.【F:js/nostrEventSchemas.js†L175-L189】【F:js/nostr.js†L2329-L2369】
2. **Pointer event** — After chunks are accepted, BitVid emits a compact index
event containing the canonical list identifier (`['d', WATCH_HISTORY_LIST_IDENTIFIER]`),
the `snapshot` label, the total chunk count, and an `a` tag for each chunk
address.【F:js/nostrEventSchemas.js†L157-L170】【F:js/nostr.js†L2425-L2441】
Chunk content is a NIP-04 encrypted JSON envelope of `{ version: 2, snapshot,
chunkIndex, totalChunks, items[] }`. Items are pointer descriptors (`e` or `a`
references plus optional relay hints) so that relays never learn which titles
were played—only which events or addresses the client can dereference later.【F:js/nostr.js†L2337-L2364】
## Batching configuration
Operators can toggle grouped resolution with `WATCH_HISTORY_BATCH_RESOLVE` and
optionally cap each response by defining `WATCH_HISTORY_BATCH_PAGE_SIZE` in
`config/instance-config.js`. Leaving the page size unset returns the full
`WATCH_HISTORY_MAX_ITEMS` window while still letting the UI paginate locally,
but setting a positive integer keeps API payloads in lockstep with batched
renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr.js†L2987-L2998】【F:js/historyView.js†L1-L38】
## Snapshot cadence
`WatchHistoryService.publishView` queues pointer entries while the feature flag
is enabled, merging repeats with a one-minute throttle and persisting the queue
in `sessionStorage`. Snapshots run when `watchHistoryService.snapshot()` is
invoked (e.g., on `beforeunload`, `visibilitychange`, or manual sync actions in
the history UI) and clear the queue on success.【F:js/watchHistoryService.js†L872-L917】【F:js/app.js†L6041-L6081】【F:js/app.js†L7467-L7484】
If no items are pending, the snapshot resolves with an empty result so UI calls
remain idempotent.【F:js/watchHistoryService.js†L932-L944】
## Republish and backoff
When a snapshot publish fails but the response is retryable, the service stores
the returned `snapshotId`, records the originating reason, and delegates to the
Nostr client to retry with exponential backoff. Retries start after two seconds,
double per attempt with 25% jitter, cap at five minutes, and stop after eight
attempts unless a publish succeeds sooner.【F:js/watchHistoryService.js†L948-L985】【F:js/watchHistoryService.js†L723-L777】【F:js/nostr.js†L1988-L2087】
## Legacy payload handling
Readers hydrate history by combining the pointer event, any encrypted chunks,
and fallback metadata embedded in legacy watch-history lists. The resolver still
accepts the `watch-history:v2:index` identifier, merges pointer tags from the
index event, and falls back to plaintext content when decryption fails so older
relays remain compatible.【F:config/instance-config.js†L60-L78】【F:js/nostr.js†L2704-L2779】【F:js/nostr.js†L5216-L5264】
## Local metadata toggles
By default the service only persists pointer data to relays; richer metadata is
stored locally (and opt-in). Users can toggle the "store watch history metadata"
preference, which flips a localStorage flag, clears cached entries when
disabled, and never pushes the sanitized video/profile cache over the network.【F:js/watchHistoryService.js†L82-L140】【F:js/watchHistoryService.js†L232-L313】【F:js/watchHistoryService.js†L331-L376】
See the [`WatchHistoryService` API](../js/watchHistoryService.js) for
event hooks, queue inspection helpers, and manual snapshot controls.
+21 -3
View File
@@ -198,10 +198,28 @@
</div>
<!-- Additional Scripts -->
<script src="js/libs/nostr.bundle.js"></script>
<script type="module">
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
window.NostrTools = { nip19, SimplePool };
import * as nostrTools from "https://esm.sh/[email protected]";
const normalizedGeneratePrivateKey =
typeof nostrTools.generatePrivateKey === "function"
? nostrTools.generatePrivateKey
: typeof nostrTools.generateSecretKey === "function"
? nostrTools.generateSecretKey
: undefined;
const normalizedGenerateSecretKey =
typeof nostrTools.generateSecretKey === "function"
? nostrTools.generateSecretKey
: typeof nostrTools.generatePrivateKey === "function"
? nostrTools.generatePrivateKey
: undefined;
window.NostrTools = {
...nostrTools,
generatePrivateKey: normalizedGeneratePrivateKey,
generateSecretKey: normalizedGenerateSecretKey,
};
</script>
<script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script>
+68 -56
View File
@@ -51,81 +51,93 @@ class AccessControl {
this.hasLoaded = false;
this.lastError = null;
this._isRefreshing = false;
this._refreshPromise = this._performRefresh();
this._refreshPromise.catch((error) => {
console.error("Failed to load admin lists:", error);
});
this._refreshPromise = Promise.resolve();
}
async _performRefresh() {
_performRefresh() {
if (this._isRefreshing) {
return this._refreshPromise;
}
this._isRefreshing = true;
try {
const state = await loadAdminState();
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist)
? state.whitelist
: [];
const blacklist = Array.isArray(state?.blacklist)
? state.blacklist
: [];
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
);
const normalizedWhitelist = dedupeNpubs(whitelist);
this.whitelist = new Set(normalizedWhitelist);
const operation = (async () => {
try {
const state = await loadAdminState();
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist)
? state.whitelist
: [];
const blacklist = Array.isArray(state?.blacklist)
? state.blacklist
: [];
const blacklistDedupe = dedupeNpubs(blacklist);
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...Array.from(this.editors),
]);
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
});
this.blacklist = new Set(sanitizedBlacklist);
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
);
const normalizedWhitelist = dedupeNpubs(whitelist);
this.whitelist = new Set(normalizedWhitelist);
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = true;
this.lastError = null;
} catch (error) {
this.lastError = error;
if (!this.hasLoaded) {
this.editors.clear();
this.whitelist.clear();
this.blacklist.clear();
const blacklistDedupe = dedupeNpubs(blacklist);
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...Array.from(this.editors),
]);
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
});
this.blacklist = new Set(sanitizedBlacklist);
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = true;
this.lastError = null;
} catch (error) {
this.lastError = error;
if (!this.hasLoaded) {
this.editors.clear();
this.whitelist.clear();
this.blacklist.clear();
}
throw error;
} finally {
this._isRefreshing = false;
}
throw error;
} finally {
this._isRefreshing = false;
}
})();
this._refreshPromise = operation;
return operation;
}
refresh() {
const promise = this._performRefresh();
this._refreshPromise = promise;
promise.catch((error) => {
if (this._isRefreshing) {
return this._refreshPromise;
}
const operation = this._performRefresh();
const tracked = operation.catch((error) => {
console.error("Failed to refresh admin lists:", error);
throw error;
});
return promise;
this._refreshPromise = tracked;
return tracked;
}
async ensureReady() {
if (!this.hasLoaded && !this._isRefreshing) {
this.refresh();
}
try {
await this._refreshPromise;
} catch (error) {
+42 -28
View File
@@ -8,12 +8,10 @@ import {
isDevMode,
} from "./config.js";
import { nostrClient } from "./nostr.js";
const LIST_IDENTIFIERS = {
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
};
import {
buildAdminListEvent,
ADMIN_LIST_IDENTIFIERS,
} from "./nostrEventSchemas.js";
const LEGACY_STORAGE_KEYS = {
editors: "bitvid_admin_editors",
@@ -319,18 +317,15 @@ async function loadNostrList(identifier) {
async function loadNostrState() {
const [editors, whitelist, blacklist] = await Promise.all([
loadNostrList(LIST_IDENTIFIERS.editors),
loadNostrList(LIST_IDENTIFIERS.whitelist),
loadNostrList(LIST_IDENTIFIERS.blacklist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.editors),
loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist),
]);
return sanitizeAdminState({ editors, whitelist, blacklist });
}
function buildListEvent(listKey, npubs, actorHex) {
const identifier = LIST_IDENTIFIERS[listKey];
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
try {
@@ -360,18 +355,11 @@ function buildListEvent(listKey, npubs, actorHex) {
}
}
const tags = [["d", dTagValue]];
hexPubkeys.forEach((hex) => {
tags.push(["p", hex]);
});
return {
kind: 30000,
return buildAdminListEvent(listKey, {
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
};
hexPubkeys,
});
}
function publishToRelay(url, signedEvent, listKey) {
@@ -401,14 +389,40 @@ function publishToRelay(url, signedEvent, listKey) {
}
if (typeof pub.on === "function") {
pub.on("ok", () => finalize(true));
pub.on("seen", () => finalize(true));
pub.on("failed", (reason) => {
const registerHandler = (eventName, handler) => {
try {
pub.on(eventName, handler);
return true;
} catch (error) {
if (isDevMode) {
console.warn(
`[adminListStore] Relay publish rejected ${eventName} listener:`,
error
);
}
return false;
}
};
const handleFailure = (reason) => {
const error =
reason instanceof Error ? reason : new Error(String(reason || "failed"));
reason instanceof Error
? reason
: new Error(String(reason || "failed"));
finalize(false, error);
});
return;
};
let handlerRegistered = false;
handlerRegistered =
registerHandler("ok", () => finalize(true)) || handlerRegistered;
handlerRegistered =
registerHandler("seen", () => finalize(true)) || handlerRegistered;
handlerRegistered =
registerHandler("failed", handleFailure) || handlerRegistered;
if (handlerRegistered) {
return;
}
}
if (typeof pub.then === "function") {
+1855 -37
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
app.handleUrlHealthBadge({ video, url, badgeEl });
});
window.app.videoList = container;
window.app.attachVideoListHandler();
// Lazy-load images
+29
View File
@@ -4,6 +4,18 @@ import {
ADMIN_SUPER_NPUB,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
DEFAULT_WHITELIST_MODE_ENABLED,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
VIEW_COUNT_BACKFILL_MAX_DAYS,
VIEW_COUNT_CACHE_TTL_MS,
} from "../config/instance-config.js";
export const isDevMode = true; // Set to false for production
@@ -42,3 +54,20 @@ export function setWhitelistMode(enabled) {
}
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
// -----------------------------------------------------------------------------
// Watch history — roadmap defaults balance retention with relay load
// -----------------------------------------------------------------------------
export { WATCH_HISTORY_KIND };
export { WATCH_HISTORY_LIST_IDENTIFIER };
export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS };
export { WATCH_HISTORY_MAX_ITEMS };
export { WATCH_HISTORY_BATCH_RESOLVE };
export { WATCH_HISTORY_BATCH_PAGE_SIZE };
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
export { WATCH_HISTORY_CACHE_TTL_MS };
export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS };
export { VIEW_COUNT_BACKFILL_MAX_DAYS };
export { VIEW_COUNT_CACHE_TTL_MS };
+67 -3
View File
@@ -1,10 +1,13 @@
const DEFAULT_FLAGS = Object.freeze({
URL_FIRST_ENABLED: true, // try URL before magnet in the player
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
URL_FIRST_ENABLED: true, // try URL before magnet in the player
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
FEATURE_WATCH_HISTORY_V2: false,
WSS_TRACKERS: Object.freeze([
"wss://tracker.openwebtorrent.com",
"wss://tracker.btorrent.xyz",
"wss://tracker.fastcast.nz",
"wss://tracker.webtorrent.dev",
"wss://tracker.sloppyta.co:443/announce",
]),
});
@@ -17,6 +20,9 @@ const runtimeFlags = (() => {
const initial = {
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
};
if (globalScope) {
@@ -92,6 +98,16 @@ export let ACCEPT_LEGACY_V1 = coerceBoolean(
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
);
export let VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
);
export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
runtimeFlags.FEATURE_WATCH_HISTORY_V2,
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
);
export let WSS_TRACKERS = freezeTrackers(
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
);
@@ -118,6 +134,34 @@ Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
},
});
Object.defineProperty(runtimeFlags, "VIEW_FILTER_INCLUDE_LEGACY_VIDEO", {
configurable: true,
enumerable: true,
get() {
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
},
set(next) {
VIEW_FILTER_INCLUDE_LEGACY_VIDEO = coerceBoolean(
next,
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
);
},
});
Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", {
configurable: true,
enumerable: true,
get() {
return FEATURE_WATCH_HISTORY_V2;
},
set(next) {
FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
next,
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
);
},
});
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
configurable: true,
enumerable: true,
@@ -132,6 +176,8 @@ Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
// Ensure the runtime object reflects the sanitized defaults immediately.
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
export function setUrlFirstEnabled(next) {
@@ -144,6 +190,15 @@ export function setAcceptLegacyV1(next) {
return ACCEPT_LEGACY_V1;
}
export function setViewFilterIncludeLegacyVideo(next) {
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = next;
return VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
}
export function getWatchHistoryV2Enabled() {
return FEATURE_WATCH_HISTORY_V2 === true;
}
export function setWssTrackers(next) {
runtimeFlags.WSS_TRACKERS = next;
return WSS_TRACKERS;
@@ -152,8 +207,17 @@ export function setWssTrackers(next) {
export function resetRuntimeFlags() {
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
setViewFilterIncludeLegacyVideo(
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO
);
setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2);
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
}
export const RUNTIME_FLAGS = runtimeFlags;
export function setWatchHistoryV2Enabled(next) {
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = next;
return FEATURE_WATCH_HISTORY_V2;
}
+208
View File
@@ -0,0 +1,208 @@
const DEFAULT_CARD_SELECTOR = ".video-card";
function defaultIsCardVisible(entry) {
if (!entry) {
return false;
}
const isIntersecting = Boolean(entry.isIntersecting);
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
return isIntersecting && ratio > 0;
}
function normalizeState(value) {
if (value && typeof value === "object") {
return value;
}
return {};
}
export function createCardObserver(options = {}) {
const {
rootMargin = "0px",
threshold = 0,
cardSelector = DEFAULT_CARD_SELECTOR,
isCardVisible = defaultIsCardVisible,
prepareEntries,
onCardVisible,
onCardRegister,
createState,
} = options;
const containerState = new WeakMap();
function ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const observedCards = new WeakSet();
const customState = normalizeState(
typeof createState === "function" ? createState(container) : {}
);
const stateWrapper = {
container,
observedCards,
customState,
observer: null,
};
const observer = new IntersectionObserver(
(entries) => {
handleEntries(entries, stateWrapper);
},
{ root: null, rootMargin, threshold }
);
stateWrapper.observer = observer;
containerState.set(container, stateWrapper);
return stateWrapper;
}
function mapEntries(entries, state) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const context = {
container: state.container,
observer: state.observer,
state: state.customState,
};
if (typeof prepareEntries === "function") {
try {
const prepared = prepareEntries(entries, context);
if (Array.isArray(prepared)) {
return prepared;
}
} catch (err) {
console.warn("[cardObserver] prepareEntries failed", err);
}
}
return entries;
}
function handleEntries(entries, state) {
const mapped = mapEntries(entries, state);
if (!mapped.length) {
return;
}
mapped.forEach((item) => {
let entry = item;
let card;
let meta;
if (item && typeof item === "object" && "entry" in item) {
entry = item.entry;
card = item.card instanceof HTMLElement ? item.card : entry?.target;
if ("meta" in item) {
meta = item.meta;
} else if ("data" in item) {
meta = item.data;
} else if (typeof item.priority !== "undefined") {
meta = { priority: item.priority };
}
} else if (entry && entry.target instanceof HTMLElement) {
card = entry.target;
}
if (!(card instanceof HTMLElement) || !entry) {
return;
}
let visible = false;
try {
visible = Boolean(isCardVisible(entry, card, {
container: state.container,
observer: state.observer,
state: state.customState,
}));
} catch (err) {
console.warn("[cardObserver] isCardVisible failed", err);
visible = false;
}
if (!visible || typeof onCardVisible !== "function") {
return;
}
try {
onCardVisible({
card,
entry,
meta,
state: state.customState,
context: {
container: state.container,
observer: state.observer,
},
});
} catch (err) {
console.warn("[cardObserver] onCardVisible failed", err);
}
});
}
function observe(container) {
if (!(container instanceof HTMLElement)) {
return null;
}
const state = ensureState(container);
const cards = container.querySelectorAll(cardSelector);
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
if (typeof onCardRegister === "function") {
try {
onCardRegister({
card,
state: state.customState,
context: { container },
});
} catch (err) {
console.warn("[cardObserver] onCardRegister failed", err);
}
}
state.observer.observe(card);
});
const records = state.observer.takeRecords();
if (records.length) {
handleEntries(records, state);
}
return state.customState;
}
function refresh(container) {
const state = containerState.get(container);
if (!state) {
return;
}
const records = state.observer.takeRecords();
if (records.length) {
handleEntries(records, state);
}
}
function getState(container) {
const state = containerState.get(container);
return state ? state.customState : null;
}
return {
observe,
refresh,
getState,
};
}
+44 -60
View File
@@ -1,7 +1,7 @@
import { createCardObserver } from "./dom/cardObserver.js";
import { infoHashFromMagnet } from "./magnets.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
const containerState = new WeakMap();
const ROOT_MARGIN = "200px 0px";
function now() {
@@ -249,28 +249,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
const probeCache = new Map();
const probeInflight = new Map();
function ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
state = { observer: null, pendingByCard, observedCards };
const observer = new IntersectionObserver(
(entries) => {
processObserverEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
state.observer = observer;
containerState.set(container, state);
return state;
}
function getViewportCenter() {
if (typeof window === "undefined") {
return null;
@@ -389,18 +367,6 @@ function prioritizeEntries(entries, viewportCenter) {
}));
}
function processObserverEntries(entries, state) {
if (!state || !entries || entries.length === 0) {
return;
}
const viewportCenter = getViewportCenter();
const prioritized = prioritizeEntries(entries, viewportCenter);
prioritized.forEach(({ entry, priority }) => {
const card = entry.target;
handleCardVisible({ card, pendingByCard: state.pendingByCard, priority });
});
}
function getIntersectionRect(entry) {
if (!entry) {
return null;
@@ -539,6 +505,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
return;
}
if (!(pendingByCard instanceof WeakMap)) {
return;
}
if (pendingByCard.has(card)) {
return;
}
@@ -590,36 +560,50 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
});
}
const gridCardObserver = createCardObserver({
rootMargin: ROOT_MARGIN,
threshold: 0.01,
createState: () => ({ pendingByCard: new WeakMap() }),
prepareEntries: (entries) => {
const viewportCenter = getViewportCenter();
return prioritizeEntries(entries, viewportCenter);
},
isCardVisible: (entry) => {
if (!entry) {
return false;
}
const isIntersecting = Boolean(entry.isIntersecting);
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
return isIntersecting && ratio > 0;
},
onCardRegister: ({ card }) => {
if (!card.dataset.magnet) {
setBadge(card, "unhealthy", { reason: "missing-source" });
}
},
onCardVisible: ({ card, meta, state }) => {
if (!state || !(state.pendingByCard instanceof WeakMap)) {
return;
}
const priority =
meta && typeof meta === "object" && Number.isFinite(meta.priority)
? meta.priority
: PRIORITY_BASELINE;
handleCardVisible({
card,
pendingByCard: state.pendingByCard,
priority,
});
},
});
export function attachHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = ensureState(container);
const cards = container.querySelectorAll(".video-card");
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
if (!card.dataset.magnet) {
setBadge(card, "unhealthy", { reason: "missing-source" });
}
});
processObserverEntries(state.observer.takeRecords(), state);
gridCardObserver.observe(container);
}
export function refreshHealthBadges(container) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = containerState.get(container);
if (!state) {
return;
}
const records = state.observer.takeRecords();
processObserverEntries(records, state);
gridCardObserver.refresh(container);
}
+1962
View File
File diff suppressed because it is too large. Load diff
+50 -26
View File
@@ -257,6 +257,10 @@ export function setHashView(viewName) {
url.searchParams.delete("v");
const newUrl = url.pathname + url.search + `#view=${viewName}`;
window.history.replaceState({}, "", newUrl);
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
handleHashChange();
}
@@ -362,38 +366,58 @@ function handleQueryParams() {
}
}
function handleHashChange() {
async function waitForAppInitialization() {
const maybePromise = window.appReady;
if (!maybePromise || typeof maybePromise.then !== "function") {
return;
}
try {
await maybePromise;
} catch (error) {
console.warn(
"Proceeding with hash handling despite app initialization failure:",
error
);
}
}
async function handleHashChange() {
console.log("handleHashChange called, current hash =", window.location.hash);
await waitForAppInitialization();
const hash = window.location.hash || "";
// Use a regex that captures up to the first ampersand or end of string.
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
const match = hash.match(/^#view=([^&]+)/);
if (!match || !match[1]) {
// No valid "#view=..." => default to "most-recent-videos"
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
loadView("views/most-recent-videos.html").then(() => {
const initFn = viewInitRegistry["most-recent-videos"];
if (typeof initFn === "function") {
initFn();
}
recordView("most-recent-videos");
});
});
return;
try {
const { loadView, viewInitRegistry } = await import("./viewManager.js");
if (!match || !match[1]) {
// No valid "#view=..." => default to "most-recent-videos"
await loadView("views/most-recent-videos.html");
const initFn = viewInitRegistry["most-recent-videos"];
if (typeof initFn === "function") {
initFn();
}
return;
}
const viewName = match[1]; // only the chunk before any '&'
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
const viewUrl = `views/${viewName}.html`;
// Now dynamically load that partial, then call its init function
await loadView(viewUrl);
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
initFn();
}
} catch (error) {
console.error("Failed to handle hash change:", error);
}
const viewName = match[1]; // only the chunk before any '&'
const viewUrl = `views/${viewName}.html`;
// Now dynamically load that partial, then call its init function
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
loadView(viewUrl).then(() => {
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
initFn();
}
});
});
}
File diff suppressed because it is too large. Load diff
+31 -205
View File
@@ -1,224 +1,50 @@
import { WSS_TRACKERS } from "./constants.js";
import {
buildMagnetUri,
ensureTrackers,
ensureTorrentHint,
ensureWebSeeds,
formatAbsoluteUrl,
normalizeMagnetInput,
resolveAppProtocol,
extractMagnetHints as sharedExtractMagnetHints,
} from "./magnetShared.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
export const extractMagnetHints = sharedExtractMagnetHints;
function getOriginProtocol() {
if (typeof window !== "undefined" && window.location?.protocol) {
return window.location.protocol.toLowerCase();
}
return "https:";
}
function decodeLoose(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
return decodeURIComponent(trimmed);
} catch (err) {
return trimmed;
}
}
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/\/+$/, "").toLowerCase();
}
function tryFormatAbsoluteUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch (err) {
return trimmed;
}
}
/**
* Normalize and enrich a magnet string entered in the upload form so that it
* always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of
* the legacy magnetUtils helper but returns a plain string for ease of use.
*/
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
const {
initial,
canonicalValue,
isMagnet,
normalizedScheme,
fragment,
params,
} = normalizeMagnetInput(rawValue);
if (!initial) {
return "";
}
let working = initial;
if (HEX_INFO_HASH.test(working)) {
working = `magnet:?xt=urn:btih:${working.toLowerCase()}`;
if (!isMagnet) {
return canonicalValue;
}
working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`);
if (!/^magnet:/i.test(working)) {
return working;
}
let fragment = "";
const hashIndex = working.indexOf("#");
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const [, queryPart = ""] = working.split("?", 2);
const normalizedScheme = "magnet:";
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
for (const rawParam of rawParams) {
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
const key = rawKey.trim();
if (!key) {
continue;
}
let value = rawVal.trim();
if (key.toLowerCase() === "xt" && value) {
const decoded = decodeLoose(value);
if (decoded) {
value = decoded;
}
}
params.push({
key,
value,
lowerKey: key.toLowerCase(),
compareValue: normalizeForComparison(value),
});
}
const appendUniqueParam = (key, value) => {
if (!value) {
return;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return;
}
const lowerKey = key.toLowerCase();
const compareValue = normalizeForComparison(trimmedValue);
if (compareValue) {
const exists = params.some(
(param) => param.lowerKey === lowerKey && param.compareValue === compareValue
);
if (exists) {
return;
}
}
params.push({
key,
value: trimmedValue,
lowerKey,
compareValue,
});
};
for (const tracker of WSS_TRACKERS) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
appendUniqueParam("tr", trimmedTracker);
}
ensureTrackers(params);
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
if (normalizedXs) {
appendUniqueParam("xs", normalizedXs);
ensureTorrentHint(params, normalizedXs, { requireHttp: false });
}
const originProtocol = getOriginProtocol();
const rawWs = typeof ws === "string" ? ws.trim() : "";
if (rawWs) {
const formattedWs = tryFormatAbsoluteUrl(rawWs);
const lowerWs = formattedWs.toLowerCase();
const isHttpSeed = lowerWs.startsWith("http://");
const allowHttpSeed = originProtocol === "http:";
if (!isHttpSeed || allowHttpSeed) {
appendUniqueParam("ws", formattedWs);
}
const formattedWs = formatAbsoluteUrl(rawWs);
const allowHttpSeed = resolveAppProtocol() === "http:";
ensureWebSeeds(params, formattedWs, {
allowHttp: allowHttpSeed,
allowUnparsed: true,
});
}
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
}
export function extractMagnetHints(rawValue) {
const hints = { ws: "", xs: "" };
if (typeof rawValue !== "string") {
return hints;
}
const trimmed = rawValue.trim();
if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) {
return hints;
}
const queryIndex = trimmed.indexOf("?");
if (queryIndex === -1 || queryIndex === trimmed.length - 1) {
return hints;
}
const query = trimmed.slice(queryIndex + 1);
if (!query) {
return hints;
}
const parts = query.split("&");
for (const part of parts) {
if (!part) {
continue;
}
const [rawKey, rawValue = ""] = part.split("=", 2);
if (!rawKey) {
continue;
}
const lowerKey = rawKey.trim().toLowerCase();
if (lowerKey !== "ws" && lowerKey !== "xs") {
continue;
}
if (hints[lowerKey]) {
continue;
}
let decoded = rawValue;
try {
decoded = decodeURIComponent(rawValue);
} catch (err) {
// Ignore decode errors and use the raw value.
}
const clean = typeof decoded === "string" ? decoded.trim() : "";
if (!clean) {
continue;
}
hints[lowerKey] = clean;
}
return hints;
return buildMagnetUri(normalizedScheme, params, fragment);
}
+345
View File
@@ -0,0 +1,345 @@
import { WSS_TRACKERS } from "./constants.js";
import {
safeDecodeURIComponent,
safeDecodeURIComponentLoose,
} from "./utils/safeDecode.js";
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const BTIH_PREFIX = "urn:btih:";
const MAGNET_SCHEME = "magnet:";
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
export function safeDecodeMagnet(value) {
if (typeof value !== "string") {
return "";
}
let decoded = value.trim();
if (!decoded) {
return "";
}
for (let i = 0; i < 2; i += 1) {
if (!decoded.includes("%")) {
break;
}
const candidate = safeDecodeURIComponent(decoded);
if (!candidate || candidate === decoded) {
break;
}
decoded = candidate.trim();
}
return decoded;
}
export function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(/\/?$/, "");
const normalizedPath = pathname ? pathname : "";
return (
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
.trim()
.toLowerCase()
);
} catch (err) {
return trimmed.replace(/\/?$/, "").toLowerCase();
}
}
function createParam(key, value) {
const trimmedValue = typeof value === "string" ? value.trim() : "";
const decoded = safeDecodeURIComponentLoose(trimmedValue);
const comparisonBasis = decoded || trimmedValue;
return {
key,
value: trimmedValue,
lowerKey: key.toLowerCase(),
decoded,
comparison: normalizeForComparison(comparisonBasis),
};
}
export function normalizeMagnetInput(rawValue) {
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
if (!initial) {
return {
initial,
canonicalValue: "",
didMutate: false,
isMagnet: false,
normalizedScheme: "",
fragment: "",
params: [],
};
}
let working = initial;
let didMutate = false;
if (HEX_INFO_HASH.test(working)) {
working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
didMutate = true;
}
ENCODED_BTih_PATTERN.lastIndex = 0;
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
didMutate = true;
return `xt=${BTIH_PREFIX}${hash}`;
});
working = decodedXt;
let fragment = "";
const hashIndex = working.indexOf("#");
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const canonicalValue = working;
if (!/^magnet:/i.test(working)) {
return {
initial,
canonicalValue,
didMutate: didMutate || canonicalValue !== initial,
isMagnet: false,
normalizedScheme: "",
fragment: "",
params: [],
};
}
const [schemePart, queryPart = ""] = working.split("?", 2);
const normalizedScheme = MAGNET_SCHEME;
if (schemePart !== normalizedScheme) {
didMutate = true;
}
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
for (const rawParam of rawParams) {
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
const key = rawKey.trim();
if (!key) {
continue;
}
const lowerKey = key.toLowerCase();
let value = rawVal.trim();
if (lowerKey === "xt" && value) {
const decoded = safeDecodeURIComponentLoose(value);
if (decoded && decoded !== value) {
value = decoded;
didMutate = true;
}
}
params.push(createParam(key, value));
}
return {
initial,
canonicalValue,
didMutate,
isMagnet: true,
normalizedScheme,
fragment,
params,
};
}
export function buildMagnetUri(normalizedScheme, params, fragment) {
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`;
}
export function appendUniqueParam(params, key, value) {
if (!Array.isArray(params)) {
return false;
}
const candidate = createParam(key, value);
if (!candidate.value && candidate.value !== "") {
return false;
}
if (candidate.comparison) {
const exists = params.some(
(param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison
);
if (exists) {
return false;
}
} else {
const exists = params.some(
(param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value
);
if (exists) {
return false;
}
}
params.push(candidate);
return true;
}
export function ensureTrackers(params, trackers = WSS_TRACKERS) {
let didMutate = false;
for (const tracker of trackers) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!trimmedTracker) {
continue;
}
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
if (appendUniqueParam(params, "tr", trimmedTracker)) {
didMutate = true;
}
}
return didMutate;
}
export function sanitizeHttpUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
} catch (err) {
return "";
}
return "";
}
export function ensureTorrentHint(params, candidate, { requireHttp = true } = {}) {
if (requireHttp) {
const sanitized = sanitizeHttpUrl(candidate);
if (!sanitized) {
return false;
}
return appendUniqueParam(params, "xs", sanitized);
}
if (typeof candidate !== "string") {
return false;
}
const trimmed = candidate.trim();
if (!trimmed) {
return false;
}
return appendUniqueParam(params, "xs", trimmed);
}
export function resolveAppProtocol(explicitProtocol) {
if (typeof explicitProtocol === "string" && explicitProtocol.trim()) {
return explicitProtocol.trim().toLowerCase();
}
if (typeof window !== "undefined" && window.location?.protocol) {
return window.location.protocol.toLowerCase();
}
return "https:";
}
export function ensureWebSeeds(
params,
seeds,
{ allowHttp = false, allowUnparsed = false, logger } = {}
) {
const list = Array.isArray(seeds)
? seeds
: typeof seeds === "string"
? [seeds]
: [];
let didMutate = false;
for (const seed of list) {
if (typeof seed !== "string") {
continue;
}
const trimmedSeed = seed.trim();
if (!trimmedSeed) {
continue;
}
let finalValue = trimmedSeed;
let parsed;
try {
parsed = new URL(trimmedSeed);
const protocol = parsed.protocol;
if (protocol === "https:") {
finalValue = parsed.toString();
} else if (protocol === "http:") {
if (!allowHttp) {
if (typeof logger === "function") {
logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`);
}
continue;
}
finalValue = parsed.toString();
} else if (!allowUnparsed) {
continue;
}
} catch (err) {
if (!allowUnparsed) {
continue;
}
}
if (appendUniqueParam(params, "ws", finalValue)) {
didMutate = true;
}
}
return didMutate;
}
export function formatAbsoluteUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
return parsed.toString();
} catch (err) {
return trimmed;
}
}
export function extractMagnetHints(rawValue) {
const { params } = normalizeMagnetInput(rawValue);
const hints = { ws: "", xs: "" };
for (const param of params) {
if (param.lowerKey === "ws" && !hints.ws) {
hints.ws = param.decoded || param.value;
} else if (param.lowerKey === "xs" && !hints.xs) {
hints.xs = param.decoded || param.value;
}
if (hints.ws && hints.xs) {
break;
}
}
return hints;
}
+44 -261
View File
@@ -1,101 +1,19 @@
// js/magnetUtils.js
import { WSS_TRACKERS } from "./constants.js";
import {
buildMagnetUri,
ensureTrackers,
ensureTorrentHint,
ensureWebSeeds,
normalizeMagnetInput,
resolveAppProtocol,
safeDecodeMagnet as sharedSafeDecodeMagnet,
} from "./magnetShared.js";
export { WSS_TRACKERS };
export const safeDecodeMagnet = sharedSafeDecodeMagnet;
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
const BTIH_PREFIX = "urn:btih:";
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
function normalizeForComparison(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(/\/?$/, "");
const normalizedPath = pathname ? pathname : "";
return (
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
.trim()
.toLowerCase()
);
} catch (err) {
return trimmed.replace(/\/?$/, "").toLowerCase();
}
}
function sanitizeHttpUrl(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
} catch (err) {
return "";
}
return "";
}
export function safeDecodeMagnet(value) {
if (typeof value !== "string") {
return "";
}
let decoded = value.trim();
if (!decoded) {
return "";
}
for (let i = 0; i < 2; i += 1) {
if (!decoded.includes("%")) {
break;
}
try {
const candidate = decodeURIComponent(decoded);
if (!candidate) {
break;
}
if (candidate === decoded) {
break;
}
decoded = candidate.trim();
} catch (err) {
break;
}
}
return decoded;
}
/**
* Normalize a magnet URI while preserving legacy payload quirks and augmenting it
* for browser playback.
*
* Key behaviors:
* - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links.
* - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization
* beyond decoding legacy `%3A` segments.
* - Only appends browser-safe WSS trackers in addition to whatever the caller provides.
* - Skips insecure `http:` web seeds unless the application itself is running over HTTP.
*
* Note: `didChange` may be reported even if the resulting magnet string is textually
* identical to the input. Callers should not rely on `didChange` to detect a modified
* URI.
*/
export function normalizeAndAugmentMagnet(
rawValue,
{
@@ -107,83 +25,32 @@ export function normalizeAndAugmentMagnet(
appProtocol,
} = {}
) {
const log = typeof logger === "function" ? logger : () => {};
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
const {
initial,
canonicalValue,
didMutate,
isMagnet,
normalizedScheme,
fragment,
params,
} = normalizeMagnetInput(rawValue);
if (!initial) {
return { magnet: "", didChange: false };
}
let working = initial;
let didMutate = false;
const bareHashMatch = HEX_INFO_HASH.test(working);
if (bareHashMatch) {
working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
didMutate = true;
}
const hashIndex = working.indexOf("#");
let fragment = "";
if (hashIndex !== -1) {
fragment = working.slice(hashIndex);
working = working.slice(0, hashIndex);
}
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
didMutate = true;
return `xt=${BTIH_PREFIX}${hash}`;
});
working = decodedXt;
if (!/^magnet:/i.test(working)) {
if (!isMagnet) {
const magnet = canonicalValue;
return {
magnet: working,
didChange: didMutate || working !== initial,
magnet,
didChange: didMutate || magnet !== initial,
};
}
const [schemePart, queryPart = ""] = working.split("?", 2);
const normalizedScheme = "magnet:";
if (schemePart !== normalizedScheme) {
didMutate = true;
}
let didChange = didMutate;
const rawParams = queryPart
.split("&")
.map((part) => part.trim())
.filter(Boolean);
const params = [];
const decodeLoose = (value) => {
if (typeof value !== "string" || !value) {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
};
for (const rawParam of rawParams) {
const [rawKey, rawValue = ""] = rawParam.split("=", 2);
const key = rawKey.trim();
if (!key) {
continue;
}
let value = rawValue.trim();
if (key.toLowerCase() === "xt" && value) {
const decoded = decodeLoose(value);
if (decoded !== value) {
value = decoded;
didMutate = true;
}
}
params.push({
key,
value,
decoded: decodeLoose(value),
});
if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) {
didChange = true;
}
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
@@ -192,118 +59,34 @@ export function normalizeAndAugmentMagnet(
? xs.trim()
: "";
const safeTorrentHint = sanitizeHttpUrl(torrentHint);
if (torrentHint) {
if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) {
didChange = true;
}
}
const seedInputs = Array.isArray(webSeed)
? webSeed
: typeof webSeed === "string"
? [webSeed]
: [];
const resolvedProtocol = typeof appProtocol === "string" && appProtocol
? appProtocol.toLowerCase()
: typeof window !== "undefined" && window.location && window.location.protocol
? window.location.protocol.toLowerCase()
: "https:";
const resolvedProtocol = resolveAppProtocol(appProtocol);
const existingTrackers = new Set(
params
.filter((param) => param.key.toLowerCase() === "tr")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
const trackerCandidates = [
...WSS_TRACKERS,
...extraTrackers,
];
for (const tracker of trackerCandidates) {
if (typeof tracker !== "string") {
continue;
}
const trimmedTracker = tracker.trim();
if (!trimmedTracker) {
continue;
}
if (!/^wss:\/\//i.test(trimmedTracker)) {
continue;
}
const normalizedTracker = normalizeForComparison(trimmedTracker);
if (!normalizedTracker || existingTrackers.has(normalizedTracker)) {
continue;
}
params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker });
existingTrackers.add(normalizedTracker);
didMutate = true;
if (
ensureWebSeeds(params, seedInputs, {
allowHttp: resolvedProtocol === "http:",
allowUnparsed: false,
logger,
})
) {
didChange = true;
}
if (safeTorrentHint) {
const existingXs = new Set(
params
.filter((param) => param.key.toLowerCase() === "xs")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
const normalizedXs = normalizeForComparison(safeTorrentHint);
if (normalizedXs && !existingXs.has(normalizedXs)) {
params.push({
key: "xs",
value: safeTorrentHint,
decoded: safeTorrentHint,
});
didMutate = true;
}
}
if (seedInputs.length) {
const existingWs = new Set(
params
.filter((param) => param.key.toLowerCase() === "ws")
.map((param) => normalizeForComparison(param.decoded))
.filter(Boolean)
);
for (const seedInput of seedInputs) {
if (typeof seedInput !== "string") {
continue;
}
const trimmedSeed = seedInput.trim();
if (!trimmedSeed) {
continue;
}
try {
const parsedSeed = new URL(trimmedSeed);
const seedProtocol = parsedSeed.protocol;
const allowHttpSeed = resolvedProtocol === "http:";
if (
seedProtocol === "https:" ||
(seedProtocol === "http:" && allowHttpSeed)
) {
const seedValue = parsedSeed.toString();
const normalizedSeed = normalizeForComparison(seedValue);
if (normalizedSeed && !existingWs.has(normalizedSeed)) {
params.push({ key: "ws", value: seedValue, decoded: seedValue });
existingWs.add(normalizedSeed);
didMutate = true;
}
} else if (seedProtocol === "http:") {
log(
`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`
);
}
} catch (err) {
// Ignore invalid web seed values silently.
}
}
}
const queryString = params
.map(({ key, value }) => (value ? `${key}=${value}` : key))
.join("&");
const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment);
return {
magnet: finalMagnet,
didChange: didMutate || finalMagnet !== initial,
didChange: didChange || finalMagnet !== initial,
};
}
+4018 -146
View File
File diff suppressed because it is too large. Load diff
+780
View File
@@ -0,0 +1,780 @@
import {
ADMIN_LIST_NAMESPACE,
isDevMode,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
} from "./config.js";
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
WATCH_HISTORY_INDEX: "watchHistoryIndex",
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
SUBSCRIPTION_LIST: "subscriptionList",
USER_BLOCK_LIST: "userBlockList",
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
});
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
moderation: "editors",
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
});
const DEFAULT_APPEND_TAGS = [];
let cachedUtf8Encoder = null;
function ensureValidUtf8Content(value) {
let normalized = "";
if (typeof value === "string") {
normalized = value;
} else if (value === undefined || value === null) {
normalized = "";
} else if (typeof value === "object") {
try {
const stringified = JSON.stringify(value);
normalized = typeof stringified === "string" ? stringified : "";
} catch (error) {
if (isDevMode) {
console.warn(
"[nostrEventSchemas] Failed to serialize view event content:",
error
);
}
normalized = "";
}
} else {
normalized = String(value);
}
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
cachedUtf8Encoder = new TextEncoder();
}
if (cachedUtf8Encoder) {
try {
cachedUtf8Encoder.encode(normalized);
} catch (error) {
if (isDevMode) {
console.warn(
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
error
);
}
const sanitized = Array.from(normalized)
.filter((char) => {
const code = char.charCodeAt(0);
return code < 0xd800 || code > 0xdfff;
})
.join("");
try {
cachedUtf8Encoder.encode(sanitized);
normalized = sanitized;
} catch (encodeError) {
if (isDevMode) {
console.warn(
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
encodeError
);
}
normalized = "";
}
}
}
return normalized;
}
const BASE_SCHEMAS = {
[NOTE_TYPES.VIDEO_POST]: {
type: NOTE_TYPES.VIDEO_POST,
label: "Video post",
kind: 30078,
topicTag: { name: "t", value: "video" },
identifierTag: { name: "d" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
schemaVersion: 3,
fields: [
{ key: "version", type: "number", required: true },
{ key: "title", type: "string", required: true },
{ key: "url", type: "string", required: false },
{ key: "magnet", type: "string", required: false },
{ key: "thumbnail", type: "string", required: false },
{ key: "description", type: "string", required: false },
{ key: "mode", type: "string", required: false },
{ key: "videoRootId", type: "string", required: true },
{ key: "deleted", type: "boolean", required: false },
{ key: "isPrivate", type: "boolean", required: false },
{ key: "enableComments", type: "boolean", required: false },
{ key: "ws", type: "string", required: false },
{ key: "xs", type: "string", required: false },
],
},
},
[NOTE_TYPES.VIDEO_MIRROR]: {
type: NOTE_TYPES.VIDEO_MIRROR,
label: "NIP-94 mirror",
kind: 1063,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional alt text carried alongside hosted URL metadata.",
},
},
[NOTE_TYPES.RELAY_LIST]: {
type: NOTE_TYPES.RELAY_LIST,
label: "Relay list metadata",
kind: 10002,
relayTagName: "r",
readMarker: "read",
writeMarker: "write",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.VIEW_EVENT]: {
type: NOTE_TYPES.VIEW_EVENT,
label: "View counter",
kind: WATCH_HISTORY_KIND,
topicTag: { name: "t", value: "view" },
identifierTag: { name: "d" },
sessionTag: { name: "session", value: "true" },
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description: "Optional plaintext content used for diagnostics.",
},
},
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
label: "Watch history index",
kind: WATCH_HISTORY_KIND,
identifierTag: {
name: "d",
value: WATCH_HISTORY_LIST_IDENTIFIER,
},
snapshotTagName: "snapshot",
totalTagName: "chunks",
chunkPointerTagName: "a",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "json",
description:
"JSON payload describing the active snapshot and chunk count.",
},
},
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
type: NOTE_TYPES.WATCH_HISTORY_CHUNK,
label: "Watch history snapshot",
kind: WATCH_HISTORY_KIND,
identifierTag: {
name: "d",
},
encryptionTag: { name: "encrypted", value: "nip04" },
snapshotTagName: "snapshot",
chunkTagName: "chunk",
headTag: { name: "head", value: "1" },
headTagIndex: 2,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description:
"Encrypted JSON payload containing chunked watch history entries.",
},
},
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
type: NOTE_TYPES.SUBSCRIPTION_LIST,
label: "Subscription list",
kind: 30002,
identifierTag: {
name: "d",
value: SUBSCRIPTION_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description: "Encrypted JSON: { subPubkeys: string[] }.",
},
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
type: NOTE_TYPES.USER_BLOCK_LIST,
label: "User block list",
kind: 30002,
identifierTag: {
name: "d",
value: BLOCK_LIST_IDENTIFIER,
},
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "nip04-json",
description: "Encrypted JSON: { blockedPubkeys: string[] }.",
},
},
[NOTE_TYPES.ADMIN_MODERATION_LIST]: {
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
label: "Admin moderation list",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_BLACKLIST]: {
type: NOTE_TYPES.ADMIN_BLACKLIST,
label: "Admin blacklist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.ADMIN_WHITELIST]: {
type: NOTE_TYPES.ADMIN_WHITELIST,
label: "Admin whitelist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
},
participantTagName: "p",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
};
let schemaOverrides = {};
function clone(value) {
if (Array.isArray(value)) {
return value.map(clone);
}
if (value && typeof value === "object") {
const result = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = clone(nested);
}
return result;
}
return value;
}
function mergeDeep(base, override) {
const result = clone(base);
if (!override || typeof override !== "object") {
return result;
}
for (const [key, value] of Object.entries(override)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
result[key] = mergeDeep(base?.[key] ?? {}, value);
} else {
result[key] = clone(value);
}
}
return result;
}
function resolveOverride(type) {
const local = schemaOverrides?.[type];
if (local) {
return local;
}
if (typeof window !== "undefined") {
const runtimeOverrides = window.BitVidNostrEventOverrides;
if (runtimeOverrides && runtimeOverrides[type]) {
return runtimeOverrides[type];
}
}
return null;
}
export function setNostrEventSchemaOverrides(overrides = {}) {
if (overrides && typeof overrides === "object") {
schemaOverrides = overrides;
} else {
schemaOverrides = {};
}
if (typeof window !== "undefined") {
window.BitVidNostrEventOverrides = schemaOverrides;
}
}
export function getNostrEventSchema(type) {
const base = BASE_SCHEMAS[type];
if (!base) {
return null;
}
const override = resolveOverride(type);
return mergeDeep(base, override);
}
export function getAllNostrEventSchemas() {
const entries = {};
for (const type of Object.keys(BASE_SCHEMAS)) {
entries[type] = getNostrEventSchema(type);
}
return entries;
}
function appendSchemaTags(tags, schema) {
if (!Array.isArray(schema?.appendTags)) {
return tags;
}
schema.appendTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
return tags;
}
export function buildVideoPostEvent({
pubkey,
created_at,
dTagValue,
content,
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
if (schema?.identifierTag?.name && dTagValue) {
tags.push([schema.identifierTag.name, dTagValue]);
}
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 30078,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : JSON.stringify(content ?? {}),
};
}
export function buildVideoMirrorEvent({
pubkey,
created_at,
tags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR);
const combinedTags = [];
appendSchemaTags(combinedTags, schema);
if (Array.isArray(tags)) {
tags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 1063,
pubkey,
created_at,
tags: combinedTags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildRelayListEvent({
pubkey,
created_at,
relays = [],
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
const tags = [];
const relayTagName = schema?.relayTagName || "r";
const readMarker = schema?.readMarker || "read";
const writeMarker = schema?.writeMarker || "write";
if (Array.isArray(relays)) {
relays.forEach((entry) => {
let url = "";
let mode = "";
if (typeof entry === "string") {
url = entry;
} else if (entry && typeof entry === "object") {
if (typeof entry.url === "string") {
url = entry.url;
}
if (typeof entry.mode === "string") {
mode = entry.mode;
} else if (typeof entry.marker === "string") {
mode = entry.marker;
} else if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
} else if (Array.isArray(entry) && entry.length >= 1) {
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
if (entry.length > 1 && typeof entry[1] === "string") {
mode = entry[1];
}
}
const normalizedUrl = typeof url === "string" ? url.trim() : "";
if (!normalizedUrl) {
return;
}
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
if (normalizedMode === "read") {
tags.push([relayTagName, normalizedUrl, readMarker]);
} else if (normalizedMode === "write") {
tags.push([relayTagName, normalizedUrl, writeMarker]);
} else {
tags.push([relayTagName, normalizedUrl]);
}
});
}
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
return {
kind: schema?.kind ?? 10002,
pubkey,
created_at,
tags,
content: "",
};
}
export function buildViewEvent({
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
dedupeTag,
includeSessionTag = false,
additionalTags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
const tags = [];
if (schema?.topicTag?.name && schema?.topicTag?.value) {
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
const pointerTagName = schema?.pointerTagName;
if (pointerValue && pointerTagName) {
tags.push([pointerTagName, pointerValue]);
}
const normalizedPointerTags = [];
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
normalizedPointerTags.push(
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
normalizedPointerTags.push(
tag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
});
}
normalizedPointerTags.forEach((tag) => {
tags.push(tag);
});
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
if (dedupeTag && schema?.identifierTag?.name) {
const identifierName = schema.identifierTag.name;
const hasDedupe = tags.some(
(tag) => tag[0] === identifierName && tag[1] === dedupeTag
);
if (!hasDedupe) {
tags.push([identifierName, dedupeTag]);
}
}
if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) {
const hasSession = tags.some(
(tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value
);
if (!hasSession) {
tags.push([schema.sessionTag.name, schema.sessionTag.value]);
}
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
return {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: resolvedContent,
};
}
export function buildWatchHistoryIndexEvent({
pubkey,
created_at,
snapshotId,
totalChunks,
chunkAddresses = [],
additionalTags = [],
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || WATCH_HISTORY_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
const snapshotTagName = schema?.snapshotTagName || "snapshot";
if (snapshotTagName && snapshotId) {
tags.push([snapshotTagName, snapshotId]);
}
const totalTagName = schema?.totalTagName || "chunks";
if (totalTagName && Number.isFinite(totalChunks)) {
tags.push([totalTagName, String(Math.max(0, Math.floor(totalChunks)))]);
}
const pointerTagName = schema?.chunkPointerTagName || "a";
chunkAddresses.forEach((address) => {
if (typeof address === "string" && address) {
tags.push([pointerTagName, address]);
}
});
appendSchemaTags(tags, schema);
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
let resolvedContent = content;
if (resolvedContent === undefined) {
const payload = {};
if (snapshotId) {
payload.snapshot = snapshotId;
}
if (Number.isFinite(totalChunks)) {
payload.totalChunks = Math.max(0, Math.floor(totalChunks));
}
resolvedContent = JSON.stringify(payload);
} else if (typeof resolvedContent !== "string") {
resolvedContent = JSON.stringify(resolvedContent ?? {});
}
return {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content:
typeof resolvedContent === "string"
? resolvedContent
: String(resolvedContent ?? ""),
};
}
export function buildWatchHistoryChunkEvent({
pubkey,
created_at,
chunkIdentifier,
snapshotId,
chunkIndex,
totalChunks,
pointerTags = [],
chunkAddresses = [],
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = chunkIdentifier || schema?.identifierTag?.value;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) {
tags.push([schema.encryptionTag.name, schema.encryptionTag.value]);
}
const snapshotTagName = schema?.snapshotTagName;
if (snapshotTagName && snapshotId) {
tags.push([snapshotTagName, snapshotId]);
}
const chunkTagName = schema?.chunkTagName;
if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") {
tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]);
}
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) {
const insertionIndex = Number.isInteger(schema?.headTagIndex)
? Math.max(0, Math.min(tags.length, schema.headTagIndex))
: Math.min(tags.length, 2);
tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]);
chunkAddresses.forEach((address) => {
if (typeof address === "string" && address) {
tags.push(["a", address]);
}
});
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? WATCH_HISTORY_KIND,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildSubscriptionListEvent({
pubkey,
created_at,
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30002,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
export function buildBlockListEvent({
pubkey,
created_at,
content,
}) {
const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST);
const tags = [];
const identifierName = schema?.identifierTag?.name || "d";
const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER;
if (identifierName && identifierValue) {
tags.push([identifierName, identifierValue]);
}
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30002,
pubkey,
created_at,
tags,
content: typeof content === "string" ? content : String(content ?? ""),
};
}
function resolveAdminNoteType(listKey) {
switch (listKey) {
case "moderation":
case "editors":
return NOTE_TYPES.ADMIN_MODERATION_LIST;
case "whitelist":
return NOTE_TYPES.ADMIN_WHITELIST;
case "blacklist":
return NOTE_TYPES.ADMIN_BLACKLIST;
default:
return null;
}
}
export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) {
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
if (!schema) {
if (isDevMode) {
console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`);
}
return {
kind: 30000,
pubkey,
created_at,
tags: [],
content: "",
};
}
const tags = [];
if (schema?.identifierTag?.name && schema?.identifierTag?.value) {
tags.push([schema.identifierTag.name, schema.identifierTag.value]);
}
const participantTagName = schema?.participantTagName || "p";
hexPubkeys.forEach((hex) => {
if (typeof hex === "string" && hex.trim()) {
tags.push([participantTagName, hex.trim()]);
}
});
appendSchemaTags(tags, schema);
return {
kind: schema?.kind ?? 30000,
pubkey,
created_at,
tags,
content: "",
};
}
if (typeof window !== "undefined") {
window.BitVidNostrEvents = {
NOTE_TYPES,
getSchema: getNostrEventSchema,
getAllSchemas: getAllNostrEventSchemas,
setOverrides: setNostrEventSchemaOverrides,
};
}
+202
View File
@@ -0,0 +1,202 @@
import { isDevMode } from "./config.js";
export const RELAY_PUBLISH_TIMEOUT_MS = 10_000;
function normalizeReason(error) {
if (error instanceof Error) {
return error.message || "publish failed";
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
if (error === undefined || error === null) {
return "publish failed";
}
try {
return JSON.stringify(error);
} catch (_) {
return String(error);
}
}
export class RelayPublishError extends Error {
constructor(message, results = [], options = {}) {
const finalMessage =
typeof message === "string" && message.trim()
? message.trim()
: "Failed to publish event to any relay.";
super(finalMessage);
this.name = "RelayPublishError";
const contextValue =
typeof options.context === "string" && options.context.trim()
? options.context.trim()
: null;
if (contextValue) {
this.context = contextValue;
}
this.relayResults = Array.isArray(results)
? results.map((result) => ({
url: result?.url || "",
success: !!result?.success,
error: result?.error || null,
reason: normalizeReason(result?.error),
}))
: [];
this.relayFailures = this.relayResults
.filter((entry) => !entry.success)
.map((entry) => ({
url: entry.url,
error: entry.error,
reason: entry.reason,
}));
this.acceptedRelays = this.relayResults
.filter((entry) => entry.success)
.map((entry) => entry.url);
}
}
export function publishEventToRelay(pool, url, event, options = {}) {
const timeoutMs =
typeof options.timeoutMs === "number" && options.timeoutMs > 0
? options.timeoutMs
: RELAY_PUBLISH_TIMEOUT_MS;
return new Promise((resolve) => {
let settled = false;
const finalize = (success, error = null) => {
if (settled) {
return;
}
settled = true;
resolve({ url, success, error });
};
const timeoutId = setTimeout(() => {
finalize(false, new Error("publish timeout"));
}, timeoutMs);
try {
const pub = pool?.publish?.([url], event);
if (pub && typeof pub.on === "function") {
const registerHandler = (eventName, handler) => {
try {
pub.on(eventName, handler);
return true;
} catch (error) {
if (isDevMode) {
console.warn(
`[nostrPublish] Relay publish handle rejected ${eventName} listener:`,
error,
);
}
return false;
}
};
const handleSuccess = () => {
clearTimeout(timeoutId);
finalize(true);
};
const handleFailure = (reason) => {
clearTimeout(timeoutId);
const err =
reason instanceof Error
? reason
: new Error(String(reason || "publish failed"));
finalize(false, err);
};
let handlerRegistered = false;
handlerRegistered =
registerHandler("ok", handleSuccess) || handlerRegistered;
handlerRegistered =
registerHandler("failed", handleFailure) || handlerRegistered;
// [email protected] removed the optional "seen" callback from relay
// publish handles. Attempting to register it now produces an
// asynchronous TypeError that bubbles up as an unhandled rejection and
// prevents watch-history snapshots from ever resolving. We therefore
// skip registering a "seen" listener and rely on "ok" (per NIP-20) for
// success notifications. Legacy relays that only emit "seen" fall back
// to the optimistic success path below because no handlers end up
// registered.
if (handlerRegistered) {
return;
}
}
if (pub && typeof pub.then === "function") {
pub
.then(() => {
clearTimeout(timeoutId);
finalize(true);
})
.catch((error) => {
clearTimeout(timeoutId);
finalize(false, error);
});
return;
}
} catch (error) {
clearTimeout(timeoutId);
finalize(false, error);
return;
}
clearTimeout(timeoutId);
finalize(true);
});
}
export function publishEventToRelays(pool, urls, event, options = {}) {
const list = Array.isArray(urls) ? urls : [];
return Promise.all(
list.map((url) => publishEventToRelay(pool, url, event, options)),
);
}
export function summarizePublishResults(results = []) {
const accepted = [];
const failed = [];
for (const result of results) {
if (result?.success) {
accepted.push(result);
} else {
failed.push(result);
}
}
return { accepted, failed };
}
export function assertAnyRelayAccepted(results = [], options = {}) {
const summary = summarizePublishResults(results);
if (summary.accepted.length > 0) {
return summary;
}
const contextValue =
typeof options.context === "string" && options.context.trim()
? options.context.trim()
: "";
const messageOverride =
typeof options.message === "string" && options.message.trim()
? options.message.trim()
: "";
const message =
messageOverride ||
(contextValue
? `Failed to publish ${contextValue} to any relay.`
: "Failed to publish event to any relay.");
throw new RelayPublishError(message, results, { context: contextValue });
}
+565
View File
@@ -0,0 +1,565 @@
import { isDevMode } from "./config.js";
import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js";
import { buildRelayListEvent } from "./nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
const MODE_SEQUENCE = ["both", "read", "write"];
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (!/^[0-9a-f]{64}$/i.test(trimmed)) {
return null;
}
return trimmed.toLowerCase();
}
function normalizeRelayUrl(input) {
if (typeof input !== "string") {
return null;
}
let candidate = input.trim();
if (!candidate) {
return null;
}
if (!/^[a-z]+:\/\//i.test(candidate)) {
candidate = `wss://${candidate}`;
}
let parsed;
try {
parsed = new URL(candidate);
} catch (error) {
return null;
}
if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") {
return null;
}
const hostname = parsed.hostname.toLowerCase();
const port = parsed.port ? `:${parsed.port}` : "";
let pathname = parsed.pathname || "";
if (pathname !== "/") {
pathname = pathname.replace(/\/+$/, "");
}
if (pathname === "/") {
pathname = "";
}
const search = parsed.search || "";
const hash = parsed.hash || "";
return `${parsed.protocol}//${hostname}${port}${pathname}${search}${hash}`;
}
function deriveMode(marker) {
if (typeof marker !== "string") {
return "both";
}
const normalized = marker.trim().toLowerCase();
if (normalized === "read") {
return "read";
}
if (normalized === "write") {
return "write";
}
return "both";
}
function createEntry(url, mode = "both") {
const normalizedMode = deriveMode(mode);
return {
url,
mode: normalizedMode,
read: normalizedMode !== "write",
write: normalizedMode !== "read",
};
}
function cloneEntry(entry) {
return {
url: entry.url,
mode: entry.mode,
read: !!entry.read,
write: !!entry.write,
};
}
function serializeEntries(entries) {
return JSON.stringify(
entries.map((entry) => ({ url: entry.url, mode: entry.mode }))
);
}
function parseRelayTags(tags) {
if (!Array.isArray(tags)) {
return [];
}
const seen = new Map();
const order = [];
tags.forEach((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return;
}
if (tag[0] !== "r") {
return;
}
const normalizedUrl = normalizeRelayUrl(tag[1]);
if (!normalizedUrl) {
return;
}
if (!seen.has(normalizedUrl)) {
seen.set(normalizedUrl, { read: false, write: false });
order.push(normalizedUrl);
}
const record = seen.get(normalizedUrl);
const mode = deriveMode(tag[2]);
if (mode === "read") {
record.read = true;
} else if (mode === "write") {
record.write = true;
} else {
record.read = true;
record.write = true;
}
});
return order.map((url) => {
const record = seen.get(url) || { read: true, write: true };
if (record.read && record.write) {
return createEntry(url, "both");
}
if (record.read) {
return createEntry(url, "read");
}
if (record.write) {
return createEntry(url, "write");
}
return createEntry(url, "both");
});
}
function resolveEntryInput(entry) {
if (typeof entry === "string") {
return { url: entry, mode: "both" };
}
if (!entry || typeof entry !== "object") {
return null;
}
const url = typeof entry.url === "string" ? entry.url : "";
let mode = entry.mode || entry.marker;
if (!mode) {
if (entry.read === true && entry.write === false) {
mode = "read";
} else if (entry.write === true && entry.read === false) {
mode = "write";
}
}
return { url, mode: deriveMode(mode) };
}
class RelayPreferencesManager {
constructor() {
this.entries = [];
this.entryIndex = new Map();
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
this.defaultEntries = DEFAULT_RELAY_URLS.map((url) => {
const normalized = normalizeRelayUrl(url) || url;
return createEntry(normalized, "both");
});
this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true });
}
snapshot() {
return this.getEntries();
}
getEntries() {
return this.entries.map((entry) => cloneEntry(entry));
}
getAllRelayUrls() {
return this.entries.map((entry) => entry.url);
}
getReadRelayUrls() {
return this.entries.filter((entry) => entry.read).map((entry) => entry.url);
}
getWriteRelayUrls() {
return this.entries.filter((entry) => entry.write).map((entry) => entry.url);
}
syncClient() {
if (nostrClient && typeof nostrClient.applyRelayPreferences === "function") {
nostrClient.applyRelayPreferences({
all: this.getAllRelayUrls(),
read: this.getReadRelayUrls(),
write: this.getWriteRelayUrls(),
});
} else if (nostrClient) {
nostrClient.relays = this.getAllRelayUrls();
}
}
setEntries(entries, { allowEmpty = false, updateClient = true } = {}) {
const nextEntries = [];
const indexMap = new Map();
if (Array.isArray(entries)) {
entries.forEach((item) => {
const resolved = resolveEntryInput(item);
if (!resolved) {
return;
}
const normalizedUrl = normalizeRelayUrl(resolved.url);
if (!normalizedUrl || indexMap.has(normalizedUrl)) {
return;
}
const normalizedEntry = createEntry(normalizedUrl, resolved.mode);
indexMap.set(normalizedUrl, nextEntries.length);
nextEntries.push(normalizedEntry);
});
}
if (!nextEntries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (updateClient) {
this.syncClient();
}
return this.getEntries();
}
this.entries = nextEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
if (!this.entries.length && !allowEmpty) {
this.entries = this.defaultEntries.map((entry) => cloneEntry(entry));
this.entryIndex = new Map(
this.entries.map((entry, idx) => [entry.url, idx])
);
}
if (updateClient) {
this.syncClient();
}
return this.getEntries();
}
addRelay(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid WSS relay URL.");
error.code = "invalid";
throw error;
}
if (this.entryIndex.has(normalizedUrl)) {
return {
changed: false,
reason: "duplicate",
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
const nextEntries = this.getEntries();
nextEntries.push({ url: normalizedUrl, mode });
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
updateRelayMode(url, mode = "both") {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
const normalizedMode = deriveMode(mode);
const index = this.entryIndex.get(normalizedUrl);
const current = this.entries[index];
if (current.mode === normalizedMode) {
return { changed: false, entry: cloneEntry(current) };
}
const nextEntries = this.getEntries();
nextEntries[index].mode = normalizedMode;
this.setEntries(nextEntries, { allowEmpty: false });
return {
changed: true,
entry: cloneEntry(this.entries[this.entryIndex.get(normalizedUrl)]),
};
}
cycleRelayMode(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
const error = new Error("Relay not found.");
error.code = "missing";
throw error;
}
const index = this.entryIndex.get(normalizedUrl);
const currentMode = this.entries[index].mode || "both";
const currentIdx = MODE_SEQUENCE.indexOf(currentMode);
const nextMode = MODE_SEQUENCE[(currentIdx + 1) % MODE_SEQUENCE.length];
return this.updateRelayMode(normalizedUrl, nextMode);
}
removeRelay(url) {
const normalizedUrl = normalizeRelayUrl(url);
if (!normalizedUrl) {
const error = new Error("Enter a valid relay URL to remove.");
error.code = "invalid";
throw error;
}
if (!this.entryIndex.has(normalizedUrl)) {
return { changed: false, reason: "missing" };
}
if (this.entries.length <= 1) {
const error = new Error("At least one relay is required.");
error.code = "minimum";
throw error;
}
const nextEntries = this.getEntries().filter((entry) => entry.url !== normalizedUrl);
this.setEntries(nextEntries, { allowEmpty: false });
return { changed: true };
}
restoreDefaults() {
const before = serializeEntries(this.entries);
this.setEntries(this.defaultEntries, { allowEmpty: false });
const after = serializeEntries(this.entries);
return { changed: before !== after };
}
reset() {
this.lastEvent = null;
this.loadedPubkey = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
}
getPublishTargets(customTargets = null) {
const targets = new Set();
const list =
Array.isArray(customTargets) && customTargets.length
? customTargets
: this.getAllRelayUrls();
list.forEach((candidate) => {
const normalized = normalizeRelayUrl(candidate);
if (normalized) {
targets.add(normalized);
}
});
DEFAULT_RELAY_URLS.forEach((url) => {
const normalized = normalizeRelayUrl(url) || url;
targets.add(normalized);
});
return Array.from(targets);
}
async loadRelayList(pubkey) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
this.loadedPubkey = normalizedPubkey;
if (!normalizedPubkey) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "invalid-pubkey" };
}
if (!nostrClient?.pool) {
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: false, reason: "nostr-uninitialized" };
}
const filter = { kinds: [10002], authors: [normalizedPubkey], limit: 1 };
const targetRelays = this.getPublishTargets();
const events = [];
await Promise.all(
targetRelays.map(async (relayUrl) => {
try {
const result = await nostrClient.pool.list([relayUrl], [filter]);
if (Array.isArray(result) && result.length) {
events.push(...result);
}
} catch (error) {
if (isDevMode) {
console.warn(`[relayManager] Failed to fetch relay list from ${relayUrl}:`, error);
}
}
})
);
if (!events.length) {
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
events.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
const newest = events[0];
this.lastEvent = newest;
const parsed = parseRelayTags(newest?.tags);
if (parsed.length) {
this.setEntries(parsed, { allowEmpty: false });
this.lastLoadSource = "event";
return { ok: true, source: "event", event: newest };
}
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", event: newest };
}
async publishRelayList(pubkey, options = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey) {
const error = new Error(
"A valid pubkey is required to publish relay preferences."
);
error.code = "invalid-pubkey";
throw error;
}
if (!window?.nostr?.signEvent) {
const error = new Error(
"A NIP-07 extension is required to publish relay preferences."
);
error.code = "nostr-extension-missing";
throw error;
}
if (!nostrClient?.pool) {
const error = new Error(
"Nostr is not connected yet. Please try again once relays are ready."
);
error.code = "nostr-uninitialized";
throw error;
}
const entries = this.getEntries();
if (!entries.length) {
const error = new Error("Add at least one relay before publishing.");
error.code = "empty";
throw error;
}
const event = buildRelayListEvent({
pubkey: normalizedPubkey,
created_at: Math.floor(Date.now() / 1000),
relays: entries,
});
const signedEvent = await window.nostr.signEvent(event);
const targets = this.getPublishTargets(options?.relayUrls);
if (!targets.length) {
const error = new Error("No relay targets are available for publishing.");
error.code = "no-targets";
throw error;
}
const publishResults = await publishEventToRelays(
nostrClient.pool,
targets,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "relay preferences update",
message: "No relays accepted the update.",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`,
relayError
);
});
}
const accepted = publishSummary.accepted.map(({ url }) => url);
const failed = publishSummary.failed.map(({ url, error: relayError }) => ({
url,
reason: relayError || null,
}));
this.lastEvent = signedEvent;
return { ok: true, event: signedEvent, accepted, failed, targets };
}
getLastLoadSource() {
return this.lastLoadSource;
}
}
export const relayManager = new RelayPreferencesManager();
export function normalizeRelayUrlForDisplay(value) {
return normalizeRelayUrl(value);
}
+33
View File
@@ -0,0 +1,33 @@
// js/sidebarLoading.js
function escapeHtml(value) {
if (typeof value !== "string") {
return "";
}
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function getSidebarLoadingMarkup(message = "", options = {}) {
const trimmed = typeof message === "string" ? message.trim() : "";
const copy = trimmed || "Fetching data…";
const safeCopy = escapeHtml(copy);
const showSpinner = options?.showSpinner !== false;
const spinnerMarkup = showSpinner
? '<span class="status-spinner status-spinner--inline" aria-hidden="true"></span>'
: "";
return `
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
${spinnerMarkup}
<span class="sidebar-loading-text">${safeCopy}</span>
</div>
</div>
`;
}
+112 -25
View File
@@ -3,8 +3,61 @@ import {
nostrClient,
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import {
buildSubscriptionListEvent,
SUBSCRIPTION_LIST_IDENTIFIER,
} from "./nostrEventSchemas.js";
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
let attachHealthBadgesLoader = null;
let attachHealthBadgesErrorLogged = false;
function scheduleAttachHealthBadges(container) {
if (!container) {
return;
}
if (!attachHealthBadgesLoader) {
attachHealthBadgesLoader = import("./gridHealth.js")
.then((module) => module?.attachHealthBadges || null)
.catch((error) => {
if (!attachHealthBadgesErrorLogged) {
attachHealthBadgesErrorLogged = true;
if (typeof console !== "undefined") {
console.warn(
"[SubscriptionsManager] Failed to load grid health helpers:",
error
);
}
}
return null;
});
}
attachHealthBadgesLoader
.then((fn) => {
if (typeof fn === "function") {
try {
fn(container);
} catch (error) {
if (typeof console !== "undefined") {
console.warn(
"[SubscriptionsManager] attachHealthBadges threw:",
error
);
}
}
}
})
.catch(() => {
// Errors are already logged above; no-op here to keep Promise chains quiet.
});
}
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
@@ -58,7 +111,7 @@ class SubscriptionsManager {
const filter = {
kinds: [30002],
authors: [userPubkey],
"#d": ["subscriptions"],
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
limit: 1,
};
@@ -168,33 +221,68 @@ class SubscriptionsManager {
throw err;
}
const evt = {
kind: 30002,
const evt = buildSubscriptionListEvent({
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", "subscriptions"]],
content: cipherText,
};
});
let signedEvent;
try {
const signedEvent = await window.nostr.signEvent(evt);
await Promise.all(
nostrClient.relays.map(async (relay) => {
try {
await nostrClient.pool.publish([relay], signedEvent);
} catch (e) {
signedEvent = await window.nostr.signEvent(evt);
} catch (signErr) {
console.error("Failed to sign subscription list:", signErr);
throw signErr;
}
const publishResults = await publishEventToRelays(
nostrClient.pool,
nostrClient.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "subscription list",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[SubscriptionsManager] Failed to publish to ${relay}`,
e
`[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`,
relayError || reason
);
}
})
);
this.subsEventId = signedEvent.id;
console.log("Subscription list published, event id:", signedEvent.id);
} catch (signErr) {
console.error("Failed to sign/publish subscription list:", signErr);
);
}
throw publishError;
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`,
relayError
);
});
}
this.subsEventId = signedEvent.id;
const acceptedUrls = publishSummary.accepted.map(({ url }) => url);
console.log(
"Subscription list published, event id:",
signedEvent.id,
"accepted relays:",
acceptedUrls
);
}
/**
@@ -224,6 +312,8 @@ class SubscriptionsManager {
return;
}
container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…");
// Gather all videos
const videos = await this.fetchSubscribedVideos(channelHexes);
this.renderSameGridStyle(videos, containerId);
@@ -604,7 +694,7 @@ class SubscriptionsManager {
});
container.appendChild(fragment);
attachHealthBadges(container);
scheduleAttachHealthBadges(container);
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
if (!window.app?.handleUrlHealthBadge) {
return;
@@ -614,10 +704,7 @@ class SubscriptionsManager {
window.app.handleUrlHealthBadge({ video, url, badgeEl });
});
if (window.app) {
window.app.videoList = container;
window.app.attachVideoListHandler?.();
}
window.app?.attachVideoListHandler?.();
// Lazy-load
const lazyEls = container.querySelectorAll("[data-lazy]");
+22 -76
View File
@@ -1,53 +1,23 @@
const containerState = new WeakMap();
import { createCardObserver } from "./dom/cardObserver.js";
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
const ROOT_MARGIN = "0px";
const THRESHOLD = 0.25;
function decodeUrl(value) {
if (typeof value !== "string" || !value) {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
function ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const observedCards = new WeakSet();
const observer = new IntersectionObserver(
(entries) => {
processEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD }
);
state = {
observer,
observedCards,
onCheck: null,
};
containerState.set(container, state);
return state;
}
function processEntries(entries, state) {
if (!state?.onCheck || !Array.isArray(entries) || !entries.length) {
return;
}
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
return;
const urlCardObserver = createCardObserver({
rootMargin: ROOT_MARGIN,
threshold: THRESHOLD,
isCardVisible: (entry) => {
if (!entry) {
return false;
}
const card = entry.target;
if (!(card instanceof HTMLElement)) {
const isIntersecting = Boolean(entry.isIntersecting);
const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
return isIntersecting && ratio > 0;
},
createState: () => ({ onCheck: null }),
onCardVisible: ({ card, state }) => {
if (!state || typeof state.onCheck !== "function") {
return;
}
@@ -71,7 +41,7 @@ function processEntries(entries, state) {
return;
}
const url = decodeUrl(encodedUrl);
const url = safeDecodeURIComponent(encodedUrl);
if (!url) {
return;
}
@@ -81,44 +51,20 @@ function processEntries(entries, state) {
} catch (err) {
console.warn("[urlHealthObserver] onCheck handler failed", err);
}
});
}
},
});
export function attachUrlHealthBadges(container, onCheck) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = ensureState(container);
if (typeof onCheck === "function") {
const state = urlCardObserver.observe(container);
if (state && typeof onCheck === "function") {
state.onCheck = onCheck;
}
const cards = container.querySelectorAll(".video-card");
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
});
const records = state.observer.takeRecords();
if (records.length) {
processEntries(records, state);
}
}
export function refreshUrlHealthBadges(container) {
const state = containerState.get(container);
if (!state) {
return;
}
const records = state.observer.takeRecords();
if (records.length) {
processEntries(records, state);
}
urlCardObserver.refresh(container);
}
+45 -14
View File
@@ -1,7 +1,10 @@
// js/userBlocks.js
import { nostrClient } from "./nostr.js";
const BLOCK_LIST_IDENTIFIER = "user-blocks";
import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js";
import {
publishEventToRelays,
assertAnyRelayAccepted,
} from "./nostrPublish.js";
function normalizeHex(pubkey) {
if (typeof pubkey !== "string") {
@@ -250,26 +253,54 @@ class UserBlockListManager {
throw err;
}
const event = {
kind: 30002,
const event = buildBlockListEvent({
pubkey: normalized,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", BLOCK_LIST_IDENTIFIER]],
content: cipherText,
};
});
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(
nostrClient.relays.map(async (relay) => {
try {
await nostrClient.pool.publish([relay], signedEvent);
} catch (err) {
console.error(`[UserBlockList] Failed to publish to ${relay}:`, err);
}
})
const publishResults = await publishEventToRelays(
nostrClient.pool,
nostrClient.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "block list",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[UserBlockList] Block list rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[UserBlockList] Block list not accepted by ${url}: ${reason}`,
relayError
);
});
}
this.blockEventId = signedEvent.id;
return signedEvent;
}
+32
View File
@@ -0,0 +1,32 @@
export function safeDecodeURIComponent(value) {
if (typeof value !== "string") {
return "";
}
if (value === "") {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
export function safeDecodeURIComponentLoose(value, { trim = true } = {}) {
if (typeof value !== "string") {
return "";
}
const working = trim ? value.trim() : value;
if (trim && !working) {
return "";
}
try {
return decodeURIComponent(working);
} catch (err) {
return working;
}
}
+643
View File
@@ -0,0 +1,643 @@
/**
* @module viewCounter
* @description Public API for tracking and formatting BitVid video view totals.
*
* The module exposes lifecycle helpers:
* - {@link initViewCounter} to prime the counter with the active Nostr client.
* - {@link subscribeToVideoViewCount} / {@link unsubscribeFromVideoViewCount}
* to observe live totals for an `a` or `e` pointer.
* - {@link ingestLocalViewEvent} so playback flows can optimistically update
* counts immediately after publishing a view event.
* - {@link formatViewCount} for rendering localized view totals in the UI.
*/
import {
VIEW_COUNT_BACKFILL_MAX_DAYS,
VIEW_COUNT_CACHE_TTL_MS,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
} from "./config.js";
import {
countVideoViewEvents,
listVideoViewEvents,
subscribeVideoViewEvents,
} from "./nostr.js";
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
const STORAGE_DEBOUNCE_MS = 500;
const SECONDS_PER_DAY = 86_400;
/** @type {Map<string, ViewCounterState>} */
const pointerStates = new Map();
/** @type {Map<string, PointerListeners>} */
const pointerListeners = new Map();
let persistTimer = null;
let nextTokenId = 1;
let nostrClientRef = null;
restoreCacheSnapshot();
/**
* @typedef {Object} ViewCounterState
* @property {number} total
* @property {Map<string, number>} dedupeBuckets
* @property {number} lastSyncedAt
* @property {"idle"|"hydrating"|"live"} status
*/
/**
* @typedef {Object} PointerListeners
* @property {{ type: string, value: string, relay?: string | null }} pointer
* @property {Map<string, Function>} handlers
* @property {(() => void) | null} liveUnsub
* @property {Promise<void> | null} hydrationPromise
* @property {Object | null} options
*/
/**
* Persist the in-memory cache to localStorage after a short debounce.
*/
function schedulePersist() {
if (!supportsStorage()) {
return;
}
if (persistTimer) {
clearTimeout(persistTimer);
}
persistTimer = setTimeout(() => {
persistTimer = null;
persistCacheSnapshot();
}, STORAGE_DEBOUNCE_MS);
}
/**
* Convert a raw pointer input into the canonical key and normalized descriptor.
*
* @param {string | { type?: string, value?: string, relay?: string | null } | { tag?: any }} input
* @returns {{ key: string, pointer: { type: string, value: string, relay?: string | null } }}
*/
function canonicalizePointer(input) {
const normalized = normalizePointer(input);
if (!normalized) {
throw new Error("Invalid pointer supplied for view counting.");
}
const type = normalized.type === "a" ? "a" : "e";
const value = typeof normalized.value === "string" ? normalized.value.trim() : "";
if (!value) {
throw new Error("Invalid pointer supplied for view counting.");
}
const key = `${type}:${value}`;
const descriptor = { type, value };
if (normalized.relay) {
descriptor.relay = normalized.relay;
}
return { key, pointer: descriptor };
}
/**
* Mirror the pointer normalization logic used inside the Nostr client.
*
* @param {any} pointer
* @returns {{ type: string, value: string, relay?: string | null } | null}
*/
function normalizePointer(pointer) {
if (!pointer) {
return null;
}
if (Array.isArray(pointer)) {
return normalizePointerTag(pointer);
}
if (typeof pointer === "object") {
if (typeof pointer.type === "string" && typeof pointer.value === "string") {
return clonePointer(pointer);
}
if (Array.isArray(pointer.tag)) {
return normalizePointerTag(pointer.tag);
}
}
if (typeof pointer !== "string") {
return null;
}
const trimmed = pointer.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("naddr") || trimmed.startsWith("nevent")) {
const decoder = typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "naddr" && decoded.data) {
const { kind, pubkey, identifier, relays } = decoded.data;
if (
typeof kind === "number" &&
typeof pubkey === "string" &&
typeof identifier === "string"
) {
const relay = Array.isArray(relays) && relays.length && typeof relays[0] === "string"
? relays[0]
: null;
return {
type: "a",
value: `${kind}:${pubkey}:${identifier}`,
relay,
};
}
}
if (decoded?.type === "nevent" && decoded.data) {
const { id, relays } = decoded.data;
if (typeof id === "string" && id.trim()) {
const relay = Array.isArray(relays) && relays.length && typeof relays[0] === "string"
? relays[0]
: null;
return {
type: "e",
value: id.trim(),
relay,
};
}
}
} catch (error) {
console.warn("[viewCounter] Failed to decode NIP-19 pointer:", error);
}
}
}
const type = trimmed.includes(":") ? "a" : "e";
return { type, value: trimmed, relay: null };
}
function clonePointer(pointer) {
const type = pointer.type === "a" ? "a" : "e";
const value = typeof pointer.value === "string" ? pointer.value.trim() : "";
if (!value) {
return null;
}
const relay = typeof pointer.relay === "string" ? pointer.relay : null;
return relay ? { type, value, relay } : { type, value };
}
function normalizePointerTag(tag) {
if (!Array.isArray(tag) || tag.length < 2) {
return null;
}
const [label, value, relay] = tag;
if (label === "a" && typeof value === "string") {
const normalized = value.trim();
if (!normalized) {
return null;
}
const relayValue = typeof relay === "string" ? relay : null;
return relayValue ? { type: "a", value: normalized, relay: relayValue } : { type: "a", value: normalized };
}
if (label === "e" && typeof value === "string") {
const normalized = value.trim();
if (!normalized) {
return null;
}
const relayValue = typeof relay === "string" ? relay : null;
return relayValue ? { type: "e", value: normalized, relay: relayValue } : { type: "e", value: normalized };
}
return null;
}
function supportsStorage() {
try {
return typeof localStorage !== "undefined";
} catch (error) {
return false;
}
}
function restoreCacheSnapshot() {
if (!supportsStorage()) {
return;
}
try {
const raw = localStorage.getItem(VIEW_COUNTER_STORAGE_KEY);
if (!raw) {
return;
}
const payload = JSON.parse(raw);
if (!payload || payload.version !== 1) {
return;
}
const savedAt = Number(payload.savedAt) || 0;
const now = Date.now();
if (!savedAt || now - savedAt > VIEW_COUNT_CACHE_TTL_MS) {
localStorage.removeItem(VIEW_COUNTER_STORAGE_KEY);
return;
}
const entries = Array.isArray(payload.entries) ? payload.entries : [];
for (const entry of entries) {
if (!Array.isArray(entry) || entry.length !== 2) {
continue;
}
const [key, state] = entry;
if (typeof key !== "string" || !state || typeof state !== "object") {
continue;
}
const total = Number.isFinite(state.total) ? Number(state.total) : 0;
const lastSyncedAt = Number.isFinite(state.lastSyncedAt) ? Number(state.lastSyncedAt) : 0;
const status = typeof state.status === "string" ? state.status : "idle";
const dedupeBuckets = new Map();
if (Array.isArray(state.dedupeBuckets)) {
for (const bucket of state.dedupeBuckets) {
if (!Array.isArray(bucket) || bucket.length < 1) {
continue;
}
const [bucketKey, bucketValue] = bucket;
if (typeof bucketKey === "string") {
dedupeBuckets.set(
bucketKey,
Number.isFinite(bucketValue) ? Number(bucketValue) : 0
);
}
}
} else if (state.dedupeBuckets && typeof state.dedupeBuckets === "object") {
for (const bucketKey of Object.keys(state.dedupeBuckets)) {
const bucketValue = state.dedupeBuckets[bucketKey];
if (typeof bucketKey === "string") {
dedupeBuckets.set(
bucketKey,
Number.isFinite(bucketValue) ? Number(bucketValue) : 0
);
}
}
}
pointerStates.set(key, {
total,
dedupeBuckets,
lastSyncedAt,
status: status === "hydrating" || status === "live" ? "idle" : status,
});
}
} catch (error) {
console.warn("[viewCounter] Failed to restore cached view counts:", error);
}
}
function persistCacheSnapshot() {
if (!supportsStorage()) {
return;
}
try {
const entries = [];
for (const [key, state] of pointerStates.entries()) {
entries.push([
key,
{
total: state.total,
dedupeBuckets: Array.from(state.dedupeBuckets.entries()),
lastSyncedAt: state.lastSyncedAt,
status: state.status,
},
]);
}
const payload = {
version: 1,
savedAt: Date.now(),
entries,
};
localStorage.setItem(VIEW_COUNTER_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("[viewCounter] Failed to persist view count cache:", error);
}
}
function ensurePointerState(key) {
let state = pointerStates.get(key);
if (!state) {
state = {
total: 0,
dedupeBuckets: new Map(),
lastSyncedAt: 0,
status: "idle",
};
pointerStates.set(key, state);
} else if (!(state.dedupeBuckets instanceof Map)) {
state.dedupeBuckets = new Map(
state.dedupeBuckets ? Object.entries(state.dedupeBuckets) : []
);
}
return state;
}
function mergePointerDescriptor(existing, next) {
if (!existing) {
return next;
}
if (!next) {
return existing;
}
if (!existing.relay && next.relay) {
return { ...existing, relay: next.relay };
}
return existing;
}
function ensurePointerListeners(key, pointer) {
let listeners = pointerListeners.get(key);
if (!listeners) {
listeners = {
pointer,
handlers: new Map(),
liveUnsub: null,
hydrationPromise: null,
options: null,
};
pointerListeners.set(key, listeners);
} else if (!listeners.pointer) {
listeners.pointer = pointer;
} else {
listeners.pointer = mergePointerDescriptor(listeners.pointer, pointer);
}
return listeners;
}
function notifyHandlers(key) {
const listeners = pointerListeners.get(key);
if (!listeners || !listeners.handlers.size) {
return;
}
const state = ensurePointerState(key);
const snapshot = {
total: state.total,
lastSyncedAt: state.lastSyncedAt,
status: state.status,
};
for (const handler of listeners.handlers.values()) {
try {
handler(snapshot);
} catch (error) {
console.warn("[viewCounter] View handler threw:", error);
}
}
}
function setPointerStatus(key, status) {
const state = ensurePointerState(key);
if (state.status === status) {
return;
}
state.status = status;
schedulePersist();
notifyHandlers(key);
}
function applyEventToState(key, event) {
if (!event || typeof event !== "object") {
return false;
}
const state = ensurePointerState(key);
const bucketKey = deriveBucketKey(event);
const finalKey = bucketKey || deriveFallbackKey(event);
if (!finalKey) {
return false;
}
const seenBefore = state.dedupeBuckets.has(finalKey);
const createdAt = Number.isFinite(event.created_at)
? Number(event.created_at)
: Math.floor(Date.now() / 1000);
state.dedupeBuckets.set(finalKey, createdAt);
if (seenBefore) {
return false;
}
state.total += 1;
state.lastSyncedAt = Date.now();
return true;
}
function deriveBucketKey(event) {
const pubkey = typeof event?.pubkey === "string" ? event.pubkey : null;
if (!pubkey) {
return null;
}
const createdAt = Number.isFinite(event?.created_at)
? Number(event.created_at)
: Math.floor(Date.now() / 1000);
const windowSize = Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS);
const bucket = Math.floor(createdAt / windowSize);
return `${pubkey}:${bucket}`;
}
function deriveFallbackKey(event) {
if (typeof event?.id === "string" && event.id) {
return `event:${event.id}`;
}
if (typeof event?.sig === "string" && event.sig) {
return `sig:${event.sig}`;
}
return null;
}
function mergeListenerOptions(existing, next) {
if (!existing) {
return next || null;
}
if (!next) {
return existing;
}
return {
...existing,
...next,
};
}
async function hydratePointer(key, listeners) {
const pointer = listeners.pointer;
const options = listeners.options || {};
setPointerStatus(key, "hydrating");
const sinceSeconds = Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
const listPromise = listVideoViewEvents(pointer, {
since: sinceSeconds,
relays: options.relays,
});
const countPromise = countVideoViewEvents(pointer, {
relays: options.relays,
signal: options.signal,
});
let listResult = [];
let countResult = null;
try {
listResult = await listPromise;
} catch (error) {
console.warn("[viewCounter] Failed to hydrate view events via list:", error);
}
try {
countResult = await countPromise;
} catch (error) {
if (error?.name === "AbortError") {
console.warn("[viewCounter] View count hydration aborted:", error);
} else {
console.warn("[viewCounter] Failed to count view events:", error);
}
}
let mutated = false;
if (Array.isArray(listResult)) {
for (const event of listResult) {
mutated = applyEventToState(key, event) || mutated;
}
}
const bestCount = Number.isFinite(countResult?.best?.count)
? Number(countResult.best.count)
: Number.isFinite(countResult?.total)
? Number(countResult.total)
: null;
if (bestCount !== null) {
const state = ensurePointerState(key);
const shouldUpdate =
!countResult?.fallback || bestCount >= state.total || state.total === 0;
if (shouldUpdate && state.total !== bestCount) {
state.total = bestCount;
state.lastSyncedAt = Date.now();
mutated = true;
}
}
if (mutated) {
schedulePersist();
notifyHandlers(key);
} else {
// Even if nothing changed we still update last synced timestamp.
const state = ensurePointerState(key);
state.lastSyncedAt = Date.now();
schedulePersist();
notifyHandlers(key);
}
}
function ensureHydration(key, listeners) {
if (!listeners.hydrationPromise) {
listeners.hydrationPromise = hydratePointer(key, listeners).finally(() => {
listeners.hydrationPromise = null;
if (listeners.liveUnsub) {
setPointerStatus(key, "live");
} else {
setPointerStatus(key, "idle");
}
});
}
}
function ensureLiveSubscription(key, listeners) {
if (listeners.liveUnsub || !listeners.handlers.size) {
return;
}
const pointer = listeners.pointer;
const options = listeners.options || {};
try {
const since = Math.floor(Date.now() / 1000) - Math.max(1, VIEW_COUNT_DEDUPE_WINDOW_SECONDS);
const unsubscribe = subscribeVideoViewEvents(pointer, {
relays: options.relays,
since,
onEvent: (event) => {
const changed = applyEventToState(key, event);
if (changed) {
schedulePersist();
notifyHandlers(key);
}
},
});
listeners.liveUnsub = () => {
try {
unsubscribe();
} catch (error) {
console.warn("[viewCounter] Failed to unsubscribe from view events:", error);
}
listeners.liveUnsub = null;
if (!listeners.hydrationPromise) {
setPointerStatus(key, "idle");
}
};
setPointerStatus(key, "live");
} catch (error) {
console.warn("[viewCounter] Failed to open live view subscription:", error);
}
}
export function initViewCounter({ nostrClient } = {}) {
nostrClientRef = nostrClient || null;
}
export function subscribeToVideoViewCount(pointerInput, handler, options = {}) {
if (typeof handler !== "function") {
throw new Error("A handler function is required to subscribe to view counts.");
}
const { key, pointer } = canonicalizePointer(pointerInput);
const listeners = ensurePointerListeners(key, pointer);
listeners.options = mergeListenerOptions(listeners.options, options);
const token = `token-${nextTokenId++}`;
listeners.handlers.set(token, handler);
const state = ensurePointerState(key);
try {
handler({
total: state.total,
lastSyncedAt: state.lastSyncedAt,
status: state.status,
});
} catch (error) {
console.warn("[viewCounter] Initial handler invocation threw:", error);
}
ensureHydration(key, listeners);
ensureLiveSubscription(key, listeners);
return token;
}
export function unsubscribeFromVideoViewCount(pointerInput, token) {
if (!token) {
return;
}
const { key } = canonicalizePointer(pointerInput);
const listeners = pointerListeners.get(key);
if (!listeners) {
return;
}
listeners.handlers.delete(token);
if (!listeners.handlers.size && listeners.liveUnsub) {
listeners.liveUnsub();
}
}
export function ingestLocalViewEvent({ event, pointer }) {
if (!event || !pointer) {
return;
}
try {
const { key } = canonicalizePointer(pointer);
const changed = applyEventToState(key, event);
if (changed) {
schedulePersist();
notifyHandlers(key);
}
} catch (error) {
console.warn("[viewCounter] Failed to ingest local view event:", error);
}
}
const numberFormatter = typeof Intl !== "undefined" && Intl.NumberFormat
? new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
: null;
const compactFormatter = typeof Intl !== "undefined" && Intl.NumberFormat
? new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 })
: null;
export function formatViewCount(total) {
const value = Number.isFinite(total) ? Number(total) : 0;
if (value < 1000) {
if (numberFormatter) {
return numberFormatter.format(value);
}
return String(value);
}
if (compactFormatter) {
return compactFormatter.format(value);
}
return String(value);
}
+10
View File
@@ -67,6 +67,16 @@ export const viewInitRegistry = {
explore: () => {
console.log("Explore view loaded.");
},
history: async () => {
try {
const module = await import("./historyView.js");
if (typeof module.initHistoryView === "function") {
await module.initHistoryView();
}
} catch (error) {
console.error("Failed to initialize history view:", error);
}
},
/**
* Subscriptions view:
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
// js/watchHistorySort.js
export function toNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function getWatchedAtScore(video) {
if (!video || typeof video !== "object") {
return Number.NEGATIVE_INFINITY;
}
const watchedAt = toNumber(video?.watchHistory?.watchedAt, Number.NaN);
if (Number.isFinite(watchedAt)) {
return watchedAt;
}
const createdAtSeconds = toNumber(video?.created_at, Number.NaN);
if (Number.isFinite(createdAtSeconds)) {
return createdAtSeconds * 1000;
}
return Number.NEGATIVE_INFINITY;
}
export function compareByWatchedAtDesc(a, b) {
const aScore = getWatchedAtScore(a);
const bScore = getWatchedAtScore(b);
if (aScore !== bScore) {
return bScore - aScore;
}
const aCreated = toNumber(a?.created_at, Number.NEGATIVE_INFINITY);
const bCreated = toNumber(b?.created_at, Number.NEGATIVE_INFINITY);
if (aCreated !== bCreated) {
return bCreated - aCreated;
}
const aId = typeof a?.id === "string" ? a.id : "";
const bId = typeof b?.id === "string" ? b.id : "";
return aId.localeCompare(bId);
}
+4 -16
View File
@@ -1,22 +1,10 @@
//js/webtorrent.js
import WebTorrent from "./webtorrent.min.js";
import { WSS_TRACKERS } from "./constants.js";
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
const DEFAULT_PROBE_TRACKERS = Object.freeze([
"wss://tracker.btorrent.xyz",
"wss://tracker.openwebtorrent.com",
]);
function decodeComponentSafe(value) {
if (typeof value !== "string") {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]);
function normalizeTrackerList(trackers) {
const normalized = [];
@@ -75,7 +63,7 @@ function appendProbeTrackers(magnetURI, trackers) {
if (!rawKey || rawKey.trim().toLowerCase() !== "tr") {
return;
}
const decoded = decodeComponentSafe(rawValue).trim().toLowerCase();
const decoded = safeDecodeURIComponent(rawValue).trim().toLowerCase();
if (decoded) {
trackerSet.add(decoded);
}
+169 -124
View File
@@ -1,160 +1,205 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WSS_TRACKERS } from "../js/constants.js";
import { normalizeAndAugmentMagnet, safeDecodeMagnet } from "../js/magnetUtils.js";
import { normalizeAndAugmentMagnet as formNormalize } from "../js/magnet.js";
import {
normalizeAndAugmentMagnet as normalizeMagnetObject,
safeDecodeMagnet,
} from "../js/magnetUtils.js";
import {
extractMagnetHints,
normalizeAndAugmentMagnet as normalizeMagnetString,
} from "../js/magnet.js";
function getParamValues(magnet, key) {
const parsed = new URL(magnet);
return parsed.searchParams.getAll(key);
}
(function testBareInfoHashNormalization() {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const result = normalizeAndAugmentMagnet(infoHash);
assert.ok(result.didChange, "Bare info hash should mark didChange true");
const xtValues = getParamValues(result.magnet, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
const trackerValues = getParamValues(result.magnet, "tr");
function assertDefaultTrackersPresent(magnet) {
const trackers = getParamValues(magnet, "tr");
for (const tracker of WSS_TRACKERS) {
assert.ok(
trackerValues.includes(tracker),
`Expected tracker ${tracker} to be appended`
trackers.includes(tracker),
`Expected default tracker ${tracker} to be present`
);
}
})();
}
(function testEncodedXtDecoding() {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`;
const result = normalizeAndAugmentMagnet(encodedMagnet);
const xtValues = getParamValues(result.magnet, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
})();
(function testDuplicateTrackerFiltering() {
const infoHash = "fedcba9876543210fedcba9876543210fedcba98";
const baseMagnet =
`magnet:?xt=urn:btih:${infoHash}` +
"&tr=wss://tracker.openwebtorrent.com" +
"&tr=WsS://tracker.openwebtorrent.com/" +
"&tr=https://not-allowed.example";
const result = normalizeAndAugmentMagnet(baseMagnet, {
extraTrackers: [
"wss://tracker.fastcast.nz",
"http://tracker.invalid",
"wss://tracker.openwebtorrent.com",
],
});
const trackers = getParamValues(result.magnet, "tr");
const openwebCount = trackers.filter(
(value) => value === "wss://tracker.openwebtorrent.com"
).length;
assert.equal(openwebCount, 1, "Duplicate WSS tracker should be collapsed");
assert.ok(
trackers.includes("wss://tracker.fastcast.nz"),
"Expected extra WSS tracker to be preserved"
);
assert.ok(
!trackers.includes("http://tracker.invalid"),
"Non-WSS tracker candidates should be ignored"
);
})();
(function testWebSeedHandlingWithLogging() {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const messages = [];
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: [
"https://cdn.example.com/video.mp4",
"http://cdn.example.com/legacy.mp4",
"https://cdn.example.com/video.mp4",
],
logger: (msg) => messages.push(msg),
appProtocol: "https:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]);
assert.equal(messages.length, 1, "HTTP seed should have been skipped with a log");
assert.ok(
messages[0].includes("Skipping insecure web seed"),
"Expected skip message for HTTP seed"
);
})();
(function testHttpWebSeedAllowedOnHttpOrigin() {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: "http://cdn.example.com/video.mp4",
appProtocol: "http:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]);
})();
(function testSafeDecodeMagnetHandlesEncodedValues() {
test("safeDecodeMagnet handles encoded values", () => {
const rawMagnet =
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example";
const encodedMagnet = encodeURIComponent(rawMagnet);
assert.equal(
safeDecodeMagnet(encodedMagnet),
rawMagnet,
"Expected percent-encoded magnet to be decoded"
"Percent-encoded magnets should be decoded"
);
})();
});
(function testSafeDecodeMagnetLeavesPlainStringsUntouched() {
test("safeDecodeMagnet leaves plain strings untouched", () => {
const rawMagnet =
"magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd";
assert.equal(
safeDecodeMagnet(rawMagnet),
rawMagnet,
"Plain magnet strings should pass through unchanged"
"Plain magnet strings should pass through"
);
})();
});
(function testFormHelperAugmentsWsXsAndDecodesXt() {
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}`;
const normalized = formNormalize(encodedMagnet, {
ws: "https://cdn.example.com/video-base/",
test("bare hashes normalize consistently across helpers", () => {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const objectResult = normalizeMagnetObject(infoHash);
const stringResult = normalizeMagnetString(infoHash);
assert.ok(objectResult.didChange, "Bare hashes should mark didChange true");
assert.equal(
stringResult,
objectResult.magnet,
"String helper should match object helper output"
);
const xtValues = getParamValues(stringResult, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
assertDefaultTrackersPresent(stringResult);
});
test("legacy %3A payloads decode across helpers", () => {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const encoded = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`;
const objectResult = normalizeMagnetObject(encoded);
const stringResult = normalizeMagnetString(encoded);
assert.equal(
stringResult,
objectResult.magnet,
"Helpers should agree on encoded xt payload normalization"
);
const xtValues = getParamValues(stringResult, "xt");
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
});
test("duplicate trackers and hints stay in sync", () => {
const infoHash = "fedcba9876543210fedcba9876543210fedcba98";
const baseMagnet =
`magnet:?xt=urn:btih:${infoHash}` +
"&tr=wss://tracker.openwebtorrent.com" +
"&tr=WsS://tracker.openwebtorrent.com/";
const objectResult = normalizeMagnetObject(baseMagnet, {
extraTrackers: [
"wss://tracker.fastcast.nz",
"http://tracker.invalid",
"wss://tracker.openwebtorrent.com",
],
xs: "https://cdn.example.com/video.torrent",
webSeed: "https://cdn.example.com/files/",
});
const stringResult = normalizeMagnetString(baseMagnet, {
ws: "https://cdn.example.com/files/", // ensure string helper adds hint
xs: "https://cdn.example.com/video.torrent",
});
const parsed = new URL(normalized);
assert.equal(
parsed.searchParams.get("xt"),
`urn:btih:${infoHash}`,
"Form helper should decode encoded xt payloads"
);
assert.deepEqual(
parsed.searchParams.getAll("ws"),
["https://cdn.example.com/video-base/"],
"Expected ws hint to be appended"
);
assert.deepEqual(
parsed.searchParams.getAll("xs"),
["https://cdn.example.com/video.torrent"],
"Expected xs hint to be appended"
);
const trackers = parsed.searchParams.getAll("tr");
for (const tracker of WSS_TRACKERS) {
assert.ok(
trackers.includes(tracker),
`Expected helper to append tracker ${tracker}`
);
}
})();
(function testFormHelperSkipsInsecureWsOnHttpsOrigin() {
assert.equal(
stringResult,
objectResult.magnet,
"Helpers should emit identical magnets after augmentation"
);
const trackers = getParamValues(stringResult, "tr");
const openwebCount = trackers.filter(
(value) => value === "wss://tracker.openwebtorrent.com"
).length;
assert.equal(openwebCount, 1, "Duplicate trackers should be deduped when appended");
assert.ok(
trackers.includes("wss://tracker.fastcast.nz"),
"Extra WSS tracker should be retained"
);
assert.ok(
!trackers.includes("http://tracker.invalid"),
"HTTP tracker candidates should be ignored"
);
const webSeeds = getParamValues(stringResult, "ws");
assert.deepEqual(webSeeds, ["https://cdn.example.com/files/"]);
const xsValues = getParamValues(stringResult, "xs");
assert.deepEqual(xsValues, ["https://cdn.example.com/video.torrent"]);
});
test("object helper enforces HTTPS web seeds when on HTTPS", () => {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const normalized = formNormalize(`magnet:?xt=urn:btih:${infoHash}`, {
const messages = [];
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: [
"https://cdn.example.com/video.mp4",
"http://cdn.example.com/legacy.mp4",
],
logger: (msg) => messages.push(msg),
appProtocol: "https:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]);
assert.equal(messages.length, 1, "HTTP web seeds should trigger a log message");
assert.ok(
messages[0].includes("Skipping insecure web seed"),
"Expected skip log for HTTP seed"
);
});
test("object helper allows HTTP seeds on HTTP origins", () => {
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: "http://cdn.example.com/video.mp4",
appProtocol: "http:",
});
const seeds = getParamValues(result.magnet, "ws");
assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]);
});
test("extractMagnetHints returns first ws/xs pair", () => {
const magnet =
"magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01" +
"&ws=https://cdn.example.com/base/" +
"&ws=https://cdn.example.com/duplicate/" +
"&xs=https://cdn.example.com/video.torrent";
const hints = extractMagnetHints(magnet);
assert.deepEqual(hints, {
ws: "https://cdn.example.com/base/",
xs: "https://cdn.example.com/video.torrent",
});
});
test("string helper skips insecure ws hints on HTTPS origins", () => {
const infoHash = "0123456789abcdef0123456789abcdef01234567";
const normalized = normalizeMagnetString(`magnet:?xt=urn:btih:${infoHash}`, {
ws: "http://cdn.example.com/video-base/",
});
const parsed = new URL(normalized);
assert.equal(
parsed.searchParams.getAll("ws").length,
0,
"HTTP ws value should be skipped on HTTPS origins"
);
})();
assert.equal(parsed.searchParams.getAll("ws").length, 0);
});
console.log("magnet-utils tests passed");
test("object helper reports didChange when output differs", () => {
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, {
webSeed: "https://cdn.example.com/video.mp4",
});
assert.ok(result.didChange, "Augmenting with web seeds should mark didChange");
});
test("helpers trim fragments from non-magnet values", () => {
const url = "https://example.com/video.mp4#fragment";
const objectResult = normalizeMagnetObject(url);
const stringResult = normalizeMagnetString(url);
assert.equal(objectResult.magnet, "https://example.com/video.mp4");
assert.equal(stringResult, "https://example.com/video.mp4");
assert.ok(
objectResult.didChange,
"Dropping the fragment should be reflected in didChange"
);
});
+215
View File
@@ -0,0 +1,215 @@
// Run with: node tests/nostr-count-fallback.test.mjs
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
const { VIEW_COUNT_BACKFILL_MAX_DAYS } = await import("../js/config.js");
const { nostrClient } = await import("../js/nostr.js");
const NostrClient = nostrClient.constructor;
async function withFrozenTime(timestampSeconds, callback) {
const originalNow = Date.now;
Date.now = () => timestampSeconds * 1000;
try {
return await callback();
} finally {
Date.now = originalNow;
}
}
function getConfiguredHorizonDays() {
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
return Number.isFinite(horizonDaysRaw)
? Math.max(0, Math.floor(horizonDaysRaw))
: 0;
}
async function testFallbackAddsDefaultSinceWhenPoolMissing() {
const client = new NostrClient();
client.pool = null;
const pointer = { type: "e", value: "count-fallback-default" };
const calls = [];
client.listVideoViewEvents = async (_, options = {}) => {
calls.push({ ...options });
return [];
};
const horizonDays = getConfiguredHorizonDays();
const nowSeconds = 1_700_000_000;
await withFrozenTime(nowSeconds, async () => {
const result = await client.countVideoViewEvents(pointer, {});
assert.equal(calls.length, 1, "fallback should issue a single list query");
const [{ since }] = calls;
if (horizonDays > 0) {
const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400);
assert.equal(
since,
expectedSince,
"fallback list should clamp history to the configured horizon"
);
} else {
assert.equal(
Object.prototype.hasOwnProperty.call(calls[0], "since"),
false,
"without a configured horizon the fallback should avoid forcing a since filter"
);
}
assert.equal(result.fallback, true, "fallback branch should be flagged");
});
}
async function testFallbackHonorsCallerOverrides() {
const client = new NostrClient();
client.pool = null;
const pointer = { type: "e", value: "count-fallback-overrides" };
let receivedOptions = null;
client.listVideoViewEvents = async (_, options = {}) => {
receivedOptions = { ...options };
return [];
};
const overrides = {
since: 1_234_567,
until: 1_235_000,
limit: 42,
};
await client.countVideoViewEvents(pointer, overrides);
assert.ok(receivedOptions, "fallback should forward overrides to list");
assert.equal(
receivedOptions.since,
Math.floor(overrides.since),
"caller supplied since should be preserved"
);
assert.equal(
receivedOptions.until,
Math.floor(overrides.until),
"caller supplied until should be preserved"
);
assert.equal(
receivedOptions.limit,
Math.floor(overrides.limit),
"caller supplied limit should be preserved"
);
}
async function testFallbackAfterCountFailureUsesDefaultWindow() {
const client = new NostrClient();
client.pool = {};
client.countEventsAcrossRelays = async () => {
throw new Error("count failed");
};
const pointer = { type: "e", value: "count-fallback-error" };
let receivedOptions = null;
client.listVideoViewEvents = async (_, options = {}) => {
receivedOptions = { ...options };
return [];
};
const horizonDays = getConfiguredHorizonDays();
const nowSeconds = 1_800_000_000;
await withFrozenTime(nowSeconds, async () => {
await client.countVideoViewEvents(pointer, {});
});
assert.ok(receivedOptions, "fallback should invoke the list helper");
if (horizonDays > 0) {
const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400);
assert.equal(
receivedOptions.since,
expectedSince,
"COUNT fallback should restrict scans to the configured horizon"
);
} else {
assert.equal(
Object.prototype.hasOwnProperty.call(receivedOptions, "since"),
false,
"COUNT fallback should avoid forcing a since filter when no horizon is set"
);
}
}
async function testUnsupportedRelaySkipsWarningsAndFallsBack() {
const client = new NostrClient();
const unsupportedRelay = "wss://unsupported.example";
client.relays = [unsupportedRelay];
let ensureRelayCalls = 0;
client.pool = {
ensureRelay: async (url) => {
ensureRelayCalls += 1;
assert.equal(
url,
unsupportedRelay,
"COUNT helper should target the configured relay"
);
return {};
},
};
let listCalls = 0;
client.listVideoViewEvents = async () => {
listCalls += 1;
return [];
};
const originalWarn = console.warn;
const warnCalls = [];
console.warn = (...args) => {
warnCalls.push(args);
};
try {
const pointer = { type: "e", value: "unsupported-relay" };
const result = await client.countVideoViewEvents(pointer, {});
assert.equal(listCalls, 1, "fallback should query list once when COUNT fails");
assert.equal(result?.fallback, true, "unsupported relay should trigger fallback");
assert.ok(
client.countUnsupportedRelays.has(unsupportedRelay),
"unsupported relay should be remembered for future requests"
);
assert.equal(
warnCalls.length,
0,
"unsupported relay error should not emit console warnings"
);
// Subsequent calls should skip the unsupported relay entirely.
await client.countVideoViewEvents(pointer, {});
assert.equal(
ensureRelayCalls,
1,
"unsupported relays should be skipped after the first failure"
);
assert.equal(
warnCalls.length,
0,
"no warnings should be emitted for cached unsupported relays"
);
} finally {
console.warn = originalWarn;
}
}
await testFallbackAddsDefaultSinceWhenPoolMissing();
await testFallbackHonorsCallerOverrides();
await testFallbackAfterCountFailureUsesDefaultWindow();
await testUnsupportedRelaySkipsWarningsAndFallsBack();
console.log("nostr COUNT fallback tests completed successfully.");
+243
View File
@@ -0,0 +1,243 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const originalWindowNostr = window.nostr;
window.nostr = {
...(originalWindowNostr || {}),
signEvent: async (event) => ({
...event,
id: event?.id || `stub-${Math.random().toString(36).slice(2)}`,
sig: "stub-signature",
}),
nip04: {
...(originalWindowNostr?.nip04 || {}),
encrypt: async () => "cipher-text",
},
};
try {
const { RelayPublishError } = await import("../js/nostrPublish.js");
const { nostrClient } = await import("../js/nostr.js");
const { subscriptions } = await import("../js/subscriptions.js");
const { userBlocks } = await import("../js/userBlocks.js");
function createRejectingPool(reason = "relay rejected") {
const calls = [];
return {
calls,
publish(urls, event) {
calls.push({
urls: Array.isArray(urls) ? [...urls] : [],
event,
});
return {
on(eventName, handler) {
if (eventName === "failed") {
handler(new Error(reason));
}
return this;
},
};
},
};
}
function snapshotRelayState(client) {
return {
relays: Array.isArray(client.relays) ? [...client.relays] : [],
readRelays: Array.isArray(client.readRelays)
? [...client.readRelays]
: [],
writeRelays: Array.isArray(client.writeRelays)
? [...client.writeRelays]
: [],
pool: client.pool,
};
}
function restoreRelayState(client, snapshot) {
client.relays = [...snapshot.relays];
client.readRelays = [...snapshot.readRelays];
client.writeRelays = [...snapshot.writeRelays];
client.pool = snapshot.pool;
}
function applyTestRelayState(client, relays, pool) {
client.relays = [...relays];
client.readRelays = [...relays];
client.writeRelays = [...relays];
client.pool = pool;
}
async function testPublishVideoRejectsWhenAllRelaysFail() {
const testRelays = [
"wss://relay-a.example",
"wss://relay-b.example",
];
const pool = createRejectingPool("video rejected");
const relaySnapshot = snapshotRelayState(nostrClient);
applyTestRelayState(nostrClient, testRelays, pool);
try {
await assert.rejects(
nostrClient.publishVideo(
{
title: "Test video",
magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890",
mode: "live",
},
"f".repeat(64)
),
(error) => {
assert.ok(
error instanceof RelayPublishError,
"publishVideo should reject with RelayPublishError"
);
assert.equal(
error.relayFailures.length,
testRelays.length,
"relayFailures should include every relay"
);
const urls = error.relayFailures
.map((entry) => entry.url)
.sort();
assert.deepEqual(
urls,
[...testRelays].sort(),
"relayFailures should include the rejecting relay URLs"
);
return true;
}
);
assert.equal(
pool.calls.length,
testRelays.length,
"publishVideo should attempt each relay"
);
} finally {
restoreRelayState(nostrClient, relaySnapshot);
}
}
async function testPublishSubscriptionListRejectsWhenAllRelaysFail() {
const testRelays = [
"wss://relay-a.example",
"wss://relay-b.example",
];
const pool = createRejectingPool("subscription rejected");
const relaySnapshot = snapshotRelayState(nostrClient);
applyTestRelayState(nostrClient, testRelays, pool);
const originalSubs = new Set(subscriptions.subscribedPubkeys);
const originalSubsEventId = subscriptions.subsEventId;
subscriptions.subscribedPubkeys = new Set(["abcdef1234567890"]);
subscriptions.subsEventId = null;
try {
await assert.rejects(
subscriptions.publishSubscriptionList("f".repeat(64)),
(error) => {
assert.ok(
error instanceof RelayPublishError,
"publishSubscriptionList should reject with RelayPublishError"
);
assert.equal(
error.relayFailures.length,
testRelays.length,
"relayFailures should include each relay for subscriptions"
);
const urls = error.relayFailures
.map((entry) => entry.url)
.sort();
assert.deepEqual(urls, [...testRelays].sort());
return true;
}
);
assert.equal(
pool.calls.length,
testRelays.length,
"publishSubscriptionList should attempt each relay"
);
assert.equal(
subscriptions.subsEventId,
null,
"subsEventId should remain unset on failure"
);
} finally {
subscriptions.subscribedPubkeys = originalSubs;
subscriptions.subsEventId = originalSubsEventId;
restoreRelayState(nostrClient, relaySnapshot);
}
}
async function testPublishBlockListRejectsWhenAllRelaysFail() {
const testRelays = [
"wss://relay-a.example",
"wss://relay-b.example",
];
const pool = createRejectingPool("block list rejected");
const relaySnapshot = snapshotRelayState(nostrClient);
applyTestRelayState(nostrClient, testRelays, pool);
const originalBlocked = new Set(userBlocks.blockedPubkeys);
const originalBlockEventId = userBlocks.blockEventId;
userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]);
userBlocks.blockEventId = null;
try {
await assert.rejects(
userBlocks.publishBlockList("f".repeat(64)),
(error) => {
assert.ok(
error instanceof RelayPublishError,
"publishBlockList should reject with RelayPublishError"
);
assert.equal(
error.relayFailures.length,
testRelays.length,
"relayFailures should include each relay for block list"
);
const urls = error.relayFailures
.map((entry) => entry.url)
.sort();
assert.deepEqual(urls, [...testRelays].sort());
return true;
}
);
assert.equal(
pool.calls.length,
testRelays.length,
"publishBlockList should attempt each relay"
);
assert.equal(
userBlocks.blockEventId,
null,
"blockEventId should remain unset on failure"
);
} finally {
userBlocks.blockedPubkeys = originalBlocked;
userBlocks.blockEventId = originalBlockEventId;
restoreRelayState(nostrClient, relaySnapshot);
}
}
await testPublishVideoRejectsWhenAllRelaysFail();
await testPublishSubscriptionListRejectsWhenAllRelaysFail();
await testPublishBlockListRejectsWhenAllRelaysFail();
console.log("nostr publish rejection tests passed");
} finally {
if (typeof originalWindowNostr === "undefined") {
delete window.nostr;
} else {
window.nostr = originalWindowNostr;
}
}
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
safeDecodeURIComponent,
safeDecodeURIComponentLoose,
} from "../js/utils/safeDecode.js";
import { safeDecodeMagnet } from "../js/magnetUtils.js";
test("safeDecodeURIComponent returns original value on malformed sequences", () => {
const malformed = "%E0%A4%A";
assert.equal(
safeDecodeURIComponent(malformed),
malformed,
"Malformed sequences should return the original input"
);
});
test("safeDecode helpers handle double-encoded values when applied repeatedly", () => {
const raw = "magnet:?xt=urn:btih:abcdef";
const doubleEncoded = encodeURIComponent(encodeURIComponent(raw));
const firstPass = safeDecodeURIComponent(doubleEncoded);
assert.equal(
firstPass,
encodeURIComponent(raw),
"First pass should peel one encoding layer"
);
const secondPass = safeDecodeURIComponent(firstPass);
assert.equal(
secondPass,
raw,
"Second pass should reveal the original string"
);
assert.equal(
safeDecodeMagnet(doubleEncoded),
raw,
"safeDecodeMagnet should continue to handle double-encoded magnets"
);
});
test("safeDecodeURIComponentLoose trims inputs by default", () => {
const padded = " magnet:?dn=Example";
assert.equal(
safeDecodeURIComponentLoose(padded),
"magnet:?dn=Example",
"Loose decoder should trim whitespace when decoding"
);
});
test("safeDecodeURIComponentLoose preserves whitespace when trim is false", () => {
const padded = " magnet:?dn=Example";
assert.equal(
safeDecodeURIComponentLoose(padded, { trim: false }),
padded,
"Loose decoder should return the original string when trim is disabled and decoding is unnecessary"
);
});
test("safeDecodeURIComponent handles empty strings consistently", () => {
assert.equal(safeDecodeURIComponent(""), "", "Empty input should stay empty");
assert.equal(
safeDecodeURIComponentLoose(""),
"",
"Loose decoder should return empty for empty input"
);
});
+708
View File
@@ -0,0 +1,708 @@
// Run with: node tests/view-counter.test.mjs
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const {
WATCH_HISTORY_KIND,
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
VIEW_COUNT_BACKFILL_MAX_DAYS,
VIEW_COUNT_CACHE_TTL_MS,
} = await import("../js/config.js");
const { buildViewEvent, setNostrEventSchemaOverrides } = await import(
"../js/nostrEventSchemas.js"
);
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
const CACHE_TTL_TEST_POINTER = { type: "e", value: "view-counter-cache-ttl" };
// Seed a stale cache entry so we can verify hydration ignores data older than the TTL.
const staleSavedAt = Date.now() - (VIEW_COUNT_CACHE_TTL_MS + 60_000);
localStorage.clear();
localStorage.setItem(
VIEW_COUNTER_STORAGE_KEY,
JSON.stringify({
version: 1,
savedAt: staleSavedAt,
entries: [
[
`${CACHE_TTL_TEST_POINTER.type}:${CACHE_TTL_TEST_POINTER.value}`,
{
total: 42,
dedupeBuckets: [["pub-cache-ttl:bucket", staleSavedAt]],
lastSyncedAt: staleSavedAt - 5_000,
status: "live",
},
],
],
})
);
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
setNostrEventSchemaOverrides({});
const canonicalViewEvent = buildViewEvent({
pubkey: "actor-canonical",
created_at: 1000,
pointerValue: CACHE_TTL_TEST_POINTER.value,
pointerTag: [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value],
dedupeTag: "ignore-dedupe",
});
assert.deepEqual(
canonicalViewEvent.tags,
[
["t", "view"],
[CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value],
["d", "ignore-dedupe"],
],
"view event should include topic, pointer, and dedupe tags when provided"
);
const sessionViewEvent = buildViewEvent({
pubkey: "actor-session",
created_at: 2000,
pointerValue: "kind:1234:actor-session",
pointerTag: ["a", "kind:1234:actor-session"],
includeSessionTag: true,
});
assert.deepEqual(
sessionViewEvent.tags,
[
["t", "view"],
["a", "kind:1234:actor-session"],
["session", "true"],
],
"view event should append the session tag when requested"
);
const { nostrClient } = await import("../js/nostr.js");
function createMockNostrHarness() {
const storedEvents = new Map();
const customCountResults = new Map();
const subscribers = new Map();
const metrics = { list: 0, count: 0 };
const resetMetrics = () => {
metrics.list = 0;
metrics.count = 0;
};
const pointerKeyFromInput = (input) => {
if (!input) {
return "";
}
if (typeof input === "string") {
const trimmed = input.trim();
if (!trimmed) {
return "";
}
if (trimmed.includes(":")) {
return trimmed;
}
return `e:${trimmed}`;
}
if (Array.isArray(input)) {
if (input.length < 2) {
return "";
}
const type = input[0] === "a" ? "a" : "e";
const value = typeof input[1] === "string" ? input[1].trim() : "";
if (!value) {
return "";
}
return `${type}:${value}`;
}
if (typeof input === "object") {
const type = input.type === "a" ? "a" : "e";
const value = typeof input.value === "string" ? input.value.trim() : "";
if (!value) {
return "";
}
return `${type}:${value}`;
}
return "";
};
const listVideoViewEvents = async (pointer, options = {}) => {
metrics.list += 1;
const key = pointerKeyFromInput(pointer);
const events = storedEvents.get(key) || [];
const since = Number.isFinite(options?.since) ? Number(options.since) : null;
if (since === null) {
return events;
}
return events.filter((event) => {
const createdAt = Number.isFinite(event?.created_at) ? Number(event.created_at) : 0;
return createdAt >= since;
});
};
const countVideoViewEvents = async (pointer) => {
metrics.count += 1;
const key = pointerKeyFromInput(pointer);
if (customCountResults.has(key)) {
const stored = customCountResults.get(key);
if (stored && typeof stored === "object" && !Array.isArray(stored)) {
const totalValue = Number(stored.total);
const normalizedTotal =
Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : 0;
const perRelay = Array.isArray(stored.perRelay)
? stored.perRelay.map((entry) =>
entry && typeof entry === "object" ? { ...entry } : entry
)
: [];
const result = {
total: normalizedTotal,
perRelay,
best:
stored.best && typeof stored.best === "object"
? { ...stored.best }
: null,
};
if (stored.fallback) {
result.fallback = true;
}
return result;
}
const numericTotal = Number(stored);
const normalizedTotal =
Number.isFinite(numericTotal) && numericTotal >= 0 ? numericTotal : 0;
return { total: normalizedTotal, perRelay: [], best: null };
}
const events = storedEvents.get(key) || [];
return { total: events.length, perRelay: [], best: null };
};
const subscribeVideoViewEvents = (pointer, options = {}) => {
const key = pointerKeyFromInput(pointer);
let handlers = subscribers.get(key);
if (!handlers) {
handlers = new Set();
subscribers.set(key, handlers);
}
const handler = typeof options?.onEvent === "function" ? options.onEvent : null;
if (handler) {
handlers.add(handler);
}
return () => {
if (handler && handlers) {
handlers.delete(handler);
if (!handlers.size) {
subscribers.delete(key);
}
}
};
};
const emit = (pointer, event) => {
const key = pointerKeyFromInput(pointer);
const handlers = subscribers.get(key);
if (!handlers) {
return;
}
for (const handler of Array.from(handlers)) {
handler(event);
}
};
const setEvents = (key, events) => {
storedEvents.set(key, Array.isArray(events) ? events : []);
};
const setCountTotal = (key, total) => {
if (
typeof total === "object" &&
total !== null &&
!Array.isArray(total)
) {
customCountResults.set(key, JSON.parse(JSON.stringify(total)));
} else if (Number.isFinite(total)) {
customCountResults.set(key, Number(total));
} else {
customCountResults.delete(key);
}
};
const reset = () => {
storedEvents.clear();
customCountResults.clear();
subscribers.clear();
resetMetrics();
};
const subscriptionCount = (key) => {
const handlers = subscribers.get(key);
return handlers ? handlers.size : 0;
};
const getCallCounts = () => ({ list: metrics.list, count: metrics.count });
return {
listVideoViewEvents,
countVideoViewEvents,
subscribeVideoViewEvents,
emit,
setEvents,
setCountTotal,
reset,
pointerKeyFromInput,
subscriptionCount,
resetMetrics,
getCallCounts,
};
}
const harness = createMockNostrHarness();
nostrClient.listVideoViewEvents = harness.listVideoViewEvents;
nostrClient.countVideoViewEvents = harness.countVideoViewEvents;
nostrClient.subscribeVideoViewEvents = harness.subscribeVideoViewEvents;
const {
initViewCounter,
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
ingestLocalViewEvent,
} = await import("../js/viewCounter.js");
initViewCounter({ nostrClient });
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
async function testDedupesWithinWindow() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = { type: "e", value: "view-counter-dedupe" };
const pointerKey = harness.pointerKeyFromInput(pointer);
const base = Math.floor(Date.now() / 1000);
const events = [
{ id: "evt-1", pubkey: "pub-dedupe", created_at: base },
{
id: "evt-2",
pubkey: "pub-dedupe",
created_at: base + Math.floor(VIEW_COUNT_DEDUPE_WINDOW_SECONDS / 2),
},
];
harness.setEvents(pointerKey, events);
harness.setCountTotal(pointerKey, 1);
const updates = [];
const token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
try {
await flushPromises();
await flushPromises();
const final = updates.at(-1);
assert.ok(final, "expected at least one state update");
assert.equal(
final.total,
1,
"multiple events from the same pubkey within the dedupe window should count once"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
}
async function testHydrationSkipsStaleEventsAndRollsOff() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = { type: "a", value: `${WATCH_HISTORY_KIND}:pk:test-hydrate` };
const pointerKey = harness.pointerKeyFromInput(pointer);
const nowSeconds = Math.floor(Date.now() / 1000);
const horizonSeconds = VIEW_COUNT_BACKFILL_MAX_DAYS * 86_400;
const staleEvent = {
id: "evt-stale",
pubkey: "pub-hydrate",
created_at: nowSeconds - horizonSeconds - 60,
};
const rolledOffEvent = {
id: "evt-rolled-off",
pubkey: "pub-hydrate",
created_at: nowSeconds - VIEW_COUNT_DEDUPE_WINDOW_SECONDS - 10,
};
const recentEvent = {
id: "evt-recent",
pubkey: "pub-hydrate",
created_at: nowSeconds - 5,
};
harness.setEvents(pointerKey, [staleEvent, rolledOffEvent, recentEvent]);
harness.setCountTotal(pointerKey, 2);
const updates = [];
let token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
try {
await flushPromises();
await flushPromises();
const final = updates.at(-1);
assert.ok(final, "expected hydration state update");
assert.equal(
final.total,
2,
"hydration should ignore events beyond the backfill window and keep unique buckets"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
const futureEvent = {
id: "evt-future",
pubkey: "pub-hydrate",
created_at: nowSeconds + VIEW_COUNT_DEDUPE_WINDOW_SECONDS + 5,
};
harness.setEvents(pointerKey, [rolledOffEvent, recentEvent, futureEvent]);
harness.setCountTotal(pointerKey, 3);
const secondUpdates = [];
token = subscribeToVideoViewCount(pointer, (state) => {
secondUpdates.push({ ...state });
});
try {
await flushPromises();
await flushPromises();
const final = secondUpdates.at(-1);
assert.ok(final, "expected updated hydration state");
assert.equal(
final.total,
3,
"events outside the dedupe window should contribute when hydrating fresh data"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
}
async function testRelayCountAggregationUsesBestEstimate() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = { type: "e", value: "view-counter-multi-relay" };
const pointerKey = harness.pointerKeyFromInput(pointer);
harness.setEvents(pointerKey, []);
harness.setCountTotal(pointerKey, {
total: 5,
best: { relay: "wss://relay.alpha", count: 5 },
perRelay: [
{ url: "wss://relay.alpha", ok: true, count: 5 },
{ url: "wss://relay.beta", ok: true, count: 5 },
],
});
const updates = [];
const token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
try {
await flushPromises();
await flushPromises();
const final = updates.at(-1);
assert.ok(final, "expected hydration update for aggregated relay counts");
assert.equal(
final.total,
5,
"identical relay COUNT responses should not be double-counted"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
}
async function testLocalIngestNotifiesImmediately() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = { type: "e", value: "view-counter-local" };
const pointerKey = harness.pointerKeyFromInput(pointer);
harness.setEvents(pointerKey, []);
harness.setCountTotal(pointerKey, 0);
const updates = [];
const token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
try {
await flushPromises();
const initial = updates.at(-1);
assert.ok(initial, "expected initial state update");
assert.equal(initial.total, 0, "initial total should start at zero");
const localEvent = {
id: "evt-local",
pubkey: "pub-local",
created_at: Math.floor(Date.now() / 1000),
};
ingestLocalViewEvent({ pointer, event: localEvent });
const latest = updates.at(-1);
assert.equal(
latest.total,
1,
"local ingestion should immediately notify subscribers with incremented total"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
}
async function testUnsubscribeStopsCallbacks() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = { type: "e", value: "view-counter-unsubscribe" };
const pointerKey = harness.pointerKeyFromInput(pointer);
harness.setEvents(pointerKey, []);
harness.setCountTotal(pointerKey, 0);
const updates = [];
const token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
await flushPromises();
const updatesBeforeUnsubscribe = updates.length;
unsubscribeFromVideoViewCount(pointer, token);
assert.equal(
harness.subscriptionCount(pointerKey),
0,
"tearing down the final handler should close the live subscription"
);
harness.emit(pointerKey, {
id: "evt-ignored",
pubkey: "pub-unsubscribe",
created_at: Math.floor(Date.now() / 1000),
});
assert.equal(
updates.length,
updatesBeforeUnsubscribe,
"no handlers should fire once the final subscriber has been removed"
);
}
async function testRecordVideoViewEmitsJsonPayload() {
localStorage.clear();
harness.reset();
harness.resetMetrics();
const pointer = {
type: "a",
value: `${WATCH_HISTORY_KIND}:pub:view-json`,
relay: "wss://relay.example",
};
const createdAt = Math.floor(Date.now() / 1000);
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalEnsureSessionActor = nostrClient.ensureSessionActor;
const originalSessionActor = nostrClient.sessionActor;
const originalPubkey = nostrClient.pubkey;
const originalWarn = console.warn;
const originalNostrTools = window.NostrTools;
const warnings = [];
console.warn = (...args) => {
warnings.push(
args
.map((value) => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
})
.join(" ")
);
};
window.NostrTools = {
...window.NostrTools,
getEventHash: () => "event-hash-record", // deterministic stub for tests
signEvent: () => "event-sig-record",
};
const publishCalls = [];
nostrClient.pool = {
publish(relayUrls, event) {
publishCalls.push({ relays: relayUrls, event });
return {
on(type, handler) {
if (type === "ok") {
setTimeout(() => handler(), 0);
}
return this;
},
};
},
};
nostrClient.relays = ["wss://relay.example"];
nostrClient.ensureSessionActor = async () => {
nostrClient.sessionActor = {
pubkey: "pub-record-json",
privateKey: "priv-record-json",
};
return "pub-record-json";
};
nostrClient.pubkey = "";
try {
const result = await nostrClient.recordVideoView(pointer, {
created_at: createdAt,
});
assert.ok(result.ok, "recordVideoView should succeed with stubbed handlers");
assert.equal(
publishCalls.length,
1,
"publishViewEvent should be invoked once per recordVideoView call"
);
const emittedEvent = publishCalls[0]?.event || null;
assert.ok(emittedEvent, "publishViewEvent should enqueue an event for relays");
assert.equal(
typeof emittedEvent.content,
"string",
"recordVideoView should provide serialized view content"
);
const payload = JSON.parse(emittedEvent.content);
assert.deepEqual(
payload,
{
target: {
type: pointer.type,
value: pointer.value,
relay: pointer.relay,
},
created_at: createdAt,
},
"default view content should capture pointer metadata and timestamp"
);
assert.equal(
result.event.content,
emittedEvent.content,
"serialized payload should persist on the emitted event"
);
} finally {
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.ensureSessionActor = originalEnsureSessionActor;
nostrClient.sessionActor = originalSessionActor;
nostrClient.pubkey = originalPubkey;
console.warn = originalWarn;
window.NostrTools = originalNostrTools;
}
const viewCounterWarnings = warnings.filter((message) =>
message.includes("[viewCounter] Failed to ingest local view event")
);
assert.equal(
viewCounterWarnings.length,
0,
"viewCounter.ingestLocalViewEvent should not reject JSON view payloads"
);
}
// Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays.
async function testHydrationRefreshesAfterCacheTtl() {
harness.reset();
harness.resetMetrics();
assert.equal(
localStorage.getItem(VIEW_COUNTER_STORAGE_KEY),
null,
"stale cache snapshot should be cleared when it exceeds the TTL"
);
const pointer = CACHE_TTL_TEST_POINTER;
const pointerKey = harness.pointerKeyFromInput(pointer);
const nowSeconds = Math.floor(Date.now() / 1000);
const events = [
{ id: "evt-ttl-1", pubkey: "pub-cache-ttl-1", created_at: nowSeconds - 15 },
{ id: "evt-ttl-2", pubkey: "pub-cache-ttl-2", created_at: nowSeconds - 5 },
];
harness.setEvents(pointerKey, events);
harness.setCountTotal(pointerKey, events.length);
const updates = [];
const token = subscribeToVideoViewCount(pointer, (state) => {
updates.push({ ...state });
});
try {
await flushPromises();
await flushPromises();
const initial = updates.at(0);
assert.ok(initial, "expected initial state update for cache TTL test");
assert.equal(
initial.total,
0,
"expired cache entries should not hydrate subscribers with stale totals"
);
const { list: listCalls, count: countCalls } = harness.getCallCounts();
assert.ok(
listCalls > 0 || countCalls > 0,
"hydration should fetch fresh data from relays once the cache TTL elapses"
);
const final = updates.at(-1);
assert.ok(final, "expected final state update for cache TTL test");
assert.equal(
final.total,
events.length,
"totals should reflect fresh relay data after invalidating the expired cache"
);
} finally {
unsubscribeFromVideoViewCount(pointer, token);
}
}
await testHydrationRefreshesAfterCacheTtl();
await testDedupesWithinWindow();
await testHydrationSkipsStaleEventsAndRollsOff();
await testLocalIngestNotifiesImmediately();
await testUnsubscribeStopsCallbacks();
await testRelayCountAggregationUsesBestEstimate();
await testRecordVideoViewEmitsJsonPayload();
console.log("View counter tests completed successfully.");
File diff suppressed because it is too large. Load diff
+107
View File
@@ -0,0 +1,107 @@
<!-- views/history.html -->
<section id="watchHistoryView" class="space-y-6">
<div class="space-y-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div>
<h2 class="text-xl font-semibold text-gray-100">Watch History</h2>
<p class="text-sm text-gray-400">
Review the videos you recently played, grouped by day.
</p>
</div>
<div class="flex flex-wrap gap-2" id="watchHistoryActions">
<button
type="button"
data-history-action="clear-cache"
class="rounded-full bg-gray-800 px-4 py-2 text-sm font-medium text-gray-100 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Clear local cache
</button>
<button
type="button"
data-history-action="republish"
class="rounded-full bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Republish now
</button>
</div>
</div>
<div
id="watchHistoryPrivacyBanner"
class="hidden rounded-lg border border-blue-500/40 bg-blue-900/30 p-4"
role="status"
aria-live="polite"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p id="watchHistoryPrivacyMessage" class="text-sm text-blue-100">
Your history stays encrypted. Share metadata with relays to sync
thumbnails across devices?
</p>
<div class="flex flex-wrap gap-2">
<button
id="watchHistoryPrivacyToggle"
type="button"
class="rounded-full bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
Share metadata
</button>
<button
id="watchHistoryPrivacyDismiss"
type="button"
class="rounded-full bg-transparent px-3 py-2 text-sm font-medium text-blue-200 hover:bg-blue-800/40 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Dismiss
</button>
</div>
</div>
</div>
<div
id="watchHistoryInfo"
class="rounded-lg bg-gray-800/60 p-4 text-sm text-gray-200"
>
Thumbnails and titles are hydrated locally from your device caches.
</div>
<div
id="watchHistoryError"
class="hidden rounded-lg border border-red-500/50 bg-red-900/40 p-3 text-sm text-red-100"
role="alert"
></div>
</div>
<div id="watchHistoryLoading">
<div id="watchHistoryStatus">
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span
class="status-spinner status-spinner--inline"
aria-hidden="true"
></span>
<span class="sidebar-loading-text"
>Fetching watch history from relays…</span
>
</div>
</div>
</div>
</div>
<div id="watchHistoryGrid" class="watch-history-grid hidden"></div>
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
Your watch history is empty. Watch some videos to populate this list.
</p>
<button
id="watchHistoryLoadMore"
type="button"
class="hidden w-full rounded-full bg-gray-800 px-4 py-2 text-sm font-medium text-gray-100 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Load more
</button>
<div id="watchHistorySentinel" class="hidden h-1 w-full"></div>
<script type="module" src="js/historyView.js"></script>
</section>
+6 -1
View File
@@ -8,6 +8,11 @@
id="videoList"
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
>
<!-- Videos will be dynamically inserted here -->
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span class="status-spinner status-spinner--inline" aria-hidden="true"></span>
<span class="sidebar-loading-text">Fetching recent videos…</span>
</div>
</div>
</div>
</section>
+8 -1
View File
@@ -9,5 +9,12 @@
gap: 2rem;
padding: 1.5rem 0;
"
></div>
>
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
<div class="sidebar-loading-indicator">
<span class="status-spinner status-spinner--inline" aria-hidden="true"></span>
<span class="sidebar-loading-text">Fetching subscriptions…</span>
</div>
</div>
</div>
</section>