Compare commits

..
559 Commits
Author SHA1 Message Date
thePR0M3TH3AN 8f316f915b Merge pull request #397 from PR0M3TH3AN/unstable
Unstable
2025-10-09 21:36:01 -04:00
thePR0M3TH3AN 002790a01d Merge pull request #395 from PR0M3TH3AN/codex/add-css-tokens-and-update-tailwind-config
Add design tokens and CSS workflow tooling
2025-10-09 20:55:15 -04:00
thePR0M3TH3AN 143abc8fd1 feat: add design tokens and css tooling 2025-10-09 20:53:40 -04:00
thePR0M3TH3AN 6376e73951 Merge pull request #394 from PR0M3TH3AN/codex/update-medialoader.js-and-confirm-load-handler
Fix lazy thumbnail load completion
2025-10-09 20:33:10 -04:00
thePR0M3TH3AN e2935c23fd Cache health badge state to avoid re-flashing 2025-10-09 20:31:40 -04:00
thePR0M3TH3AN 96bbeef040 Fix lazy thumbnail load completion 2025-10-09 20:17:06 -04:00
thePR0M3TH3AN b78337af6d Merge pull request #393 from PR0M3TH3AN/codex/fix-video-modal-initialization-issue
Fix video modal rehydration after back navigation
2025-10-09 20:10:56 -04:00
thePR0M3TH3AN d58d411180 Ensure video modal rehydrates when video element is missing 2025-10-09 20:09:58 -04:00
thePR0M3TH3AN 5463677237 Fix video modal rehydration after back navigation 2025-10-09 19:59:00 -04:00
thePR0M3TH3AN ca959cb33e Merge pull request #391 from PR0M3TH3AN/codex/extend-video-metadata-handling-in-watch-history
Preserve playback metadata in watch history
2025-10-09 19:24:21 -04:00
thePR0M3TH3AN 4d367109d5 Preserve playback metadata in watch history 2025-10-09 19:23:29 -04:00
thePR0M3TH3AN b5ee063260 Merge pull request #390 from PR0M3TH3AN/codex/new-task
Speed up profile and relay fetches
2025-10-09 19:05:51 -04:00
thePR0M3TH3AN 77fa149cc3 Speed up profile and relay fetches 2025-10-09 18:55:18 -04:00
thePR0M3TH3AN 35e84971b8 Merge pull request #389 from PR0M3TH3AN/codex/refactor-switchprofile-to-reuse-login-data
Refine profile switching login flow
2025-10-09 18:42:57 -04:00
thePR0M3TH3AN 12b1c1e7f8 Refine profile switching login flow 2025-10-09 18:41:27 -04:00
thePR0M3TH3AN 30895e4444 Merge pull request #388 from PR0M3TH3AN/codex/refactor-authservice-login-process
Defer auth post-login hydration to async follow-up
2025-10-09 16:41:47 -04:00
thePR0M3TH3AN 0a3057d081 Defer post-login hydration to async follow-up 2025-10-09 16:37:23 -04:00
thePR0M3TH3AN f61563d2e2 Merge pull request #387 from PR0M3TH3AN/codex/update-normalizeactorkey-and-decodenpubtohex
Short-circuit hex actor keys and quiet npub warnings
2025-10-09 16:22:30 -04:00
thePR0M3TH3AN 614ff3b1f1 Enable runtime watch history debug logging 2025-10-09 16:18:31 -04:00
thePR0M3TH3AN 3792b218a9 Add verbose logging for watch history metadata resolution 2025-10-09 16:06:46 -04:00
thePR0M3TH3AN 492c591f06 Add detailed watch history decryption logging 2025-10-09 15:46:33 -04:00
thePR0M3TH3AN 5d7df0eb61 Add manual npub decode fallback and expand tests 2025-10-09 15:37:52 -04:00
thePR0M3TH3AN 89986759e0 Short-circuit hex actor keys 2025-10-09 15:26:51 -04:00
thePR0M3TH3AN cc02228548 Merge pull request #386 from PR0M3TH3AN/codex/update-normalizeactorkey-and-watch-history
Normalize watch history actor keys for hex-based decrypt
2025-10-09 15:03:47 -04:00
thePR0M3TH3AN 61b893e94d Ensure auth UI syncs after extension login 2025-10-09 14:57:12 -04:00
thePR0M3TH3AN 3b0465067f Normalize watch history actors to hex 2025-10-09 14:19:00 -04:00
thePR0M3TH3AN e2540c729f Merge pull request #385 from PR0M3TH3AN/codex/refactor-extension.enable-request-loop
Tighten NIP-07 enable retries and expand login tests
2025-10-09 13:56:59 -04:00
thePR0M3TH3AN 18981f546c Adjust NIP-07 enable fallback ordering and tests 2025-10-09 13:55:00 -04:00
thePR0M3TH3AN a6e67e2284 Merge pull request #384 from PR0M3TH3AN/lsbgnr-codex/update-loadblocks-for-concurrent-relay-queries
Parallelize block list relay fetches
2025-10-09 13:46:38 -04:00
thePR0M3TH3AN b02d275bdb Parallelize block list relay fetches 2025-10-09 13:45:18 -04:00
thePR0M3TH3AN 13430ccea0 Merge pull request #381 from PR0M3TH3AN/codex/refactor-applypostloginstate-for-concurrency
Refactor post-login state loading and add coverage
2025-10-09 13:37:27 -04:00
thePR0M3TH3AN 599c40c3cc Refactor post-login state loading and add tests 2025-10-09 13:27:45 -04:00
thePR0M3TH3AN c092e10188 Merge pull request #380 from PR0M3TH3AN/codex/fix-video-player-modal-teardown-issue
Fix video modal teardown after history navigation
2025-10-09 13:01:37 -04:00
thePR0M3TH3AN e36e2c0aee Sync video modal element after cleanup 2025-10-09 13:00:31 -04:00
thePR0M3TH3AN 661d6965e4 Merge pull request #379 from PR0M3TH3AN/codex/implement-discussion-count-service
Refactor discussion count fetching into service
2025-10-09 12:56:18 -04:00
thePR0M3TH3AN 4ed5c97678 Refactor discussion counts into dedicated service 2025-10-09 12:51:34 -04:00
thePR0M3TH3AN 32686c5ef0 Merge pull request #378 from PR0M3TH3AN/codex/refactor-more-menu-into-controller-ax8cjy
Fix watch history decryption permissions
2025-10-09 12:42:04 -04:00
thePR0M3TH3AN fd6919b65f Ensure watch history decrypts request nip04 permission 2025-10-09 12:29:59 -04:00
thePR0M3TH3AN c081d950b2 Merge pull request #377 from PR0M3TH3AN/codex/refactor-more-menu-into-controller-yeuyxk
Refactor more menu logic into controller
2025-10-09 11:56:58 -04:00
thePR0M3TH3AN e9e21f733d Refactor more menu handling into controller 2025-10-09 11:54:52 -04:00
thePR0M3TH3AN c870d35f7c Merge pull request #375 from PR0M3TH3AN/codex/introduce-watchhistorytelemetry-module
Refactor watch history telemetry into helper
2025-10-09 11:40:53 -04:00
thePR0M3TH3AN 507f09dff5 Preserve video metadata in watch history payloads 2025-10-09 11:31:47 -04:00
thePR0M3TH3AN 4e42db2667 Request NIP-07 permissions needed for watch history decrypt 2025-10-09 10:47:52 -04:00
thePR0M3TH3AN 0b0d3628ee Refactor watch history telemetry into helper 2025-10-09 10:31:09 -04:00
thePR0M3TH3AN 0cf50ee7e9 Merge pull request #374 from PR0M3TH3AN/codex/change-send-button-to-done-after-zap
Make zap send button close dialog after success
2025-10-09 10:15:24 -04:00
thePR0M3TH3AN b54a0209d9 Make zap send button close dialog after success 2025-10-09 10:10:34 -04:00
thePR0M3TH3AN 0d7eb42643 Merge pull request #373 from PR0M3TH3AN/codex/refactor-code-into-nwcsettingsservice-module
Refactor NWC settings handling into dedicated service
2025-10-09 10:00:49 -04:00
thePR0M3TH3AN 419bb02610 Refactor NWC wallet settings into service 2025-10-09 09:54:10 -04:00
thePR0M3TH3AN e05006f0b7 Merge pull request #372 from PR0M3TH3AN/codex/add-boost-on-nostr-menu-options
Add manual Nostr boost menu actions
2025-10-09 09:40:11 -04:00
thePR0M3TH3AN da0d17c8f9 Round video card thumbnails 2025-10-09 09:39:04 -04:00
thePR0M3TH3AN 0bf0c1be5c Fix admin list npub parsing 2025-10-09 09:30:03 -04:00
thePR0M3TH3AN 70dec62d8f Improve NIP-07 login reliability 2025-10-09 09:29:47 -04:00
thePR0M3TH3AN 87d0caed5b Rename ensure presence menu copy to Rebroadcast 2025-10-09 09:07:06 -04:00
thePR0M3TH3AN d6fd0fd8e9 Fix video card menu clipping 2025-10-09 08:55:14 -04:00
thePR0M3TH3AN 55e3be4624 Fix video card menu clipping 2025-10-09 08:48:14 -04:00
thePR0M3TH3AN a5f9a33465 Add manual Nostr boost actions 2025-10-09 08:34:10 -04:00
thePR0M3TH3AN 5b57fc644d Merge pull request #371 from PR0M3TH3AN/codex/add-dedupe-cache-and-gate-ensure-presence
Add rebroadcast cooldown guard for ensure presence
2025-10-08 23:01:33 -04:00
thePR0M3TH3AN 86be0284d9 Add rebroadcast cooldown guard and toast handling 2025-10-08 23:01:05 -04:00
thePR0M3TH3AN 79e592b4a6 Merge pull request #370 from PR0M3TH3AN/codex/add-helper-to-fetch-raw-event-by-id
Add raw Nostr event helper and documentation
2025-10-08 22:44:02 -04:00
thePR0M3TH3AN de358dd643 Add raw nostr event cache and helper 2025-10-08 22:35:10 -04:00
thePR0M3TH3AN cb58c73b17 Merge pull request #369 from PR0M3TH3AN/codex/update-signandpublishevent-logic
Add session actor fallback for event publishing
2025-10-08 22:27:43 -04:00
thePR0M3TH3AN 98dd51509d Add session fallback signing to nostr publish flow 2025-10-08 22:27:11 -04:00
thePR0M3TH3AN ebbddf77bc Merge pull request #368 from PR0M3TH3AN/codex/update-pointer-selection-logic-in-app.js
Prioritize video root pointers for view tracking
2025-10-08 21:37:07 -04:00
thePR0M3TH3AN f9e2b975d1 Synchronize modal and card posted timestamps 2025-10-08 21:35:26 -04:00
thePR0M3TH3AN 0434401596 Show original posted timestamp for videos 2025-10-08 21:21:44 -04:00
thePR0M3TH3AN c99f6834cd Show posted and edited times from root history 2025-10-08 20:45:59 -04:00
thePR0M3TH3AN 7d389bb33d Speed up video subscription flushes 2025-10-08 20:35:29 -04:00
thePR0M3TH3AN 0addd59226 feat: prioritize video root pointer for view tracking 2025-10-08 17:50:03 -04:00
thePR0M3TH3AN e9ec0b790e Merge pull request #367 from PR0M3TH3AN/codex/update-collectsection-to-sanitize-input
Sanitize NIP-71 hashtag inputs
2025-10-08 16:39:50 -04:00
thePR0M3TH3AN 0ec2d48142 Sanitize NIP-71 hashtag inputs 2025-10-08 16:39:30 -04:00
thePR0M3TH3AN 55b2dc77e7 Merge pull request #366 from PR0M3TH3AN/codex/track-pending-submit-state-in-editmodal
Prevent duplicate edit submissions
2025-10-08 16:32:40 -04:00
thePR0M3TH3AN ebc306c928 Prevent duplicate edit submissions 2025-10-08 16:27:04 -04:00
thePR0M3TH3AN eec5b58175 Merge pull request #365 from PR0M3TH3AN/codex/implement-nip-71-tag-handling-in-nostr.js
Preserve NIP-71 metadata on base video posts
2025-10-08 16:19:09 -04:00
thePR0M3TH3AN 4f266e25db Preserve NIP-71 metadata on base video posts 2025-10-08 16:18:44 -04:00
thePR0M3TH3AN 9361752b54 Merge pull request #364 from PR0M3TH3AN/codex/adjust-legend-styling-for-nip71tagsfieldset
Align NIP-71 tags legend spacing in modals
2025-10-08 15:18:08 -04:00
thePR0M3TH3AN 073b62459a Align NIP-71 tags legends inside modal panels 2025-10-08 15:17:44 -04:00
thePR0M3TH3AN 55429a705c Merge pull request #363 from PR0M3TH3AN/codex/enhance-revert-version-details-rendering
Surface NIP-71 metadata in the revert modal
2025-10-08 15:04:35 -04:00
thePR0M3TH3AN ff83438dee Enhance revert modal metadata details 2025-10-08 15:02:09 -04:00
thePR0M3TH3AN 879330b2a2 Merge pull request #362 from PR0M3TH3AN/codex/refactor-nip-71-video-publishing-and-metadata-handling
Add NIP-71 metadata caching and pointer hydration
2025-10-08 14:47:58 -04:00
thePR0M3TH3AN 04865ffdc5 Add NIP-71 metadata wiring and caching 2025-10-08 14:47:26 -04:00
thePR0M3TH3AN c3f08a8178 Merge pull request #361 from PR0M3TH3AN/codex/implement-reusable-nip-71-form-helper
Refactor NIP-71 form handling and update edit modal
2025-10-08 12:57:16 -04:00
thePR0M3TH3AN eaae8066da Improve channel view loading and fix card menu 2025-10-08 12:39:51 -04:00
thePR0M3TH3AN cb18615c1b Refactor NIP-71 form handling and update edit modal 2025-10-08 12:20:27 -04:00
thePR0M3TH3AN 3573b7a287 Merge pull request #360 from PR0M3TH3AN/codex/update-private-toggle-label-and-documentation
Clarify private listing visibility messaging
2025-10-08 11:42:31 -04:00
thePR0M3TH3AN 0a929e7b6d Clarify private listing visibility messaging 2025-10-08 11:41:40 -04:00
thePR0M3TH3AN ccf65f7ccd Merge pull request #359 from PR0M3TH3AN/codex/update-video-card-highlight-styles
Highlight owner-only private videos
2025-10-08 11:31:55 -04:00
thePR0M3TH3AN 2f0d8ad07b Fix edit modal unlock toggles 2025-10-08 11:26:43 -04:00
thePR0M3TH3AN df20aaa114 Improve edit modal unlock handling 2025-10-08 10:28:52 -04:00
thePR0M3TH3AN 9d6f17fedb Fix edit modal unlock controls 2025-10-08 10:19:39 -04:00
thePR0M3TH3AN fb07d706b1 Add visibility toggle to edit modal 2025-10-08 10:07:19 -04:00
thePR0M3TH3AN 31cad16403 feat: highlight private videos for owners 2025-10-08 09:47:44 -04:00
thePR0M3TH3AN 23c3681b21 Merge pull request #358 from PR0M3TH3AN/codex/extend-video-visibility-logic-in-services
Hide private videos from non-owners in feeds and profiles
2025-10-08 09:42:02 -04:00
thePR0M3TH3AN d53ce6742a Hide private videos from non-owners 2025-10-08 09:41:38 -04:00
thePR0M3TH3AN 45749f1977 Merge pull request #357 from PR0M3TH3AN/codex/remove-fake-encryption-helpers-and-clean-up-code
Keep private magnets raw across publish and edit
2025-10-08 09:33:24 -04:00
thePR0M3TH3AN 004c6426a8 Stop obfuscating private magnets 2025-10-08 09:31:33 -04:00
thePR0M3TH3AN 484343b027 Merge pull request #355 from PR0M3TH3AN/codex/refactor-watch-history-handling-into-controller
Refactor watch history handling into controller
2025-10-08 08:30:03 -04:00
thePR0M3TH3AN 46ef2b8123 Ensure watch history loads include full lists by default 2025-10-08 08:11:26 -04:00
thePR0M3TH3AN 10751a8fb9 Improve watch history loading responsiveness 2025-10-08 07:33:44 -04:00
thePR0M3TH3AN 9d7efce44e Refactor watch history actions into controller 2025-10-08 07:16:26 -04:00
thePR0M3TH3AN 151c547490 Merge pull request #354 from PR0M3TH3AN/codex/create-zapcontroller-to-manage-zap-state
Refactor video modal zap handling into controller
2025-10-08 07:05:05 -04:00
thePR0M3TH3AN 8855fac640 Restore wallet URI validation helper 2025-10-08 07:03:56 -04:00
thePR0M3TH3AN a43f8e634d Refactor video modal zap flow into dedicated controller 2025-10-07 22:49:51 -04:00
thePR0M3TH3AN 934d7c6cce Merge pull request #353 from PR0M3TH3AN/codex/add-tests-for-profilemodalcontroller-functionality
Add jsdom-based tests for profile modal controller
2025-10-07 22:29:08 -04:00
thePR0M3TH3AN 9122a14493 Add jsdom-based tests for profile modal controller 2025-10-07 22:28:43 -04:00
thePR0M3TH3AN 86464f9798 Merge pull request #352 from PR0M3TH3AN/codex/refactor-profilemodal-integration-in-app.js-565efn
Improve channel profile loading reliability and modal playback
2025-10-07 22:22:22 -04:00
thePR0M3TH3AN b84f8c6031 Fix video modal layering and playback readiness 2025-10-07 22:19:29 -04:00
thePR0M3TH3AN 17dda8f2c1 Fix video modal readiness and sidebar stacking 2025-10-07 22:08:09 -04:00
thePR0M3TH3AN ad0713b4cb Fix video modal stacking and playback readiness 2025-10-07 21:51:12 -04:00
thePR0M3TH3AN 6ecbef207f Merge pull request #350 from PR0M3TH3AN/codex/port-cleanup-logic-to-profilemodalcontroller
Consolidate profile modal teardown into controller
2025-10-07 20:56:15 -04:00
thePR0M3TH3AN ae6ba0d9e0 Prevent VideoCard clicks from navigating 2025-10-07 20:50:59 -04:00
thePR0M3TH3AN 0843c70123 Align channel grid playback with main view 2025-10-07 20:38:32 -04:00
thePR0M3TH3AN a97ce36c70 Start channel playback before metadata fetch 2025-10-07 19:58:28 -04:00
thePR0M3TH3AN 7995f92a38 Prevent duplicate modal playback sessions 2025-10-07 19:46:46 -04:00
thePR0M3TH3AN ea81e3bf32 Return playback session result 2025-10-07 19:36:35 -04:00
thePR0M3TH3AN 9531fb21be Ensure channel grid passes playback sources to modal 2025-10-07 19:30:07 -04:00
thePR0M3TH3AN daf39ce361 Improve channel grid loading responsiveness 2025-10-07 19:21:28 -04:00
thePR0M3TH3AN 7070eea35e Ensure channel grid cards trigger playback 2025-10-07 19:12:48 -04:00
thePR0M3TH3AN 814bb65ffe Fix profile modal teardown state reset 2025-10-07 19:03:41 -04:00
thePR0M3TH3AN 749818fa13 Refine profile modal teardown handling 2025-10-07 17:59:01 -04:00
thePR0M3TH3AN f8c227e1ed Merge pull request #349 from PR0M3TH3AN/codex/migrate-controller-methods-and-verify-feedback
Refactor profile modal controller constant handling
2025-10-07 17:50:34 -04:00
thePR0M3TH3AN dbceaa98df refactor: inject profile modal constants 2025-10-07 17:48:26 -04:00
thePR0M3TH3AN bf552d3816 Merge branch 'unstable' of https://github.com/PR0M3TH3AN/bitvid into unstable 2025-10-07 17:40:17 -04:00
thePR0M3TH3AN c1ae2c8c9f updated agents.md 2025-10-07 17:40:12 -04:00
thePR0M3TH3AN efe4ff99eb Merge pull request #348 from PR0M3TH3AN/codex/refactor-profile-modal-integration
Ensure profile modal controller reflects application state
2025-10-07 17:28:39 -04:00
thePR0M3TH3AN 1a6b304daa Sync application state with profile modal controller 2025-10-07 17:25:50 -04:00
thePR0M3TH3AN fe0e023bd7 Merge pull request #346 from PR0M3TH3AN/codex/extend-profilemodalcontroller-with-event-callbacks-x8ug98
Ensure video grid refresh on auth changes
2025-10-07 17:00:33 -04:00
thePR0M3TH3AN 1db10f7f81 Fix channel playback and speed up profile fetches 2025-10-07 16:55:24 -04:00
thePR0M3TH3AN 90bbcf0ad4 Ensure video grid refresh on auth changes 2025-10-07 16:32:53 -04:00
thePR0M3TH3AN 35898a95a5 Merge pull request #344 from PR0M3TH3AN/codex/extend-profilemodalcontroller-constructor-options
Refactor profile modal dependencies
2025-10-07 16:07:12 -04:00
thePR0M3TH3AN 3bf380dfc6 Ensure login UI updates after NIP-07 approval 2025-10-07 15:59:43 -04:00
thePR0M3TH3AN 2e516ca989 Refactor profile modal dependencies 2025-10-07 15:33:11 -04:00
thePR0M3TH3AN 3fe9f75700 Merge pull request #343 from PR0M3TH3AN/codex/refactor-profilemodalcontroller-and-application
Refactor profile modal interactions
2025-10-07 15:20:36 -04:00
thePR0M3TH3AN cc10bc33f0 Improve NIP-07 login retry logic 2025-10-07 15:18:06 -04:00
thePR0M3TH3AN 30e66fe969 Refactor profile modal interactions 2025-10-07 14:55:58 -04:00
thePR0M3TH3AN b5cde1b977 Merge pull request #342 from PR0M3TH3AN/codex/update-profilemodalcontroller.js-functionality
Rely on ProfileModalController service fallbacks
2025-10-07 14:37:07 -04:00
thePR0M3TH3AN 384892eea2 Use controller fallbacks for profile management 2025-10-07 14:31:18 -04:00
thePR0M3TH3AN c3b16baf0d Merge pull request #341 from PR0M3TH3AN/codex/refactor-profilemodal-integration-in-application
Refactor profile modal integration to rely on controller
2025-10-07 13:54:41 -04:00
thePR0M3TH3AN 3eb63f28fb Refactor profile modal wiring to use controller 2025-10-07 13:52:30 -04:00
thePR0M3TH3AN d2c4758d6d Merge pull request #340 from PR0M3TH3AN/codex/update-profilemodalcontroller-structure
Refactor profile modal DOM caches
2025-10-07 13:38:32 -04:00
thePR0M3TH3AN c76f780264 Fix moderator management aliases and validation 2025-10-07 13:36:17 -04:00
thePR0M3TH3AN 58d28019e9 Refactor profile modal DOM caches 2025-10-07 12:52:51 -04:00
thePR0M3TH3AN bc392b7088 Merge pull request #339 from PR0M3TH3AN/codex/extend-profilemodalcontroller-callbacks
Refactor profile modal actions through app callbacks
2025-10-07 12:23:25 -04:00
thePR0M3TH3AN bece17afda Refactor profile modal actions through app callbacks 2025-10-07 12:12:56 -04:00
thePR0M3TH3AN c699d3f438 Merge pull request #338 from PR0M3TH3AN/codex/refactor-profilemodalcontroller-imports
Refine profile modal service dependencies
2025-10-07 11:56:24 -04:00
thePR0M3TH3AN 6fb26fede5 Restore app user npub and blocklist helpers 2025-10-07 11:54:07 -04:00
thePR0M3TH3AN 73be11403b Refine profile modal service dependencies 2025-10-07 11:40:24 -04:00
thePR0M3TH3AN 56cf4aa8e6 Merge pull request #337 from PR0M3TH3AN/codex/refactor-profile-modal-implementation
Delegate profile modal helpers to controller
2025-10-07 11:13:04 -04:00
thePR0M3TH3AN 38e010c828 Refactor profile modal helpers to use controller 2025-10-07 11:08:50 -04:00
thePR0M3TH3AN 19bcc8ca6b Merge pull request #336 from PR0M3TH3AN/codex/refactor-profilemodalcontroller-event-handling
Refactor profile modal event handling to use controller
2025-10-07 10:26:33 -04:00
thePR0M3TH3AN cfa2e2f8bf Ensure profile modal mounts directly in modal container 2025-10-07 10:24:43 -04:00
thePR0M3TH3AN ee2175a7ad Refactor profile modal to rely on controller event handling 2025-10-07 09:45:59 -04:00
thePR0M3TH3AN 47a78a083c Merge pull request #335 from PR0M3TH3AN/codex/refactor-modal-and-wallet-helpers-in-profilemodalcontroller
Align profile modal helpers with service contract
2025-10-07 09:24:24 -04:00
thePR0M3TH3AN 723c8f8bf8 Refine profile modal service usage 2025-10-07 09:16:49 -04:00
thePR0M3TH3AN 4529a59499 Merge pull request #334 from PR0M3TH3AN/codex/add-profile-modal-controller-methods
Add profile modal helper implementations to controller
2025-10-07 08:57:06 -04:00
thePR0M3TH3AN 41b2b8fba6 Ensure modal stack stays above sidebar 2025-10-07 08:52:17 -04:00
thePR0M3TH3AN dba7763000 Ensure video modal loads before playback fallback 2025-10-07 08:42:16 -04:00
thePR0M3TH3AN ac29eea29c Expand profile modal controller helpers 2025-10-06 22:42:14 -04:00
thePR0M3TH3AN 98fb9b437d Merge pull request #333 from PR0M3TH3AN/codex/update-profilemodalcontroller-constructor-options
Wire profile modal controller dependencies
2025-10-06 22:17:55 -04:00
thePR0M3TH3AN 38f70b666d Fix profile state adapter and magnet fallback input 2025-10-06 22:12:12 -04:00
thePR0M3TH3AN e7939f52c1 Ensure modals overlay sidebar and harden channel playback 2025-10-06 21:57:31 -04:00
thePR0M3TH3AN f4925a2726 Fix channel video playback and stabilize edit gear menu 2025-10-06 21:45:53 -04:00
thePR0M3TH3AN 715087992e Refine profile modal controller wiring 2025-10-06 21:37:21 -04:00
thePR0M3TH3AN ddf1a560a8 Merge pull request #332 from PR0M3TH3AN/codex/fix-main-body-resizing-on-sidebar-change
Stabilize layout spacing when toggling the sidebar
2025-10-06 20:46:55 -04:00
thePR0M3TH3AN c0290b33a7 Merge branch 'unstable' into codex/fix-main-body-resizing-on-sidebar-change 2025-10-06 20:46:06 -04:00
thePR0M3TH3AN 79aa7f7669 Ensure sidebar state shifts app layout 2025-10-06 20:43:48 -04:00
thePR0M3TH3AN 9834fc7d9f Merge pull request #331 from PR0M3TH3AN/codex/fix-main-body-resizing-on-sidebar-change
Fix main layout width when toggling sidebar
2025-10-06 20:31:34 -04:00
thePR0M3TH3AN 25e53c08e3 Bump asset version for sidebar layout fix 2025-10-06 20:30:36 -04:00
thePR0M3TH3AN 8cf1ff0037 Merge pull request #330 from PR0M3TH3AN/codex/fix-main-body-resizing-on-sidebar-change
Fix main layout width when toggling sidebar
2025-10-06 20:20:43 -04:00
thePR0M3TH3AN 4fd16c5a8e Fix main layout width when toggling sidebar 2025-10-06 20:19:28 -04:00
thePR0M3TH3AN d1d481555d Merge pull request #329 from PR0M3TH3AN/codex/implement-collapsible-sidebar-on-desktop
Improve desktop sidebar collapse behavior
2025-10-06 20:11:15 -04:00
thePR0M3TH3AN 8ad39b5cf6 Default sidebar to collapsed state by default 2025-10-06 20:06:43 -04:00
thePR0M3TH3AN b3b467755a Stabilize collapsed sidebar rail 2025-10-06 19:57:16 -04:00
thePR0M3TH3AN 957a3ae6fa Fix collapsed sidebar layout and cache busting 2025-10-06 18:58:54 -04:00
thePR0M3TH3AN 58fd6ef7d5 Tighten collapsed sidebar rail spacing 2025-10-06 17:46:02 -04:00
thePR0M3TH3AN 2bafecfb50 Refine sidebar collapse interactions 2025-10-06 17:08:35 -04:00
thePR0M3TH3AN f04c21004a Bust cached sidebar assets 2025-10-06 16:27:13 -04:00
thePR0M3TH3AN 63a178263a Refine collapsed sidebar spacing 2025-10-06 16:10:00 -04:00
thePR0M3TH3AN 4d97493003 Polish desktop sidebar collapse visuals 2025-10-06 15:46:17 -04:00
thePR0M3TH3AN bffd98247e Refine desktop sidebar collapse experience 2025-10-06 15:29:29 -04:00
thePR0M3TH3AN a453ead95e Fix desktop sidebar toggle visibility 2025-10-06 15:08:32 -04:00
thePR0M3TH3AN 9a9dcb239f Refine desktop sidebar collapse styling 2025-10-06 14:42:41 -04:00
thePR0M3TH3AN 8981f1cb6a Improve desktop sidebar collapse behavior 2025-10-06 14:14:44 -04:00
thePR0M3TH3AN ff3d9fa05c Merge pull request #328 from PR0M3TH3AN/codex/implement-responsive-sidebar-functionality
Improve sidebar density controls
2025-10-06 14:00:31 -04:00
thePR0M3TH3AN 251290ab2e Improve sidebar density controls 2025-10-06 13:59:09 -04:00
thePR0M3TH3AN 3b2874a5c9 Merge pull request #327 from PR0M3TH3AN/codex/extend-sidebar-styles-for-media-query
Adjust desktop sidebar collapse behavior
2025-10-06 13:48:37 -04:00
thePR0M3TH3AN 38ec169b22 Lower sidebar z-index below modals 2025-10-06 13:47:44 -04:00
thePR0M3TH3AN 8158eb2089 Adjust desktop sidebar collapse styles 2025-10-06 13:40:44 -04:00
thePR0M3TH3AN 48cf7ed4f8 Merge pull request #326 from PR0M3TH3AN/codex/add-sidebar-toggle-button-in-sidebar.html
Add desktop collapse toggle to sidebar
2025-10-06 13:35:31 -04:00
thePR0M3TH3AN 8e8ab147fb Ensure modal stack stays above sidebar 2025-10-06 13:34:27 -04:00
thePR0M3TH3AN 19f62d545d Add desktop collapse toggle to sidebar 2025-10-06 12:57:40 -04:00
thePR0M3TH3AN 7286295a3a Merge pull request #325 from PR0M3TH3AN/codex/fix-sidebar-ui/ux-issues
Improve mobile sidebar interactions
2025-10-06 12:03:01 -04:00
thePR0M3TH3AN 24b12d9108 Ensure mobile sidebar stays under modal layers 2025-10-06 12:01:56 -04:00
thePR0M3TH3AN 1bb935628e Improve mobile sidebar interactions 2025-10-06 11:54:47 -04:00
thePR0M3TH3AN 7a923ae77c Merge pull request #324 from PR0M3TH3AN/codex/fix-loading-errors-on-the-site-37t00u
Add local nostr-tools fallback for restricted domains
2025-10-06 11:41:30 -04:00
thePR0M3TH3AN 26c4420293 Merge branch 'unstable' into codex/fix-loading-errors-on-the-site-37t00u 2025-10-06 11:40:49 -04:00
thePR0M3TH3AN 4f75f2dff4 Add local nostr-tools fallback for restricted domains 2025-10-06 11:40:09 -04:00
thePR0M3TH3AN 4926cb5242 Merge pull request #323 from PR0M3TH3AN/codex/fix-loading-errors-on-the-site-k8wabg
Improve nostr-tools SimplePool detection
2025-10-06 11:30:13 -04:00
thePR0M3TH3AN d9d07d8ff9 Merge branch 'unstable' into codex/fix-loading-errors-on-the-site-k8wabg 2025-10-06 11:29:32 -04:00
thePR0M3TH3AN 82f779099a Improve nostr-tools SimplePool detection 2025-10-06 11:28:05 -04:00
thePR0M3TH3AN 332fc156bf Merge pull request #322 from PR0M3TH3AN/codex/fix-loading-errors-on-the-site
Handle alternate SimplePool exports
2025-10-06 10:43:58 -04:00
thePR0M3TH3AN d35f396c2f Handle alternate SimplePool exports 2025-10-06 10:42:39 -04:00
thePR0M3TH3AN c4cc89af29 Merge pull request #321 from PR0M3TH3AN/codex/refactor-nostr-tools-integration-for-async-readiness
Defer nostr-tools usage until bootstrap
2025-10-06 10:17:39 -04:00
thePR0M3TH3AN f80ebef5c3 Defer nostr-tools access until bootstrap 2025-10-06 10:17:11 -04:00
thePR0M3TH3AN e3b2220269 Merge pull request #320 from PR0M3TH3AN/codex/update-index.html-for-nostr-tools-script
Document nostr tools bootstrap ordering
2025-10-06 10:12:19 -04:00
thePR0M3TH3AN b5f218b221 Fix Lightning override string syntax 2025-10-06 09:59:09 -04:00
thePR0M3TH3AN 84948101f3 Document nostr tools bootstrap ordering 2025-10-06 09:53:39 -04:00
thePR0M3TH3AN c39208c629 Merge pull request #319 from PR0M3TH3AN/codex/add-bootstrap-routine-for-nostr-tools
Add resilient nostr-tools bootstrap loader
2025-10-06 09:46:59 -04:00
thePR0M3TH3AN 62ccc468f9 Add Nostr tools bootstrapper with timeout fallbacks 2025-10-06 09:45:25 -04:00
thePR0M3TH3AN 33f96b762a update 2025-10-06 08:51:27 -04:00
thePR0M3TH3AN 7c9443e712 update 2025-10-05 22:45:04 -04:00
thePR0M3TH3AN beb8b54d14 Merge pull request #318 from PR0M3TH3AN/codex/update-config-validation-and-add-unit-tests
Require platform Lightning override when enabling fees
2025-10-05 22:42:26 -04:00
thePR0M3TH3AN e6b465ccca Enforce platform override when Lightning fees are enabled 2025-10-05 22:41:52 -04:00
thePR0M3TH3AN 287d954823 Merge pull request #317 from PR0M3TH3AN/codex/add-ensurepool-helper-to-nostrclient
Ensure platform zap lookups await Nostr pool
2025-10-05 22:38:42 -04:00
thePR0M3TH3AN 1c805b053e Wait for nostr pool before platform lookup 2025-10-05 22:37:23 -04:00
thePR0M3TH3AN 908435f476 Merge pull request #316 from PR0M3TH3AN/codex/fix-bitcoin-zap-function-to-split-payments
Handle string zap platform fee inputs
2025-10-05 22:03:23 -04:00
thePR0M3TH3AN b773e1ec21 Fix zap fee parsing for split calculations 2025-10-05 22:03:07 -04:00
thePR0M3TH3AN c0ffde6462 Handle string zap platform fee inputs 2025-10-05 21:48:39 -04:00
thePR0M3TH3AN d84dbe5fae Merge pull request #315 from PR0M3TH3AN/codex/verify-nostr-wallet-connect-implementation
Document Nostr Wallet Connect implementation coverage
2025-10-05 20:54:19 -04:00
thePR0M3TH3AN 793375a4fb Document NWC implementation coverage 2025-10-05 20:54:04 -04:00
thePR0M3TH3AN c93075bb22 Merge pull request #314 from PR0M3TH3AN/codex/adjust-ensureencryption-for-nip04-fallback
Fix NWC encryption fallback handling
2025-10-05 20:50:11 -04:00
thePR0M3TH3AN aa4e86758a Fix NWC encryption fallback handling 2025-10-05 20:49:56 -04:00
thePR0M3TH3AN 9d57a43c98 Merge pull request #313 from PR0M3TH3AN/codex/update-uri-parsing-for-nwcclient.js
Handle multi-relay NWC URIs
2025-10-05 19:50:10 -04:00
thePR0M3TH3AN e76107c8b2 Handle multi-relay NWC URIs 2025-10-05 19:49:22 -04:00
thePR0M3TH3AN 8dd4b40b5a Merge pull request #312 from PR0M3TH3AN/codex/update-sendpayment-to-use-msats
Convert NWC client payment amount to msats
2025-10-05 19:34:29 -04:00
thePR0M3TH3AN 1e42b2af56 Convert NWC payment amounts to msats 2025-10-05 19:33:58 -04:00
thePR0M3TH3AN 24823ebed9 Merge pull request #311 from PR0M3TH3AN/codex/verify-nostr-wallet-connect-execution
Support negotiated encryption for Nostr Wallet Connect
2025-10-05 19:22:50 -04:00
thePR0M3TH3AN 1f0fb6ac87 Support NIP-47 encryption negotiation 2025-10-05 19:22:24 -04:00
thePR0M3TH3AN 9e296d4910 Merge pull request #310 from PR0M3TH3AN/codex/fix-bitcoin-zap-function-timeout
Fix NWC zap timeout when wallet omits e-tag
2025-10-05 19:12:19 -04:00
thePR0M3TH3AN f2c7520a40 Handle NWC responses without e-tag IDs 2025-10-05 19:08:43 -04:00
thePR0M3TH3AN 05f3f88795 Merge pull request #309 from PR0M3TH3AN/codex/fix-zap-button-layout-and-visibility
Fix zap dialog positioning and gating
2025-10-05 18:49:19 -04:00
thePR0M3TH3AN 8fd5ce3c88 Darken zap dialog and add send control 2025-10-05 18:45:03 -04:00
thePR0M3TH3AN 0fd54abde8 Fix zap dialog positioning and gating 2025-10-05 18:24:32 -04:00
thePR0M3TH3AN 5d04571b6b Merge pull request #308 from PR0M3TH3AN/codex/fix-amount-is-not-defined-error
Fix zap payment parameter handling
2025-10-05 14:18:57 -04:00
thePR0M3TH3AN a39446f1e0 Fix zap payment parameter handling 2025-10-05 14:18:32 -04:00
thePR0M3TH3AN 01a9b2f0bd Merge pull request #307 from PR0M3TH3AN/codex/fix-amount-not-defined-error-on-zap
Fix channel zap share amount tracking
2025-10-05 14:07:40 -04:00
thePR0M3TH3AN 8925eee72e Fix channel zap share amount tracking 2025-10-05 14:07:15 -04:00
thePR0M3TH3AN 16562cbb39 Merge pull request #306 from PR0M3TH3AN/codex/fix-bitcoin-zaps-referenceerror
Fix zap payment logging when amount is referenced
2025-10-05 13:58:24 -04:00
thePR0M3TH3AN c9ea82a6dc Fix zap tracking amount reference 2025-10-05 13:57:53 -04:00
thePR0M3TH3AN 76e063e8f6 Merge pull request #305 from PR0M3TH3AN/codex/add-profilemodalcontroller-module
Add profile modal controller skeleton
2025-10-05 13:55:14 -04:00
thePR0M3TH3AN e07d4c4cab Add profile modal controller skeleton 2025-10-05 13:52:31 -04:00
thePR0M3TH3AN 6dd970d55b Merge pull request #304 from PR0M3TH3AN/codex/fix-zap-amount-transaction-failure
Ensure NostrTools nip04 support survives external overrides
2025-10-05 08:44:54 -04:00
thePR0M3TH3AN ad4f56a1d4 Ensure NostrTools nip04 helper persists 2025-10-05 08:44:23 -04:00
thePR0M3TH3AN 2529ab1314 Merge pull request #303 from PR0M3TH3AN/codex/add-detailed-logging-for-troubleshooting-i0faly
Improve zap failure diagnostics
2025-10-05 02:35:18 -04:00
thePR0M3TH3AN 80438555f5 Improve zap failure diagnostics 2025-10-05 02:34:52 -04:00
thePR0M3TH3AN 5d3c2a5f35 Merge pull request #302 from PR0M3TH3AN/codex/add-detailed-logging-for-troubleshooting
Add zap failure debug logging
2025-10-05 02:25:06 -04:00
thePR0M3TH3AN 22e93d83dd Add detailed zap failure logging 2025-10-05 02:24:45 -04:00
thePR0M3TH3AN 991da0f2d5 Merge pull request #301 from PR0M3TH3AN/codex/investigate-bitcoin-zap-issues
Improve nostr-tools nip04 fallback initialization
2025-10-05 02:09:44 -04:00
thePR0M3TH3AN 0824bbca98 Improve nostr-tools nip04 fallback initialization 2025-10-05 02:09:12 -04:00
thePR0M3TH3AN 69ea5b0919 Merge pull request #299 from PR0M3TH3AN/codex/add-documentation-on-moderation-features
Add moderation documentation bundle
2025-10-05 01:53:10 -04:00
thePR0M3TH3AN 7550007fe6 Add moderation documentation bundle 2025-10-05 01:52:50 -04:00
thePR0M3TH3AN 2d20ecb6ca Merge pull request #298 from PR0M3TH3AN/codex/fix-zaping-process-issue
Ensure NostrTools nip04 helper loads for zaps
2025-10-05 01:46:58 -04:00
thePR0M3TH3AN fe89e54a2a Ensure nip04 helper is available for zaps 2025-10-05 01:46:43 -04:00
thePR0M3TH3AN c2620a7564 Merge pull request #297 from PR0M3TH3AN/codex/fix-bitcoin-zap-failure-and-improve-logging
Handle namespace-style NostrTools nip04 exports for zaps
2025-10-05 00:48:47 -04:00
thePR0M3TH3AN d843a6aa51 Fix NostrTools detection for zaps 2025-10-04 23:48:23 -04:00
thePR0M3TH3AN 2132a913d5 Merge pull request #296 from PR0M3TH3AN/codex/enhance-zap-functionality-in-video-handler
Enhance video modal zap flow and add integration tests
2025-10-04 23:41:18 -04:00
thePR0M3TH3AN 99e8bd0c78 Fix modal zap flow tests and dependencies 2025-10-04 23:40:51 -04:00
thePR0M3TH3AN 90e3649506 Merge pull request #295 from PR0M3TH3AN/codex/update-zap-feature-in-channel-profile
Add interactive zap controls and metadata caching to channel profile
2025-10-04 22:38:09 -04:00
thePR0M3TH3AN ce94e2c411 Enhance channel zap flow with UI and metadata caching 2025-10-04 22:33:29 -04:00
thePR0M3TH3AN dbc8a5278a Merge pull request #294 from PR0M3TH3AN/codex/implement-payments-module-with-helper-functions
Add Lightning payment utilities and zap split tests
2025-10-04 22:16:41 -04:00
thePR0M3TH3AN bbad8f7d48 Add Lightning zap split utilities 2025-10-04 22:16:13 -04:00
thePR0M3TH3AN b990578027 Merge pull request #293 from PR0M3TH3AN/codex/add-wallet-nav-button-and-profile-section
Add wallet management pane to profile modal
2025-10-04 22:04:50 -04:00
thePR0M3TH3AN a37e82fe03 Add wallet management pane to profile modal 2025-10-04 22:04:23 -04:00
thePR0M3TH3AN c8600f824e Merge pull request #292 from PR0M3TH3AN/codex/implement-nwcsettings.js-with-storage-features
Add NWC settings persistence with per-profile isolation
2025-10-04 21:46:48 -04:00
thePR0M3TH3AN 73a7a48c5c Add NWC settings persistence with per-profile isolation 2025-10-04 21:44:50 -04:00
thePR0M3TH3AN f6e1bf2424 Merge pull request #291 from PR0M3TH3AN/codex/extend-config-with-platform-fee-and-validation
Add platform fee configuration validation
2025-10-04 21:31:58 -04:00
thePR0M3TH3AN 7672726088 Add platform fee configuration validation 2025-10-04 21:31:12 -04:00
thePR0M3TH3AN 57cde4460a Merge pull request #290 from PR0M3TH3AN/codex/implement-nip-71-video-note-handling
Add NIP-71 publishing pipeline alongside legacy video notes
2025-10-04 21:16:20 -04:00
thePR0M3TH3AN 62fe113bf4 Add NIP-71 video builder and dual publish workflow 2025-10-04 21:14:41 -04:00
thePR0M3TH3AN fd400f6fb2 Merge pull request #289 from PR0M3TH3AN/codex/redefine-payload-contract-for-publishvideonote
Support NIP-71 metadata in publish pipeline
2025-10-04 18:51:10 -04:00
thePR0M3TH3AN 9041f9c0b7 feat: pass nip71 metadata through publish pipeline 2025-10-04 18:49:43 -04:00
thePR0M3TH3AN 1d8fa2d0c0 Merge pull request #288 from PR0M3TH3AN/codex/enhance-uploadmodal-with-nip-71-features
Add NIP-71 metadata handling to upload modal
2025-10-04 18:40:23 -04:00
thePR0M3TH3AN 84002d439d Add NIP-71 metadata controls to upload modal 2025-10-04 18:39:55 -04:00
thePR0M3TH3AN 109a0a7aec Merge pull request #287 from PR0M3TH3AN/codex/extend-upload-modal-with-nip-71-structure
Add NIP-71 metadata controls to Cloudflare upload workflow
2025-10-04 18:27:11 -04:00
thePR0M3TH3AN 3d56cc3835 Extend Cloudflare upload form with NIP-71 metadata 2025-10-04 18:23:47 -04:00
thePR0M3TH3AN 7d88cb1dbc Merge pull request #286 from PR0M3TH3AN/codex/update-upload-modal-for-nip-71-fields
Add NIP-71 metadata controls to custom upload modal
2025-10-04 18:18:26 -04:00
thePR0M3TH3AN 94a203f594 Add NIP-71 form controls to upload modal 2025-10-04 18:10:49 -04:00
thePR0M3TH3AN 57cf16a0f4 Merge pull request #285 from PR0M3TH3AN/codex/create-and-implement-watch-history-feed-adapter
Add watch history feed adapter and UI integration
2025-10-04 17:52:33 -04:00
thePR0M3TH3AN 94eb750854 Fix watch history feed dropping older entries 2025-10-04 17:42:34 -04:00
thePR0M3TH3AN 7c3af36003 Add watch history feed adapter and update renderer 2025-10-04 17:25:43 -04:00
thePR0M3TH3AN b1b0b0f625 Merge pull request #284 from PR0M3TH3AN/codex/implement-subscriptions-feed-and-refactor-code
Refactor subscriptions feed to run through the engine
2025-10-04 17:06:51 -04:00
thePR0M3TH3AN 24309c1356 Refactor subscriptions view to use feed engine 2025-10-04 16:58:48 -04:00
thePR0M3TH3AN a940d0cdfd Merge pull request #283 from PR0M3TH3AN/codex/integrate-feed-engine-in-app-setup
Integrate feed engine into recent video flow
2025-10-04 16:42:27 -04:00
thePR0M3TH3AN 4a588ed5d5 Integrate feed engine into recent video flow 2025-10-04 16:36:00 -04:00
thePR0M3TH3AN 6bb089f4c1 Merge pull request #282 from PR0M3TH3AN/codex/add-feed-engine-module-with-features
Add feed engine module and baseline adapters
2025-10-04 16:25:11 -04:00
thePR0M3TH3AN f81c97e5a8 feat: introduce feed engine module 2025-10-04 16:21:23 -04:00
thePR0M3TH3AN 58d2611df7 Merge pull request #281 from PR0M3TH3AN/codex/improve-back-button-functionality-in-video-modal
Improve video modal back button responsiveness
2025-10-04 15:55:49 -04:00
thePR0M3TH3AN edc03bf152 Improve video modal close responsiveness 2025-10-04 15:50:18 -04:00
thePR0M3TH3AN be0e16efec Merge pull request #280 from PR0M3TH3AN/codex/perform-manual-qa-for-app-features
Add manual QA summary for 2024-05-10
2025-10-04 15:39:39 -04:00
thePR0M3TH3AN 205a9fdbda Add manual QA summary for 2024-05-10 2025-10-04 15:31:24 -04:00
thePR0M3TH3AN 88ad16326b Merge pull request #279 from PR0M3TH3AN/codex/refactor-application-initialization-and-exports
Refactor application bootstrap to run on DOM ready
2025-10-04 13:47:22 -04:00
thePR0M3TH3AN 8a73d6d1fb Refactor application bootstrap to run on DOM ready 2025-10-04 13:41:26 -04:00
thePR0M3TH3AN 7b324593c5 Merge pull request #278 from PR0M3TH3AN/codex/refactor-bitvidapp-to-application-class
Refactor app orchestrator into Application with dependency injection
2025-10-04 13:24:08 -04:00
thePR0M3TH3AN 1421a0c198 Parallelize application bootstrap tasks 2025-10-04 12:58:06 -04:00
thePR0M3TH3AN 21b095e45f Fix duplicate magnet validator definition 2025-10-03 22:24:14 -04:00
thePR0M3TH3AN cf34e28a47 Refactor app orchestrator into Application with dependency injection 2025-10-03 21:10:22 -04:00
thePR0M3TH3AN ef6b8bf1cf Merge pull request #277 from PR0M3TH3AN/codex/refactor-modals-and-component-initialization
Refactor video UI component orchestration
2025-10-03 20:08:08 -04:00
thePR0M3TH3AN ad58ff04a3 Refactor video UI component orchestration 2025-10-03 19:58:21 -04:00
thePR0M3TH3AN f579c965a7 Merge pull request #276 from PR0M3TH3AN/codex/add-videolistview-class-with-dependencies
Refactor video list rendering into VideoListView
2025-10-03 16:36:30 -04:00
thePR0M3TH3AN b46135cd36 Refactor video list rendering into dedicated view 2025-10-03 16:35:32 -04:00
thePR0M3TH3AN 8baeab2b0f Merge pull request #275 from PR0M3TH3AN/codex/refactor-modals-in-js/app.js
Ensure VideoModal poster cleanup uses component API
2025-10-03 15:56:12 -04:00
thePR0M3TH3AN f33cd66642 Call VideoModal cleanup hooks before tearing down video 2025-10-03 15:54:24 -04:00
thePR0M3TH3AN e0a9fae4c8 Merge pull request #274 from PR0M3TH3AN/codex/create-videocard-component-for-video-handling
Refactor video list rendering into reusable VideoCard component
2025-10-03 15:50:18 -04:00
thePR0M3TH3AN 2fb2a83272 Refactor video list rendering into reusable VideoCard component 2025-10-03 15:48:48 -04:00
thePR0M3TH3AN 2e77cdbe04 Merge pull request #273 from PR0M3TH3AN/codex/add-revertmodal-class-and-initialize-methods
Refactor revert modal into standalone component
2025-10-03 15:33:14 -04:00
thePR0M3TH3AN 361185ec02 Refactor revert modal into standalone component 2025-10-03 15:28:35 -04:00
thePR0M3TH3AN 0abd4249d1 Merge pull request #272 from PR0M3TH3AN/codex/refactor-editmodal-functionality-and-methods
Refactor edit modal into component
2025-10-03 15:17:07 -04:00
thePR0M3TH3AN 1e1600984c Refactor edit modal into component 2025-10-03 15:15:25 -04:00
thePR0M3TH3AN 05a3254372 Merge pull request #271 from PR0M3TH3AN/codex/implement-uploadmodal-class-and-logic
Refactor upload modal into component
2025-10-03 15:05:27 -04:00
thePR0M3TH3AN 2cfcbc8366 Refactor upload modal into component 2025-10-03 14:58:33 -04:00
thePR0M3TH3AN 7c295b57be Merge pull request #270 from PR0M3TH3AN/codex/implement-videomodal-class-and-features
Refactor video modal into reusable component
2025-10-03 14:38:01 -04:00
thePR0M3TH3AN cbf5203dcf Refactor video modal into reusable component 2025-10-03 14:35:19 -04:00
thePR0M3TH3AN 97e74875ae Merge pull request #269 from PR0M3TH3AN/codex/implement-nostrservice-for-video-handling
Refactor video handling through nostr service
2025-10-03 13:49:44 -04:00
thePR0M3TH3AN 61815ba289 Add nostr service layer for video operations 2025-10-03 13:41:44 -04:00
thePR0M3TH3AN 9fa359f9b5 Merge pull request #268 from PR0M3TH3AN/codex/build-authservice-for-login-and-profiles
Refactor authentication handling into dedicated service
2025-10-03 12:42:44 -04:00
thePR0M3TH3AN 3d04c90ddc refactor: introduce auth service 2025-10-03 12:39:17 -04:00
thePR0M3TH3AN 1dfb9f2877 Merge pull request #267 from PR0M3TH3AN/codex/create-playback-service-and-update-ui
Refactor playback into service
2025-10-02 23:02:46 -04:00
thePR0M3TH3AN 2b42d0ada6 Refactor playback into service 2025-10-02 22:59:12 -04:00
thePR0M3TH3AN 8a4ab536da Merge pull request #266 from PR0M3TH3AN/codex/add-r2service-and-update-app.js-methods
Refactor Cloudflare R2 upload logic into service
2025-10-02 22:34:00 -04:00
thePR0M3TH3AN 06c979569f Refactor Cloudflare R2 handling into dedicated service 2025-10-02 22:29:54 -04:00
thePR0M3TH3AN 9498ba3465 Merge pull request #265 from PR0M3TH3AN/codex/create-global-state-and-cache-management-modules
Fix duplicate url health reader in cache module
2025-10-02 22:06:06 -04:00
thePR0M3TH3AN 2e26242153 Fix duplicate url health reader in cache module 2025-10-02 22:03:12 -04:00
thePR0M3TH3AN d950fd9470 Merge pull request #264 from PR0M3TH3AN/codex/refactor-utilities-into-dedicated-modules
Refactor shared helpers into utility modules
2025-10-02 21:37:48 -04:00
thePR0M3TH3AN 2e6e7012a6 Refactor shared helpers into utility modules 2025-10-02 21:33:56 -04:00
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
thePR0M3TH3AN 1d1655d2a7 Merge pull request #166 from PR0M3TH3AN/unstable
Unstable
2025-09-30 09:10:39 -04:00
thePR0M3TH3AN 4ebc6f6f4a Merge pull request #165 from PR0M3TH3AN/codex/ensure-notification-box-visibility-on-modals
Ensure notifications appear over modal overlays
2025-09-30 09:06:45 -04:00
thePR0M3TH3AN db408f4b06 Raise notification portal above modals 2025-09-30 09:05:03 -04:00
thePR0M3TH3AN f832695a6a Ensure notifications appear above modals 2025-09-30 08:59:18 -04:00
thePR0M3TH3AN 213f92f496 Merge pull request #164 from PR0M3TH3AN/codex/update-moderation-list-loading-indication
Replace moderation relay warning with loading status
2025-09-30 00:01:10 -04:00
thePR0M3TH3AN 96e8adc553 Ensure moderation loading banner clears after sync 2025-09-29 23:59:52 -04:00
thePR0M3TH3AN 233c50d238 Show moderation filter loading status 2025-09-29 23:51:20 -04:00
thePR0M3TH3AN 7937410e87 Merge pull request #163 from PR0M3TH3AN/codex/update-rendersavedprofiles-for-metadata-fetching
Auto-fetch metadata for saved profiles lacking hints
2025-09-29 23:46:53 -04:00
thePR0M3TH3AN b50996b907 Auto-fetch saved profile metadata 2025-09-29 23:38:22 -04:00
thePR0M3TH3AN 3f4b56ae90 Merge pull request #162 from PR0M3TH3AN/codex/extend-profile-switching-functionality
Add profile switching flow to saved profile cards
2025-09-29 23:19:20 -04:00
thePR0M3TH3AN edbc3786eb Enhance profile switching interactions 2025-09-29 23:18:37 -04:00
thePR0M3TH3AN c7419a7f74 Merge pull request #161 from PR0M3TH3AN/codex/enhance-nostrclient.login-with-options-object
Add NIP-07 profile selection flow to profile switcher
2025-09-29 23:15:18 -04:00
thePR0M3TH3AN ee09b723af Add profile switching support for NIP-07 accounts 2025-09-29 23:14:52 -04:00
thePR0M3TH3AN c617fcbfda Merge pull request #160 from PR0M3TH3AN/codex/implement-profile-modal-rendering-logic
Refine saved profile rendering
2025-09-29 23:08:25 -04:00
thePR0M3TH3AN 622aa756cc Refine saved profile rendering 2025-09-29 23:07:45 -04:00
thePR0M3TH3AN c52199b1d0 Merge pull request #159 from PR0M3TH3AN/codex/refactor-login-function-and-update-logout-logic
Refine login flow and profile storage
2025-09-29 22:57:33 -04:00
thePR0M3TH3AN ec83b1222f Refine login flow and profile storage 2025-09-29 22:55:40 -04:00
thePR0M3TH3AN 033d3b0288 Merge pull request #158 from PR0M3TH3AN/codex/add-saved-profile-management-in-app.js
Persist saved Nostr profiles in localStorage
2025-09-29 22:40:53 -04:00
thePR0M3TH3AN 5cfdd79a18 Add saved Nostr profile storage helpers 2025-09-29 17:24:25 -04:00
thePR0M3TH3AN ce1f2a8f37 Merge pull request #157 from PR0M3TH3AN/codex/add-ui-for-new-nostr-profiles
Add UI affordance for managing multiple Nostr profiles
2025-09-29 16:01:10 -04:00
thePR0M3TH3AN 2b1d5488d6 Restore hidden profile panes 2025-09-29 15:58:12 -04:00
thePR0M3TH3AN e50780e4b3 Consolidate profile modal account styling 2025-09-29 15:10:53 -04:00
thePR0M3TH3AN c2846f9348 Polish profile switcher card styling 2025-09-29 14:51:05 -04:00
thePR0M3TH3AN 4266d0bd18 Refine account cards in profile modal 2025-09-29 14:39:23 -04:00
thePR0M3TH3AN 711df9bf07 Ensure full npub wraps in profile modal 2025-09-29 14:27:45 -04:00
thePR0M3TH3AN 24328c4c52 Refine active profile layout in modal 2025-09-29 14:23:18 -04:00
thePR0M3TH3AN d15c574b13 Restyle profile switcher active row 2025-09-29 14:15:10 -04:00
thePR0M3TH3AN 198fd97a80 Adjust profile switcher layout 2025-09-29 14:09:48 -04:00
thePR0M3TH3AN c285bd7e9c Stack profile switcher list vertically 2025-09-29 13:59:07 -04:00
thePR0M3TH3AN 55774f588e Add UI affordance for managing multiple Nostr profiles 2025-09-29 13:52:34 -04:00
158 changed files with 62693 additions and 18126 deletions

No files matched your search

+85 -9
View File
@@ -8,14 +8,14 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
* **Main** is the production track. Anything merged here must preserve today’s UX and magnet safety guarantees. Rollbacks should be painless: keep commits atomic, avoid destructive migrations, and leave feature flags in a known-good default (`false` unless product explicitly flips them).
* **Unstable** is our experimentation lane. Gate risky behavior behind feature flags defined in `js/constants.js` and document the toggle/rollback plan in PR descriptions.
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately and annotate the AGENTS.md changelog with remediation tips so future agents do not repeat the mistake.
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately, call this out in the PR, and annotate the AGENTS.md changelog with remediation tips. **Note:** These regressions usually surface in dev logs and tests around playback/magnet helpers—reference them when triaging so future agents don’t repeat the mistake.
## 2. Mission: URL‑First Playback with WebTorrent Fallback
* **Goal:** Always deliver smooth playback while keeping hosting costs low.
* **Primary transport:** A hosted video URL (MP4/WebM/HLS/DASH) that the `<video>` element can stream directly.
* **Fallback transport:** A WebTorrent magnet that includes browser‑safe trackers plus optional HTTP hints.
* **Runtime behavior:** Call `playVideoWithFallback({ url, magnet })` (see `js/playbackUtils.js`). It probes the URL first; only when that fails should WebTorrent start.
* **Runtime behavior:** Call `bitvidApp.playVideoWithFallback({ url, magnet })` in `js/app.js`. This function orchestrates URL probing and magnet fallback, delegating low-level stream handling to `js/services/playbackService.js`. `js/playbackUtils.js` now provides magnet/session helper utilities rather than the playback entry point itself.
* **Content contract:** Bitvid posts (Nostr kind `30078`) must include a `title` and at least one of `url` or `magnet`. Prefer to publish both along with optional `thumbnail`, `description`, and `mode` fields. When mirroring to NIP‑94 (`kind 1063`), copy the hosted URL and (optionally) the magnet so other clients can discover the same asset.
---
@@ -24,9 +24,10 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
* **Use the helpers every time:** Always run inbound values through `safeDecodeMagnet()` before playback or normalization, and call `normalizeAndAugmentMagnet()` (see `js/magnetUtils.js`) to append `ws=` / `xs=` hints without mutating hashes.
* **Keep magnets raw:** Persist the literal `magnet:?xt=urn:btih:...` string. Decode once, then feed that exact value to WebTorrent or storage. Never rely on re-encoding helpers that might alter casing.
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and can corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
* **HTTP hints:** Encourage HTTPS `ws=` web seeds (file roots only) and HTTPS `xs=` pointers to `.torrent` files so fallback peers warm quickly.
* **Tracker policy:** Browser code must ship WSS trackers only. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers.
* **Tracker policy:** Browser code must ship **WSS trackers only**. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers. (If you introduce a new tracker set or feature flag, update `js/constants.js` and mention it here.)
* **Validation reference:** When in doubt about legacy infohash handling or normalization, consult the existing tests that cover legacy/bare infohash inputs and magnet normalization. If you extend helper behavior, extend those tests alongside the code.
---
@@ -46,29 +47,41 @@ This guide tells AI agents how to keep Bitvid aligned with the current product d
Run this script before shipping notable UI changes, especially around upload/playback flows. Automate when possible, but manual verification is required for releases.
1. **Smoke test the modal**
* Launch the site, open the Upload modal, and confirm required-field validation (title + one of URL/magnet).
* Submit with URL only, magnet only, and both; ensure success states are clear.
2. **URL-first playback**
* Publish a post with both URL and magnet. Load the card and verify the player fetches the hosted URL first (check network tab or logs).
* Temporarily block the URL (e.g., devtools throttling or offline). Confirm playback seamlessly falls back to the magnet.
3. **Magnet hygiene**
* Paste an encoded magnet; ensure `safeDecodeMagnet()` returns the raw value and the final string still includes the original `xt` hash.
* Confirm `normalizeAndAugmentMagnet()` appends `ws=`/`xs=` hints without corrupting parameters.
4. **P2P hints**
* Verify magnets authored through the modal include HTTPS `ws=` and optional `xs=` values when provided.
* Ensure tracker lists come from `js/constants.js` and remain WSS-only.
5. **Cross-browser sanity**
* Spot-check playback in Chromium and Firefox (desktop) to ensure no console errors related to CORS, Range requests, or tracker connections.
Document the run in PR descriptions so QA can cross-reference results.
---
## 6. Additional Notes for Agents
## 6. Moderation Quickstart (Bitvid)
* **Nostr interoperability:** Keep kind `30078` events as the source of truth and optionally mirror to kind `1063` so external clients see the hosted URL.
* **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI.
* **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above.
The moderation and admin list tooling remain active in Bitvid and must follow Nostr moderation policies and QA steps. Review `docs/moderation/README.md` and these key expectations:
1. **Report thresholds:** Parse NIP-56 reports and compute `trustedReportCount` using **only F1 followers**.
2. **Default behaviors:** Blur thumbnails at ≥3 F1 `nudity` reports and block autoplay at ≥2.
3. **Admin lists:** Use NIP‑51 lists — `10000` for mute/downrank and `30000` for admin hard-hide/whitelist. Ensure these list IDs are correctly handled in the profile modal.
4. **Profile Modal Integration:** Admin list toggles and moderation controls in `ProfileModalController` must trigger local state changes and reflect mutations visually without breaking cached profiles.
5. **User overrides:** Add a “show anyway” option to any blurred or blocked content.
6. **Testing:** Run all cases from `docs/moderation/testing.md` before shipping changes. Confirm blur, hide, and override flows function correctly across sessions.
7. **Telemetry and privacy:** Log moderation decisions locally for debugging without leaking PII.
8. **QA demo:** Record a ≤90 s screen demo showing blur/hide/override flows when submitting major moderation work.
---
@@ -78,7 +91,70 @@ Document the run in PR descriptions so QA can cross-reference results.
`{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`.
* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable.
* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied.
* **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.
* **Playback orchestration:** `bitvidApp.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.
---
## 8. Architectural Guide: `bitvidApp` vs. UI Controllers
To keep the codebase maintainable, we are moving complex UI logic from `js/app.js` into dedicated controller classes (e.g., `ProfileModalController`, `UploadModal`). **This is an architectural direction for `unstable`**—some controllers may not exist yet. When working on these components, follow these core principles:
#### **Principle: Separation of Concerns**
* **`bitvidApp` (js/app.js): The Orchestrator.** Its main job is to manage application-wide state, wire up services, and orchestrate high-level actions. It should **not** directly manipulate the DOM elements of complex components like modals. Instead, it should instantiate their controllers and communicate with them through a clean API.
* **UI Controllers (e.g., `js/ui/profileModalController.js`): The Specialists.** A controller is responsible for a specific piece of the UI. It manages its own DOM elements, handles user interactions within its scope, and uses services and callbacks provided by `bitvidApp` to perform actions.
#### **State Management: The Source of Truth**
The single source of truth for shared application state (like saved profiles, relay lists, and block lists) lives in modules like `js/state/cache.js` and services like `relayManager`.
1. **`bitvidApp` Writes State:** `bitvidApp` is responsible for calling the functions that mutate this central state (e.g., `setSavedProfiles`, `userBlocks.addBlock`).
2. **`bitvidApp` Notifies the Controller:** After updating the state, `bitvidApp` must call a method on the controller to tell it that its data is stale and it needs to re-render.
* **Example:** When a user logs in, `bitvidApp` updates the active user and saved profiles, then calls `this.profileModalController.handleAuthLogin(detail)` so the modal can update its own display.
3. **Controller Reads State:** The controller receives functions to *read* this state in its constructor (via the `state` and `services` objects). It should **always** use these functions (e.g., `this.state.getSavedProfiles()`, `this.services.userBlocks.getBlocked()`) to get the data it needs to render.
#### **Execution Flow: Callbacks**
When a user performs an action inside a modal that needs to affect the whole application (like switching a profile or updating relays), the flow is:
1. **User Action:** A user clicks a "Switch Profile" button inside the modal.
2. **Controller Event Handler:** The controller's internal event listener fires.
3. **Controller Invokes Callback:** The controller calls a callback function passed to it by `bitvidApp` (e.g., `this.callbacks.onRequestSwitchProfile({ pubkey })`). It does *not* contain the logic for switching profiles itself.
4. **`bitvidApp` Executes the Logic:** `bitvidApp` provides the implementation for that callback (e.g., `handleProfileSwitchRequest`). This method contains the actual logic to call the auth service, reload videos, and update the application state.
#### **Checklist for Refactoring from `bitvidApp` to a Controller:**
When moving a feature to a controller, follow these steps:
1. **Identify Logic:** Find the methods in `js/app.js` that directly read from or write to the modal's DOM (e.g., old `renderSavedProfiles` or `populateBlockedList` logic).
2. **Create Controller Method:** Implement the new logic inside the controller. Ensure it gets its data from `this.services.*` or `this.state.*`.
3. **Create `bitvidApp` Wrapper:** In `js/app.js`, replace the old logic with a new, thin wrapper method. This wrapper should:
* a. Update any central application state if needed.
* b. Call the new method on the controller instance (e.g., `this.profileModalController.populateBlockedList()`).
4. **Wire Up Callbacks:** If the controller needs to trigger an app-wide action, ensure a callback is defined in the controller's constructor and provided by `bitvidApp` during instantiation.
5. **Remove Old Code:** Once fully replaced, delete the old DOM manipulation logic and direct event listeners from `js/app.js`. All interactions should be routed through the controller.
#### **File Map (quick reference)**
* `js/app.js` — **`bitvidApp`** (orchestrator; owns high-level flows like `playVideoWithFallback` wiring)
* `js/services/playbackService.js` — Lower-level playback stream handling
* `js/playbackUtils.js` — Magnet/session helper utilities
* `js/magnetUtils.js` — `safeDecodeMagnet`, `normalizeAndAugmentMagnet` (WSS trackers, `ws=`/`xs=` hints)
* `js/constants.js` — Feature flags, canonical WSS tracker list
---
## 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**
+37 -14
View File
@@ -11,7 +11,7 @@
## Features
- **Decentralized Sharing**: Video sharing without central servers.
- **Private Video Listings**: Share encrypted videos for added privacy.
- **Private Video Listings**: Hide cards from shared grids so only the owner sees them.
- **Nostr Integration**: Use Nostr keys for login and interaction.
- **WebTorrent Streaming**: Stream videos directly through torrent technology.
- **Developer-Friendly**: Open source and customizable for your needs.
@@ -37,7 +37,7 @@ The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly
- **Web seeds (`ws=`)**: HTTPS only. Point to a file root (e.g., `https://cdn.example.com/video/`). Mixed-content (`http://`) hints are rejected just like the modal message explains.
- **Additional sources (`xs=`)**: Recommend adding an HTTPS `.torrent` link so WebTorrent peers can bootstrap faster.
- **Trackers**: Bitvid’s browser client only connects to WSS trackers shipped in `js/constants.js`. Do not add UDP or plaintext HTTP trackers to published magnets.
- **Private toggle**: Encrypts the listing for invited viewers.
- **Private toggle**: Hides the card from shared grids so only you see it and adds a purple accent so the private state stands out.
### How playback works
@@ -62,7 +62,6 @@ To run **bitvid** locally:
```
2. Start a local server:
- Using Python:
```bash
python -m http.server 8000
@@ -73,24 +72,28 @@ To run **bitvid** locally:
```
3. Open the site in your browser:
```
http://localhost:8000
```
```
http://localhost:8000
```
### CSS build pipeline
Tailwind utilities are generated from `css/tailwind.source.css` using the build
script below. Run this whenever you add or remove classes in HTML or JavaScript
so the purged output stays in sync:
Tailwind utilities are generated from `css/tailwind.source.css` and themed via
the shared tokens in `css/tokens.css`. Install dependencies once and lean on the
package scripts to keep formatting, linting, and generated output consistent:
```bash
npm install # first run only
npm run build:css
npm install # install Prettier, Stylelint, and Tailwind toolchain
npm run format # format CSS/HTML/JS/MD with Prettier + tailwindcss plugin
npm run lint:css # enforce token usage and forbid raw hex colors
npm run build:css # rebuild css/tailwind.generated.css from tailwind.source.css
npm run check:css # CI-friendly guard that fails if tailwind.generated.css is dirty
```
The command compiles Tailwind with `tailwind.config.cjs`, runs it through the
PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and emits
the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
The build command compiles Tailwind with `tailwind.config.cjs`, runs it through
the PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and
emits the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
regenerated file alongside any template changes so deployments pick up the
latest styles.
@@ -100,6 +103,13 @@ latest styles.
- Central place for instance-specific values like the Super Admin npub and the
default whitelist-only mode setting. Update the documented exports here when
preparing a new deployment.
- Tune `PLATFORM_FEE_PERCENT` (0–100) to keep a percentage of Lightning tips.
When the fee is positive, BitVid routes the platform’s split to
`PLATFORM_LUD16_OVERRIDE`, so set it to the Lightning address that should
receive the sats (or publish a `lud16` on the Super Admin profile). Leave
the fee at `0` to pass through every satoshi.
- Populate `DEFAULT_RELAY_URLS_OVERRIDE` with WSS URLs to replace the bundled
relay bootstrap list. Keep it empty to stick with the upstream defaults.
- **`config.js`**:
- Toggle `isDevMode` for development (`true`) or production (`false`).
- **`js/constants.js`**:
@@ -107,6 +117,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.
@@ -156,6 +173,12 @@ Use the manual QA checklist before releases or when altering upload/playback flo
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
For automated DOM-focused regression coverage, run the Node-based test suite:
```bash
node tests/profile-modal-controller.test.mjs
```
---
## Acknowledgments
+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

+621
View File
@@ -73,6 +73,572 @@
</div>
</div>
<!-- NOTE: Keep this NIP-71 markup in sync with the upload and revert modals. -->
<div
class="space-y-4 rounded-lg border border-gray-800 bg-black/30 p-4"
id="editNip71EventDetails"
data-nip71-section="event-details"
>
<fieldset
id="editNip71KindFieldset"
class="space-y-2"
data-nip71-fieldset="kind"
>
<legend class="text-sm font-semibold text-gray-200">
NIP-71 event type
</legend>
<p class="text-xs text-gray-400">
Choose the video format. <span class="font-medium text-gray-300">Kind 21</span> is a full video, while
<span class="font-medium text-gray-300">Kind 22</span> is a short.
</p>
<div
class="flex flex-wrap gap-4"
role="radiogroup"
aria-labelledby="editNip71KindFieldset"
>
<label class="flex items-center gap-2 text-sm text-gray-200">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindLong"
value="21"
checked
data-nip71-input="kind"
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
/>
Normal video (kind 21)
</label>
<label class="flex items-center gap-2 text-sm text-gray-200">
<input
type="radio"
name="editNip71Kind"
id="editNip71KindShort"
value="22"
data-nip71-input="kind"
class="h-4 w-4 border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
/>
Short video (kind 22)
</label>
</div>
</fieldset>
<div class="grid gap-4 md:grid-cols-2" data-nip71-grid="metadata">
<div>
<label for="editNip71PublishedAt" class="block text-sm font-medium text-gray-200">
Published at
</label>
<input
type="datetime-local"
id="editNip71PublishedAt"
data-nip71-input="published_at"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">
Set the canonical publish time for cross-client ordering.
</p>
</div>
<div>
<label for="editNip71AltText" class="block text-sm font-medium text-gray-200">
Alt text
</label>
<input
type="text"
id="editNip71AltText"
data-nip71-input="alt"
placeholder="Describe the visuals for screen readers"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">
Plain-language fallback shown when the media cannot load.
</p>
</div>
<div>
<label for="editNip71Duration" class="block text-sm font-medium text-gray-200">
Duration (seconds)
</label>
<input
type="number"
id="editNip71Duration"
min="0"
step="1"
data-nip71-input="duration"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">
Provide the runtime in whole seconds for timeline scrubbing.
</p>
</div>
<div>
<label for="editNip71ContentWarning" class="block text-sm font-medium text-gray-200">
Content warning
</label>
<input
type="text"
id="editNip71ContentWarning"
data-nip71-input="content-warning"
placeholder="e.g., Flashing lights"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">
Leave blank if the video is safe for all audiences.
</p>
</div>
</div>
<div>
<label for="editNip71Summary" class="block text-sm font-medium text-gray-200">
Summary
</label>
<textarea
id="editNip71Summary"
rows="3"
data-nip71-input="summary"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="Short synopsis used as the Nostr content field"
></textarea>
<p class="mt-1 text-xs text-gray-400">
This becomes the event content. Keep it concise for Nostr readers.
</p>
</div>
</div>
<fieldset
id="editNip71TagsFieldset"
class="space-y-6 rounded-lg border border-gray-800 bg-black/30 p-4"
data-nip71-section="tags"
>
<legend class="relative text-sm font-semibold text-gray-200">
<span class="inline-block -ml-2 rounded px-2 bg-black/30">NIP-71 tags</span>
</legend>
<p class="text-xs text-gray-400">
Use the controls below to describe media variants, captions, chapters, participants, and references.
</p>
<section class="space-y-4" aria-labelledby="editNip71ImetaLabel" data-nip71-repeater="imeta">
<div class="flex items-center justify-between">
<h3 id="editNip71ImetaLabel" class="text-sm font-medium text-gray-200">Image metadata (imeta)</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="imeta"
aria-describedby="editNip71ImetaHelp"
>
Add variant
</button>
</div>
<p id="editNip71ImetaHelp" class="text-xs text-gray-400">
Provide dimensions, MIME type, and resource links for each variant. Nested repeaters let you attach multiple
<code>image</code>, <code>fallback</code>, or <code>service</code> values.
</p>
<div id="editNip71ImetaList" class="space-y-4" data-nip71-list="imeta"></div>
<template id="editNip71ImetaTemplate" data-nip71-template="imeta">
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="imeta">
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-xs font-medium text-gray-300">
MIME type (<code>m</code>)
</label>
<input
type="text"
data-nip71-field="m"
placeholder="video/mp4"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-[11px] text-gray-500">RFC 2046 media type for this encoding.</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">
Pixel dimensions (<code>dim</code>)
</label>
<input
type="text"
data-nip71-field="dim"
placeholder="1920x1080"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-[11px] text-gray-500">Formatted as widthxheight.</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">
Streaming URL (<code>url</code>)
</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/variant.m3u8"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-[11px] text-gray-500">HTTPS variant URL shared across clients.</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">
Magnet (<code>x</code>)
</label>
<input
type="text"
data-nip71-field="x"
placeholder="magnet:?xt=urn:btih:..."
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<p class="mt-1 text-[11px] text-gray-500">Optional magnet pointer if the variant is seeded via P2P.</p>
</div>
</div>
<div class="space-y-3" data-nip71-nested="image">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Image values</h4>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-nested-add="image"
>
Add image URL
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="image"></div>
<template data-nip71-nested-template="image">
<div class="flex items-center gap-2" data-nip71-nested-entry="image">
<input
type="url"
placeholder="https://cdn.example.com/poster.jpg"
data-nip71-nested-field="image"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="nested"
aria-label="Remove image URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="fallback">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Fallback URLs</h4>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-nested-add="fallback"
>
Add fallback
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="fallback"></div>
<template data-nip71-nested-template="fallback">
<div class="flex items-center gap-2" data-nip71-nested-entry="fallback">
<input
type="url"
placeholder="https://cdn.backup.net/video.mp4"
data-nip71-nested-field="fallback"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="nested"
aria-label="Remove fallback URL"
>
Remove
</button>
</div>
</template>
</div>
<div class="space-y-3" data-nip71-nested="service">
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">Services</h4>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-nested-add="service"
>
Add service
</button>
</div>
<div class="space-y-2" data-nip71-nested-list="service"></div>
<template data-nip71-nested-template="service">
<div class="flex items-center gap-2" data-nip71-nested-entry="service">
<input
type="text"
placeholder="https://api.example.com"
data-nip71-nested-field="service"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="nested"
aria-label="Remove service entry"
>
Remove
</button>
</div>
</template>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="imeta"
>
Remove variant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71TextTracksLabel" data-nip71-repeater="text-track">
<div class="flex items-center justify-between">
<h3 id="editNip71TextTracksLabel" class="text-sm font-medium text-gray-200">
Caption &amp; subtitle tracks (text-track)
</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="text-track"
aria-describedby="editNip71TextTracksHelp"
>
Add track
</button>
</div>
<p id="editNip71TextTracksHelp" class="text-xs text-gray-400">
Supply caption or subtitle URLs along with file type and language so players can surface accessibility features.
</p>
<div id="editNip71TextTrackList" class="space-y-4" data-nip71-list="text-track"></div>
<template id="editNip71TextTrackTemplate" data-nip71-template="text-track">
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="text-track">
<div class="grid gap-3 md:grid-cols-3">
<div>
<label class="block text-xs font-medium text-gray-300">Track URL</label>
<input
type="url"
data-nip71-field="url"
placeholder="https://cdn.example.com/subs.vtt"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">Type</label>
<input
type="text"
data-nip71-field="type"
placeholder="text/vtt"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">Language</label>
<input
type="text"
data-nip71-field="language"
placeholder="en"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="text-track"
>
Remove track
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71SegmentsLabel" data-nip71-repeater="segment">
<div class="flex items-center justify-between">
<h3 id="editNip71SegmentsLabel" class="text-sm font-medium text-gray-200">Chapters &amp; segments (segment)</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="segment"
aria-describedby="editNip71SegmentsHelp"
>
Add segment
</button>
</div>
<p id="editNip71SegmentsHelp" class="text-xs text-gray-400">
Outline chapters with start and end times, titles, and optional thumbnails.
</p>
<div id="editNip71SegmentList" class="space-y-4" data-nip71-list="segment"></div>
<template id="editNip71SegmentTemplate" data-nip71-template="segment">
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="segment">
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-xs font-medium text-gray-300">Start (seconds)</label>
<input
type="number"
data-nip71-field="start"
min="0"
step="1"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">End (seconds)</label>
<input
type="number"
data-nip71-field="end"
min="0"
step="1"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">Title</label>
<input
type="text"
data-nip71-field="title"
placeholder="Chapter title"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">Thumbnail URL</label>
<input
type="url"
data-nip71-field="thumbnail"
placeholder="https://cdn.example.com/frame.jpg"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="segment"
>
Remove segment
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71HashtagsLabel" data-nip71-repeater="t">
<div class="flex items-center justify-between">
<h3 id="editNip71HashtagsLabel" class="text-sm font-medium text-gray-200">Hashtags (<code>t</code>)</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="t"
>
Add hashtag
</button>
</div>
<p class="text-xs text-gray-400">
Enter keywords; leading <code>#</code> characters are stripped automatically when saving.
</p>
<div id="editNip71HashtagList" class="space-y-2" data-nip71-list="t"></div>
<template id="editNip71HashtagTemplate" data-nip71-template="t">
<div class="flex items-center gap-2" data-nip71-entry="t">
<input
type="text"
data-nip71-field="value"
placeholder="#nostr"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="t"
>
Remove
</button>
</div>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71ParticipantsLabel" data-nip71-repeater="p">
<div class="flex items-center justify-between">
<h3 id="editNip71ParticipantsLabel" class="text-sm font-medium text-gray-200">Participants (<code>p</code>)</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="p"
>
Add participant
</button>
</div>
<div id="editNip71ParticipantList" class="space-y-3" data-nip71-list="p"></div>
<template id="editNip71ParticipantTemplate" data-nip71-template="p">
<article class="space-y-3 rounded-md border border-gray-800 bg-gray-900/60 p-4" data-nip71-entry="p">
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-xs font-medium text-gray-300">Participant pubkey</label>
<input
type="text"
data-nip71-field="pubkey"
placeholder="npub1... or hex"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-300">Relay hint</label>
<input
type="text"
data-nip71-field="relay"
placeholder="wss://relay.example.com"
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="p"
>
Remove participant
</button>
</div>
</article>
</template>
</section>
<section class="space-y-4" aria-labelledby="editNip71ReferencesLabel" data-nip71-repeater="r">
<div class="flex items-center justify-between">
<h3 id="editNip71ReferencesLabel" class="text-sm font-medium text-gray-200">Reference links (<code>r</code>)</h3>
<button
type="button"
class="rounded-md border border-gray-700 px-3 py-1 text-xs font-semibold text-gray-200 hover:border-gray-500 hover:text-white"
data-nip71-add="r"
>
Add reference
</button>
</div>
<div id="editNip71ReferenceList" class="space-y-2" data-nip71-list="r"></div>
<template id="editNip71ReferenceTemplate" data-nip71-template="r">
<div class="flex items-center gap-2" data-nip71-entry="r">
<input
type="url"
data-nip71-field="url"
placeholder="https://example.com/context"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
class="rounded border border-gray-700 px-2 py-1 text-[11px] text-gray-300 hover:border-gray-500 hover:text-white"
data-nip71-remove="r"
>
Remove
</button>
</div>
</template>
</section>
</fieldset>
<div class="space-y-1">
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
Hosted video URL (https)
@@ -199,6 +765,59 @@
</div>
</div>
<div class="space-y-1">
<span id="editVideoVisibilityLabel" class="block text-sm font-medium text-gray-200">
Visibility
</span>
<div
class="space-y-3 rounded-md border border-gray-800 bg-black/30 p-3"
role="group"
aria-labelledby="editVideoVisibilityLabel"
data-visibility-toggle
>
<div class="flex flex-wrap items-center gap-2 sm:justify-between">
<div
class="video-visibility-toggle"
data-visibility-options
>
<button
type="button"
class="video-visibility-toggle__option"
data-visibility-option="public"
aria-pressed="false"
>
Public
</button>
<button
type="button"
class="video-visibility-toggle__option"
data-visibility-option="private"
aria-pressed="false"
>
Private
</button>
</div>
<input
type="checkbox"
id="editVideoIsPrivate"
class="hidden"
data-default-checked="false"
aria-hidden="true"
/>
</div>
<p class="text-xs text-gray-400" data-visibility-helper>
Private notes stay visible only to you. Switch back to public when you're ready to share.
</p>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoIsPrivate"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editEnableComments" class="block text-sm font-medium text-gray-200">
Enable comments
@@ -211,6 +830,8 @@
type="checkbox"
id="editEnableComments"
class="h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
data-default-checked="true"
checked
/>
<span class="text-sm text-gray-300">
Allow replies once comment threads are available.
@@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>bitvid Whitelist Application Form</title>
<!-- Link to your main stylesheet -->
<link rel="stylesheet" href="../../css/style.css" />
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
<style>
/* Override for form page to match modal field styling */
@@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>bitvid Bug Report Form</title>
<!-- Link to your main stylesheet -->
<link rel="stylesheet" href="../../css/style.css" />
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
<style>
/* Override for form page to match modal field styling */
@@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>bitvid Content Appeals Form</title>
<!-- Link to your main stylesheet -->
<link rel="stylesheet" href="../../css/style.css" />
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
<style>
/* Override for form page to match modal field styling */
@@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>bitvid General Feedback Form</title>
<!-- Link to your main stylesheet -->
<link rel="stylesheet" href="../../css/style.css" />
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
<style>
/* Override for form page to match modal field styling */
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<title>bitvid Feature Request Form</title>
<!-- Link to your main stylesheet -->
<link rel="stylesheet" href="../../css/style.css" />
<link rel="stylesheet" href="../../css/style.css?v=2024.10.29" />
<style>
/* Override for form page to match modal field styling */
+290 -19
View File
@@ -79,6 +79,15 @@
>
Relays
</button>
<button
id="profileNavWallet"
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="profilePaneWallet"
aria-selected="false"
>
Wallet
</button>
<button
id="profileNavBlocked"
type="button"
@@ -88,6 +97,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"
@@ -107,27 +125,90 @@
role="tabpanel"
aria-labelledby="profileNavAccount"
>
<div class="flex items-center space-x-4">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Profile"
class="w-16 h-16 object-cover rounded-full"
/>
<p id="profileModalName" class="text-lg font-semibold truncate">
Loading...
<!--
The account pane now relies on bespoke CSS (see css/style.css under
"Profile modal account pane") so we are not dependent on utility
classes that may be purged or overridden by other builds.
-->
<div class="profile-switcher" id="profileSwitcherTray">
<div class="profile-switcher__header">
<div class="profile-switcher__intro">
<h3 class="profile-switcher__title">Switch profiles</h3>
<p class="profile-switcher__subtitle">
Save multiple Nostr profiles so you can swap identities without leaving BitVid.
</p>
</div>
<span class="profile-switcher__badge">Preview</span>
</div>
<div
class="profile-card profile-card--active"
aria-label="Current Nostr profile"
aria-pressed="true"
role="button"
tabindex="0"
>
<span class="profile-card__avatar">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Active profile avatar"
/>
</span>
<span class="profile-card__meta">
<span class="profile-card__topline">
<span class="profile-card__label">Active profile</span>
<a
id="profileChannelLink"
href="#"
class="profile-card__action hidden"
>
View channel
</a>
</span>
<span id="profileModalName" class="profile-card__name">Loading...</span>
<span id="profileModalNpub" class="profile-card__npub">npub1...</span>
</span>
</div>
<div
id="profileSwitcherList"
class="profile-switcher__list"
aria-live="polite"
aria-label="Additional Nostr profiles"
></div>
<button
type="button"
id="profileAddAccountBtn"
class="profile-switcher__add"
aria-label="Add a new Nostr profile"
>
<span class="profile-switcher__addAvatar">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
<span class="profile-switcher__addCopy">
<span class="profile-switcher__addTitle">Add profile</span>
<span class="profile-switcher__addHint">Link another npub</span>
</span>
</button>
<p class="profile-switcher__helper">
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
</p>
</div>
<a
id="profileChannelLink"
href="#"
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
>
View your channel
</a>
<p class="text-sm text-gray-400">
Manage your account details and upcoming preferences here.
</p>
</section>
<section
@@ -171,6 +252,71 @@
<ul id="relayList" class="space-y-3"></ul>
</section>
<section
id="profilePaneWallet"
class="profile-pane hidden space-y-6"
role="tabpanel"
aria-labelledby="profileNavWallet"
>
<div class="space-y-4">
<p class="text-sm text-gray-400">
Connect a Nostr Wallet Connect (NWC) compatible wallet to zap creators directly from BitVid.
</p>
<div class="space-y-4">
<label class="block text-sm text-gray-300" for="profileWalletUri">
<span class="mb-2 block font-medium">Wallet Connect URI</span>
<input
id="profileWalletUri"
type="url"
placeholder="nostr+walletconnect://<public-key>?relay=wss://relay.example.com"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
<label class="block text-sm text-gray-300" for="profileWalletDefaultZap">
<span class="mb-2 block font-medium">Default zap amount (sats)</span>
<input
id="profileWalletDefaultZap"
type="number"
inputmode="numeric"
min="0"
step="1"
placeholder="500"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
id="profileWalletSave"
type="button"
class="inline-flex items-center justify-center 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"
>
Save wallet
</button>
<button
id="profileWalletTest"
type="button"
class="inline-flex items-center justify-center 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-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Test connection
</button>
<button
id="profileWalletDisconnect"
type="button"
class="inline-flex items-center justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Disconnect wallet
</button>
</div>
<p
id="profileWalletStatus"
class="text-sm text-gray-400"
role="status"
aria-live="polite"
></p>
</div>
</section>
<section
id="profilePaneBlocked"
class="profile-pane hidden space-y-4"
@@ -209,6 +355,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"
+58 -2
View File
@@ -87,8 +87,64 @@
class="rounded-lg border border-gray-800 bg-gray-900/70 p-4 text-sm text-gray-200"
aria-live="polite"
>
<div id="revertVersionPlaceholder" class="flex h-full items-center justify-center text-center text-gray-500">
Select an earlier revision to inspect its metadata.
<!-- NOTE: Keep Upload/Edit metadata fields mirrored when adjusting these sections. -->
<div class="space-y-6" aria-labelledby="revertVersionPlaceholderHeading">
<div class="space-y-2 text-center">
<h3 id="revertVersionPlaceholderHeading" class="text-sm font-semibold text-gray-200">
Revision metadata
</h3>
<p
id="revertVersionPlaceholder"
class="text-sm text-gray-500"
>
Select an earlier revision to inspect its metadata.
</p>
</div>
<section class="space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
Playback sources
</h4>
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
<div>
<dt class="font-semibold text-gray-300">Hosted URL</dt>
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
</div>
<div>
<dt class="font-semibold text-gray-300">Magnet</dt>
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
</div>
</dl>
</section>
<section class="space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
NIP-71 event metadata
</h4>
<dl class="grid gap-2 text-xs text-gray-400 sm:grid-cols-2">
<div>
<dt class="font-semibold text-gray-300">Kind</dt>
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
</div>
<div>
<dt class="font-semibold text-gray-300">Duration</dt>
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
</div>
<div class="sm:col-span-2">
<dt class="font-semibold text-gray-300">Summary</dt>
<dd class="mt-1 text-gray-500">Awaiting selection</dd>
</div>
</dl>
</section>
<section class="space-y-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-400">
NIP-71 media metadata
</h4>
<p class="text-xs text-gray-500">
Media variants, text tracks, chapters, hashtags, participants, and references will appear once a revision is selected.
</p>
</section>
</div>
</section>
</div>
+139 -116
View File
@@ -5,139 +5,174 @@
-->
<aside
id="sidebar"
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto fade-in fade-in-delay-100"
class="fade-in fade-in-delay-100 sidebar-collapsed"
data-state="collapsed"
>
<div class="flex flex-col h-full">
<!-- Top Navigation Links -->
<div class="flex-1 overflow-y-auto px-4">
<div class="bg-gray-800 rounded-lg shadow-md mt-4 p-4">
<nav class="flex flex-col space-y-2">
<a
href="#view=most-recent-videos"
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
<div class="sidebar__inner">
<div class="sidebar-scroll">
<!-- Primary navigation -->
<section class="sidebar-panel sidebar-panel--primary">
<div class="sidebar-panel__header">
<button
id="sidebarCollapseToggle"
type="button"
class="sidebar-collapse-toggle"
aria-controls="sidebar"
aria-expanded="false"
aria-label="Expand sidebar"
title="Expand sidebar"
>
<svg
data-sidebar-chevron
class="sidebar-collapse-toggle__icon sidebar-toggle-icon--rotated"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 6l6 6-6 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav" aria-label="Primary">
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recent">
<img
src="assets/svg/recent-icon.svg"
alt="Recent"
class="w-6 h-6 mr-3 flex-shrink-0"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Recent</span>
</a>
<a
href="#view=explore"
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
>
<a href="#view=explore" class="sidebar-nav-link" title="Explore">
<img
src="assets/svg/explore-icon.svg"
alt="Explore"
class="w-6 h-6 mr-3 flex-shrink-0"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Explore</span>
</a>
<a href="#view=history" class="sidebar-nav-link" title="History">
<img
src="assets/svg/history-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">History</span>
</a>
<a
id="subscriptionsLink"
href="#view=subscriptions"
class="hidden flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
class="sidebar-nav-link hidden"
title="Subscriptions"
>
<img
src="assets/svg/subscriptions-icon.svg"
alt="Subscriptions"
class="w-6 h-6 mr-3 flex-shrink-0"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Subscriptions</span>
</a>
</nav>
</div>
</div>
</section>
<!-- Footer Links Toggle -->
<div class="md:mt-auto">
<div class="p-4">
<!-- Footer Links Toggle -->
<footer class="sidebar-footer" data-footer-state="collapsed">
<button
id="footerDropdownButton"
type="button"
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
class="sidebar-dropup-trigger"
aria-expanded="false"
aria-controls="footerLinksContainer"
>
<!-- Arrow on the left side -->
<img
src="assets/svg/ellipsis.svg"
alt=""
aria-hidden="true"
class="sidebar-icon sidebar-dropup-trigger__glyph"
/>
<span id="footerDropdownText" class="sidebar-text">More</span>
<svg
id="footerDropdownIcon"
class="w-4 h-4 mr-2 transform transition-transform duration-300"
class="sidebar-dropup-trigger__chevron"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
<path d="M6 9l6 6 6-6" />
</svg>
<span id="footerDropdownText">More</span>
</button>
</div>
<!-- Footer Links Container -->
<div id="footerLinksContainer" class="hidden">
<hr class="border-gray-700" />
<div class="p-4">
<nav class="space-y-2 text-sm opacity-70">
<a
href="#view=about"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<!-- Footer Links Container -->
<div
id="footerLinksContainer"
class="sidebar-dropup-panel"
aria-hidden="true"
data-state="collapsed"
>
<nav class="sidebar-dropup-panel__inner" aria-label="Secondary">
<a href="#view=about" class="sidebar-nav-link sidebar-nav-link--sub" title="About">
<img
src="assets/svg/about-icon.svg"
alt="About"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">About</span>
</a>
<a
href="#view=community-guidelines"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Community Guidelines"
>
<img
src="assets/svg/guidelines-icon.svg"
alt="Guidelines"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Guidelines</span>
</a>
<a
href="#view=getting-started"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Getting Started"
>
<img
src="assets/svg/getting-started-icon.svg"
alt="Getting Started"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Getting Started</span>
</a>
<a
href="#view=roadmap"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<a href="#view=roadmap" class="sidebar-nav-link sidebar-nav-link--sub" title="Roadmap">
<img
src="assets/svg/roadmap-icon.svg"
alt="Roadmap"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Roadmap</span>
</a>
<a
href="#view=blog"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<a href="#view=blog" class="sidebar-nav-link sidebar-nav-link--sub" title="Blog">
<img
src="assets/svg/blog-icon.svg"
alt="Blog"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Blog</span>
</a>
@@ -145,12 +180,14 @@
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Nostr"
>
<img
src="assets/svg/nostr-icon.svg"
alt="Nostr"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Nostr</span>
</a>
@@ -158,12 +195,14 @@
href="https://github.com/PR0M3TH3AN/bitvid"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="GitHub"
>
<img
src="assets/svg/github-icon.svg"
alt="GitHub"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">GitHub</span>
</a>
@@ -171,12 +210,14 @@
href="torrent/beacon.html"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="βeacon"
>
<img
src="assets/svg/beacon-icon.svg"
alt="βeacon"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">βeacon</span>
</a>
@@ -184,34 +225,32 @@
href="https://beta.bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="Beta"
>
<img
src="assets/svg/beta-icon.svg"
alt="Beta"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Beta</span>
</a>
<a
href="#view=links"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<a href="#view=links" class="sidebar-nav-link sidebar-nav-link--sub" title="Links">
<img
src="assets/svg/links-icon.svg"
alt="Links"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">Links</span>
</a>
<a
href="#view=ipns"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
>
<a href="#view=ipns" class="sidebar-nav-link sidebar-nav-link--sub" title="IPNS">
<img
src="assets/svg/ipns-icon.svg"
alt="IPNS"
class="w-5 h-5 mr-3"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">IPNS</span>
</a>
@@ -219,36 +258,20 @@
href="https://bitvid.network/"
target="_blank"
rel="noopener noreferrer"
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
class="sidebar-nav-link sidebar-nav-link--sub"
title="DNS"
>
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
<img
src="assets/svg/dns-icon.svg"
alt=""
aria-hidden="true"
class="sidebar-icon"
/>
<span class="sidebar-text">DNS</span>
</a>
</nav>
</div>
</div>
</footer>
</div>
</div>
<script>
(function () {
var btn = document.getElementById("footerDropdownButton");
var footerLinks = document.getElementById("footerLinksContainer");
if (btn && footerLinks) {
btn.addEventListener("click", function () {
footerLinks.classList.toggle("hidden");
var isHidden = footerLinks.classList.contains("hidden");
btn.setAttribute("aria-expanded", String(!isHidden));
var icon = document.getElementById("footerDropdownIcon");
var textSpan = document.getElementById("footerDropdownText");
if (textSpan) {
textSpan.textContent = isHidden ? "More" : "Less";
}
if (icon) {
icon.classList.toggle("rotate-180", !isHidden);
}
});
}
})();
</script>
</aside>
+1067 -124
View File
File diff suppressed because it is too large. Load diff
+154 -14
View File
@@ -35,18 +35,124 @@
class="icon-image"
/>
</button>
<button
id="modalZapBtn"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<div class="relative" data-modal-zap-wrapper>
<button
id="modalZapBtn"
type="button"
class="icon-button"
aria-label="Send a zap"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="modalZapDialog"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<div
id="modalZapDialog"
class="zap-popover hidden"
role="dialog"
aria-hidden="true"
>
<div
class="zap-popover__content w-72 max-w-[calc(100vw-2rem)] rounded-lg p-4 shadow-lg"
>
<div class="mb-3 flex items-center justify-between gap-4">
<h3
class="text-xs font-semibold uppercase tracking-wide text-gray-300"
>
Send a zap
</h3>
<button
type="button"
id="modalZapCloseBtn"
class="text-gray-400 transition-colors hover:text-gray-200"
aria-label="Close zap dialog"
>
✕
</button>
</div>
<p
id="modalZapWalletPrompt"
class="mb-3 hidden text-sm text-gray-200"
aria-hidden="true"
>
Connect a wallet in
<button
type="button"
id="modalZapWalletLink"
class="font-semibold text-blue-300 underline decoration-dotted transition-colors hover:text-blue-200"
>
Wallet Connect settings
</button>
to send zaps.
</p>
<form id="modalZapForm" class="space-y-3">
<div>
<label
for="modalZapAmountInput"
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
>
Zap amount (sats)
</label>
<input
id="modalZapAmountInput"
type="number"
min="1"
step="1"
inputmode="numeric"
class="w-full rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
placeholder="Enter sats"
/>
</div>
<div>
<label
for="modalZapCommentInput"
class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300"
>
Comment (optional)
</label>
<textarea
id="modalZapCommentInput"
rows="2"
maxlength="280"
class="w-full resize-y rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
placeholder="Include a note with your zap"
></textarea>
</div>
<p
id="modalZapSplitSummary"
class="text-sm text-gray-300"
aria-live="polite"
>
Enter an amount to view the split.
</p>
<p
id="modalZapStatus"
class="text-xs text-gray-400"
role="status"
aria-live="polite"
></p>
<ul
id="modalZapReceipts"
class="space-y-2 text-xs text-gray-300"
></ul>
<div class="flex items-center justify-end gap-2">
<button
type="submit"
id="modalZapSendBtn"
class="rounded bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Send
</button>
</div>
</form>
</div>
</div>
</div>
<button id="shareBtn" class="icon-button">
<img
src="assets/svg/share-video.svg"
@@ -90,6 +196,33 @@
>
Copy link
</button>
<div
class="px-4 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
>
Boost on Nostr…
</div>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="repost-event"
data-context="modal"
>
Repost (kind 6)
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="mirror-video"
data-context="modal"
>
Mirror (kind 1063)
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="ensure-presence"
data-context="modal"
>
Rebroadcast
</button>
<div class="my-1 border-t border-gray-700 opacity-70"></div>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
@@ -131,9 +264,16 @@
<!-- Video Timestamp -->
<div
class="flex items-center justify-between text-sm text-gray-400 mb-4"
class="flex items-start justify-between text-sm text-gray-400 mb-4"
>
<span id="videoTimestamp">just now</span>
<div class="flex flex-wrap items-center gap-2 modal-meta">
<span id="videoTimestamp">Posted just now</span>
<span
id="videoEditedTimestamp"
class="hidden"
>Last edited 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>
+1
View File
@@ -0,0 +1 @@
export const ASSET_VERSION = "2024.10.29";
+204
View File
@@ -25,6 +25,67 @@
export const ADMIN_SUPER_NPUB =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
/**
* Canonical URL for the public BitVid site.
*
* Surfaces in admin outreach copy and moderation DMs so that recipients can
* quickly jump back to the primary destination. Update this if your
* deployment relies on a different canonical hostname.
*/
export const BITVID_WEBSITE_URL = "https://bitvid.network/";
/**
* Default image included in automated moderation DMs.
*
* BitVid embeds this media asset at the top of notification messages so the
* payload renders with a recognizable preview. Provide a fully qualified URL
* that points to a hosted image accessible by the intended recipients.
*/
export const ADMIN_DM_IMAGE_URL =
"https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg";
/**
* Maximum satoshi value allowed when storing the default zap amount.
*
* Wallet settings clamp user input to this value before persistence to guard
* against accidental overpayment. Tune the ceiling to match your instance's
* risk tolerance.
*/
export const MAX_WALLET_DEFAULT_ZAP = 100000000;
/**
* Percentage of every Lightning payment the platform retains as a fee.
*
* Accepts numbers between 0 and 100 (inclusive). Decimals are supported when
* you want to keep a fractional cut—e.g., `2.5` represents a 2.5% fee. The
* default of `0` disables the fee so creators receive the full payment.
*/
export const PLATFORM_FEE_PERCENT = 30;
/**
* Lightning address to fall back to when authors omit their own `lud16`.
*
* Supply a string like `"[email protected]"` to force a deployment-wide
* fallback Lightning address. When `PLATFORM_FEE_PERCENT` is greater than 0,
* this override also acts as the Lightning target for the platform’s split.
* Leave the value as `null` to respect the creator’s metadata and rely on the
* Super Admin profile publishing a `lud16` so BitVid still knows where to route
* fees when they are enabled.
*/
export const PLATFORM_LUD16_OVERRIDE = "[email protected]";
/**
* Optional list of relays to seed new sessions with instead of the defaults.
*
* Provide WSS URLs (e.g., `"wss://relay.example.com"`). Leave the array empty
* to keep BitVid’s bundled defaults. Operators that need a custom bootstrap set
* should list the relays in priority order; entries later in the list are used
* as fallbacks when earlier relays fail.
*/
export const DEFAULT_RELAY_URLS_OVERRIDE = Object.freeze([
// "wss://relay.example.com",
]);
/**
* Storage key used to persist whitelist-only mode in the browser.
*
@@ -44,3 +105,146 @@ 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 = 10;
/**
* 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;
/**
* Cooldown window (in seconds) between manual "Rebroadcast" attempts.
*
* Rebroadcasting is an escape hatch for creators to nudge stale relays. A
* short cooldown keeps this safeguard from turning into an accidental DDoS.
* Five minutes provides enough breathing room for relays to catch up while
* still letting operators retry if a publish genuinely failed.
*/
export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60;
+85
View File
@@ -0,0 +1,85 @@
// config/validate-config.js
// -----------------------------------------------------------------------------
// Runtime validation helpers for instance-level configuration
// -----------------------------------------------------------------------------
import {
ADMIN_SUPER_NPUB,
PLATFORM_FEE_PERCENT,
PLATFORM_LUD16_OVERRIDE,
} from "./instance-config.js";
function assertSuperAdminConfigured(value) {
if (typeof value !== "string") {
throw new Error(
"ADMIN_SUPER_NPUB must be a string exported from config/instance-config.js."
);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(
"ADMIN_SUPER_NPUB is required. Populate it with the Super Admin npub in config/instance-config.js."
);
}
if (!trimmed.startsWith("npub")) {
throw new Error(
"ADMIN_SUPER_NPUB should be a bech32 npub (e.g., starting with 'npub')."
);
}
return trimmed;
}
function assertPlatformFeeInRange(value) {
const numericValue =
typeof value === "number" ? value : Number.parseFloat(value);
if (!Number.isFinite(numericValue)) {
throw new Error(
"PLATFORM_FEE_PERCENT must be a finite number between 0 and 100."
);
}
if (numericValue < 0 || numericValue > 100) {
throw new Error("PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive.");
}
return numericValue;
}
function getPlatformLud16Override(value) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function resolveConfig(overrides = {}) {
return {
adminSuperNpub:
overrides.ADMIN_SUPER_NPUB ?? ADMIN_SUPER_NPUB,
platformFeePercent:
overrides.PLATFORM_FEE_PERCENT ?? PLATFORM_FEE_PERCENT,
platformLud16Override:
overrides.PLATFORM_LUD16_OVERRIDE ?? PLATFORM_LUD16_OVERRIDE,
};
}
export function validateInstanceConfig(overrides) {
const config = resolveConfig(overrides);
assertSuperAdminConfigured(config.adminSuperNpub);
const platformFeePercent = assertPlatformFeeInRange(
config.platformFeePercent
);
const trimmedOverride = getPlatformLud16Override(
config.platformLud16Override
);
if (platformFeePercent > 0 && !trimmedOverride) {
throw new Error(
"PLATFORM_FEE_PERCENT is positive but PLATFORM_LUD16_OVERRIDE is empty. Set PLATFORM_LUD16_OVERRIDE to the Lightning address that should receive the platform's split, or publish a lud16 value on the Super Admin profile so BitVid can route platform fees."
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
- Fix "Dev Mode" publishing "Live Mode" notes—add a flag for dev mode posts.
- Fix issue with video post set to private.
- Disable "private video" until I can make it work better.
- Make "private video" work better using nip-04 encryption for magnet field.
- Make "private video" work better by hiding cards from shared grids while keeping an owner-only view.
- Fix slow back button issues on Firefox.
- Add Amber login support for mobile.
- Add settings (three dots) button for all videos, not the same as gear menu. Only show delete and edit gear for logged-in users videos.
@@ -31,7 +31,7 @@ This new file could export:
- `buildNewNote(data, pubkey)`: Takes basic form data and returns a fully structured Nostr note (an object) ready to be signed.
- `buildEditNote(originalEvent, updatedData)`: Merges old note content with new fields.
- `softDeleteNote(originalEvent)`: Constructs a note with `deleted = true`.
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Real or placeholder encryption functions.
- `encryptMagnet(magnet)`, `decryptMagnet(magnet)`: Legacy pass-through helpers kept in case encryption returns; private listings now hide rather than encrypt.
- `validateNoteContent(content)`: Ensures essential fields (title, magnet, mode, etc.) are present and valid.
Because you’re not implementing Version 3 yet, keep your existing version logic. If you do plan to adopt Version 3 later, the new file is where you’d add or change fields without scattering edits across multiple files.
@@ -53,7 +53,7 @@ export function prepareNewNote(formInput, pubkey) {
// Combine user inputs with defaults
const isPrivate = formInput.isPrivate === true;
const finalMagnet = isPrivate
? encryptMagnet(formInput.magnet)
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
: formInput.magnet;
return {
@@ -178,7 +178,7 @@ By delegating the actual note-building to `buildEditNote` and `buildDeleteNote`,
6. **Test Thoroughly:**
- Create, edit, and delete events to ensure everything behaves the same.
- Confirm that private videos are still encrypted as expected.
- Confirm that private videos stay hidden from shared grids as expected.
---
@@ -189,13 +189,13 @@ Below is a small outline showing how you might organize the new file. The actual
```js
// bitvidNoteSpec.js
// A placeholder or real encryption method
// Legacy placeholder kept for future encryption experiments (currently returns the raw magnet)
export function encryptMagnet(magnet) {
return magnet.split("").reverse().join("");
return magnet; // private cards are hidden instead of encrypted
}
export function decryptMagnet(magnet) {
return magnet.split("").reverse().join("");
return magnet; // private cards are hidden instead of encrypted
}
function generateUniqueDTag() {
@@ -206,7 +206,7 @@ function generateUniqueDTag() {
export function prepareNewNote(formInput, pubkey) {
const isPrivate = formInput.isPrivate === true;
const finalMagnet = isPrivate
? encryptMagnet(formInput.magnet)
? encryptMagnet(formInput.magnet) // currently a pass-through; hiding happens at the feed layer
: formInput.magnet;
return {
@@ -234,7 +234,7 @@ export function prepareNewNote(formInput, pubkey) {
export function buildEditNote(originalEvent, updatedData) {
// parse old content
// combine with new fields
// handle old vs new encryption
// handle legacy visibility toggles / future encryption experiments
// return the final event object
}
@@ -43,19 +43,19 @@ A typical event:
| `videoRootId` | String (optional) | A stable root ID used to link multiple versions (edits) and a delete event together. **Recommended** for overshadow logic. |
| `version` | Integer | Now set to `3`. |
| `deleted` | Boolean | `true` marks the post as “soft deleted.” |
| `isPrivate` | Boolean | Indicates if `magnet` (and possibly other fields) are encrypted. |
| `isPrivate` | Boolean | Indicates the card should stay off shared grids and remain visible only to its owner; payloads stay plaintext. |
| `title` | String | Display title for the media. |
| `magnet` | String | Magnet link for primary media (encrypted if `isPrivate = true`). |
| `magnet` | String | Magnet link for primary media (still stored in plaintext even when `isPrivate = true`). |
| `extraMagnets` | Array (optional) | Additional magnet links (e.g., multiple resolutions). |
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
| `description` | String (optional) | A textual description (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image. |
| `description` | String (optional) | A textual description. |
| `mode` | String | Typically `live` or `dev`. |
| `adult` | Boolean (optional) | `true` if content is adult-only. Default: `false` or omitted. |
| `categories` | Array (optional) | Array of categories, e.g. `["comedy", "music"]`. |
| `language` | String (optional) | Language code (e.g. `"en"`, `"es"`). |
| `payment` | String (optional) | Monetization field (e.g. a Lightning address). |
| `i18n` | Object (optional) | Internationalization map (e.g. `{"title_en": "...", "description_es": "..."}`). |
| `encryptedMeta` | Boolean (optional) | Indicates if fields like `description` or `thumbnail` are encrypted. |
| `encryptedMeta` | Boolean (optional) | Legacy flag from earlier encryption experiments; leave `false` unless a future spec revives encrypted metadata. |
| **Audio/Podcast-Specific Fields** | | |
| `contentType` | String (optional) | E.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
| `albumName` | String (optional) | Name of the album (for music). |
@@ -194,7 +194,7 @@ A typical event:
### **8.2 Deletion**
1. **deleted = true**: Mark the item as deleted in the `content` JSON.
2. **Remove or encrypt** sensitive fields (`magnet`, `description`, etc.).
2. **Clear** sensitive fields (`magnet`, `description`, etc.) if you do not want them mirrored in older clients.
3. **videoRootId**: Must remain the same as the original so clients remove/overshadow the old item.
4. **subscribeVideos** Logic:
```js
@@ -217,7 +217,7 @@ A typical event:
1. **Adult Content**: If `adult = true`, clients typically hide the post unless the user enables adult content in settings.
2. **Categories**: Provide optional grouping or searching by `categories`.
3. **Language**: If specified, clients can filter by language.
4. **Encryption**: If `isPrivate = true`, some fields (e.g., `magnet`) may need client-side decryption.
4. **Privacy handling**: If `isPrivate = true`, clients should hide the card from shared grids instead of attempting client-side decryption.
---
+1360 -119
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -1 +1,3 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@mediLine truncated
:root{--color-page:#fff;--color-page-alt:#f9fafb;--color-surface:#0f172a;--color-surface-alt:#111827;--color-bg:var(--color-surface);--color-card:#1e293b;--color-card-private:#3b1d74;--color-card-critical:#3f0b13;--color-panel:#1f2937;--color-panel-hover:#334155;--color-border:#374151;--color-border-subtle:#d1d5db;--color-primary:#fe0032;--color-secondary:#ff93a5;--color-info:#60a5fa;--color-info-strong:#2563eb;--color-info-pressed:#1d4ed8;--color-muted-strong:#6b7280;--color-text:#f8fafc;--color-text-strong:#0f172a;--color-muted:#94a3b8;--color-muted-alt:#9ca3af;--color-subtle-text:#e2e8f0;--color-subtle:#e5e7eb;--color-critical:#7f1d1d;--color-critical-strong:#991b1b;--color-neutral-700:#3f3f46;--color-neutral-600:#52525b;--color-white:#fff;--color-black:#000;--color-overlay:#1a2234;--space-2xs:0.25rem;--space-xs:0.5rem;--space-sm:0.75rem;--space-md:1rem;--space-lg:1.5rem;--space-xl:2rem;--space-2xl:3rem;--radius-sm:0.25rem;--radius-md:0.5rem;--radius-lg:0.75rem;--radius-xl:1rem;--radius-full:9999px;--shadow-sm:0 0.0625rem 0.125rem 0 rgb(0 0 0/5%);--shadow-md:0 0.25rem 0.375rem -0.0625rem rgba(0,0,0,.1);--shadow-lg:0 0.625rem 0.9375rem -0.1875rem rgba(0,0,0,.1);--shadow-modal:0 1.25rem 2.5rem -0.375rem rgba(15,23,42,.55);--font-family-sans:"Inter","Segoe UI",system-ui,-apple-system,sans-serif;--font-family-mono:"Fira Code","SFMono-Regular",ui-monospace,monospace;--font-size-xs:0.75rem;--font-size-sm:0.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.5rem;--font-size-2xl:2rem;--line-height-tight:1.1;--line-height-snug:1.25;--line-height-base:1.5;--line-height-relaxed:1.75;--motion-duration-fast:120ms;--motion-duration-base:160ms;--motion-duration-slow:280ms;--motion-ease-out:cubic-bezier(0.16,1,0.3,1);--motion-ease-in-out:cubic-bezier(0.4,0,0.2,1);--modal-motion-duration:var(--motion-duration-base)}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:var(--font-family-sans);font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:var(--font-family-mono);font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.bottom-4{bottom:1rem}.bottom-full{bottom:100%}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.top-0{top:0}.top-12{top:3rem}.top-14{top:3.5rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[60\]{z-index:60}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-0{margin-top:0;margin-bottom:0}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.-ml-2{margin-left:-.5rem}.mb-0{margin-bottom:0}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-4{margin-right:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.box-border{box-sizing:border-box}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-4{height:1rem}.h-48{height:12rem}Line truncated
+2
View File
@@ -1,3 +1,5 @@
@import "./tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
+78
View File
@@ -0,0 +1,78 @@
:root {
/* Color tokens */
--color-page: rgb(255 255 255);
--color-page-alt: rgb(249 250 251);
--color-surface: rgb(15 23 42);
--color-surface-alt: rgb(17 24 39);
--color-bg: var(--color-surface);
--color-card: rgb(30 41 59);
--color-card-private: rgb(59 29 116);
--color-card-critical: rgb(63 11 19);
--color-panel: rgb(31 41 55);
--color-panel-hover: rgb(51 65 85);
--color-border: rgb(55 65 81);
--color-border-subtle: rgb(209 213 219);
--color-primary: rgb(254 0 50);
--color-secondary: rgb(255 147 165);
--color-info: rgb(96 165 250);
--color-info-strong: rgb(37 99 235);
--color-info-pressed: rgb(29 78 216);
--color-muted-strong: rgb(107 114 128);
--color-text: rgb(248 250 252);
--color-text-strong: rgb(15 23 42);
--color-muted: rgb(148 163 184);
--color-muted-alt: rgb(156 163 175);
--color-subtle-text: rgb(226 232 240);
--color-subtle: rgb(229 231 235);
--color-critical: rgb(127 29 29);
--color-critical-strong: rgb(153 27 27);
--color-neutral-700: rgb(63 63 70);
--color-neutral-600: rgb(82 82 91);
--color-white: rgb(255 255 255);
--color-black: rgb(0 0 0);
--color-overlay: rgb(26 34 52);
/* Spacing scale */
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
/* Border radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 5%);
--shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 10%);
--shadow-lg: 0 0.625rem 0.9375rem -0.1875rem rgb(0 0 0 / 10%);
--shadow-modal: 0 1.25rem 2.5rem -0.375rem rgb(15 23 42 / 55%);
/* Typography */
--font-family-sans: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--font-family-mono: "Fira Code", "SFMono-Regular", ui-monospace, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--line-height-tight: 1.1;
--line-height-snug: 1.25;
--line-height-base: 1.5;
--line-height-relaxed: 1.75;
/* Motion */
--motion-duration-fast: 120ms;
--motion-duration-base: 160ms;
--motion-duration-slow: 280ms;
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--modal-motion-duration: var(--motion-duration-base);
}
+25
View File
@@ -0,0 +1,25 @@
# Application Responsibility Audit
This document catalogs the major responsibilities still implemented inside `Application` (formerly `bitvidApp`). Each area is a candidate for future extraction into dedicated service or UI modules.
## Auth & Profile Coordination
- Instantiates `AuthService`, wires login/logout/profile listeners, and exposes helper methods for cached profile state management.【F:js/app.js†L221-L314】【F:js/app.js†L620-L772】
- Manages profile modal lifecycle, navigation between panes, and account switching UI state.【F:js/app.js†L283-L420】【F:js/app.js†L1328-L1822】
## Playback Orchestration
- Configures `PlaybackService`, logs telemetry callbacks, and tracks the active playback session lifecycle.【F:js/app.js†L224-L323】【F:js/app.js†L2805-L3352】
- Coordinates view logging, cooldown keys, and watch-count subscriptions for the primary player and modal overlays.【F:js/app.js†L4305-L4592】【F:js/app.js†L4972-L5332】
## Modal & UI Composition
- Constructs upload, edit, revert, and video modals; registers their event listeners; and bridges UI events back into application logic.【F:js/app.js†L340-L615】【F:js/app.js†L2382-L2799】
- Builds and manages the `VideoListView` component, injecting helpers for health badges, share links, and context menu actions.【F:js/app.js†L516-L744】【F:js/app.js†L2149-L2376】
## Routing & View Bootstrapping
- Handles initial view loading, hash-based navigation, and video list mounting during application startup.【F:js/app.js†L903-L959】【F:js/app.js†L1932-L2124】
- Provides navigation helpers for channel/profile transitions tied to hash routing.【F:js/app.js†L934-L968】
## State & Watch History Management
- Synchronizes watch-history preferences, local metadata caching, and publication of watch history events to services.【F:js/app.js†L1975-L2093】【F:js/app.js†L4087-L4478】
- Tracks app-level state such as active intervals, cleanup routines, view counters, and URL/magnet health caches.【F:js/app.js†L200-L323】【F:js/app.js†L3354-L4075】
These clusters outline the remaining cross-cutting responsibilities that can be incrementally migrated into focused modules under `js/services` and `js/ui`.
+111
View File
@@ -0,0 +1,111 @@
# Feed Engine Architecture
The feed engine is a lightweight pipeline for composing Bitvid feeds from one
or more data sources. It lives in `js/feedEngine/` and exposes factories for
sources, stages, sorters, and the engine itself. The goal is to make it easy to
register new feeds today while leaving room for future "open algorithm" work
that surfaces optional "why this video" metadata.
## Core Concepts
- **Feed definition** – A feed consists of a source, zero or more pipeline
stages, an optional sorter, and optional decorators. Each feed can expose its
own hooks and configuration defaults.
- **Feed items** – The engine normalizes everything into a `{ video, pointer?,
metadata }` DTO so stages can reason about inputs without caring about the
original source.
- **Context** – Every source and stage receives a context object containing the
feed configuration, runtime helpers, registered hooks, and a `addWhy()`
method for collecting metadata about why an item was filtered or prioritised.
## Getting Started
```js
import {
createFeedEngine,
createActiveNostrSource,
createDedupeByRootStage,
createBlacklistFilterStage,
createChronologicalSorter,
} from "../js/feedEngine/index.js";
const engine = createFeedEngine();
engine.registerFeed("recent", {
source: createActiveNostrSource(),
stages: [
createBlacklistFilterStage(),
createDedupeByRootStage(),
],
sorter: createChronologicalSorter(),
});
const { videos, metadata } = await engine.runFeed("recent", {
runtime: {
blacklistedEventIds: new Set(["..."]),
isAuthorBlocked: (pubkey) => false,
},
});
```
`metadata.why` collects the audit trail from each stage so later phases of the
open algorithm project can surface transparency UI.
## Sources
| Factory | Description |
| --- | --- |
| `createActiveNostrSource` | Wraps `nostrService.getFilteredActiveVideos(...)` and emits DTOs with a `metadata.source` of `nostr:active`. |
| `createSubscriptionAuthorsSource` | Filters the active video list down to subscribed authors using runtime hooks or `config.actorFilters`. |
| `createWatchHistoryPointerSource` | Loads pointer DTOs from `watchHistoryService.getQueuedPointers(...)`. Optional hooks can resolve the backing video if a pointer is missing it. |
Every source resolves blacklist and author-block runtime helpers so feeds
constructed today behave exactly like the existing UI.
## Stages
| Stage | Behavior |
| --- | --- |
| `createDedupeByRootStage` | Reuses the application’s `dedupeVideosByRoot` helper (falling back to `dedupeToNewestByRoot`) to drop older versions of the same videoRoot. Adds "why" metadata for removed entries. |
| `createBlacklistFilterStage` | Calls `nostrService.shouldIncludeVideo(...)` so moderators and block lists stay enforced. Each rejection logs a "blacklist" reason in the why-trail. |
| `createWatchHistorySuppressionStage` | Invokes feed-provided hooks to optionally suppress watched items. Useful for per-feed watch history preferences. |
Stages receive `(items, context)` and should return the transformed list. They
can rely on `context.addWhy(...)` to annotate decisions without mutating the
items in place.
## Sorting & Decorators
`createChronologicalSorter` is the baseline sorter that orders DTOs by
`video.created_at` (newest first by default). Additional decorators can run
after sorting to attach extra metadata or inject presentation hints.
## Configuration Hooks
Every feed inherits the default config contract:
```json
{
"timeWindow": null,
"actorFilters": [],
"tagFilters": [],
"sortOrder": "recent"
}
```
Feeds can override defaults or expose richer schemas through
`definition.defaultConfig` and `definition.configSchema`. At execution time the
engine merges `options.config` with those defaults and passes them to the
pipeline via `context.config`.
Hooks can be provided globally when registering the feed or per-execution via
`engine.runFeed(name, { hooks: { ... } })`. The watch-history suppression stage
uses this mechanism so Phase 1 feeds can plug in actor-specific suppression
logic later without changing the core pipeline.
## Why-Metadata
`context.addWhy()` records structured audit entries. All built-in stages use it
for dedupe drops, blacklist filtering, and watch-history suppression. The
engine returns these records alongside the final video list so UI components can
render transparency affordances when the open algorithm effort ships.
+177
View File
@@ -0,0 +1,177 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bitvid Tailwind Kitchen Sink</title>
<link rel="stylesheet" href="../css/tailwind.generated.css" />
</head>
<body class="bg-page font-sans text-text-strong">
<div class="min-h-screen bg-page-alt">
<header class="bg-surface text-text shadow-modal">
<div
class="mx-auto flex max-w-6xl items-center justify-between px-md py-sm"
>
<h1 class="text-2xl font-semibold">Bitvid UI Kitchen Sink</h1>
<button
class="rounded-lg bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Primary Action
</button>
</div>
</header>
<main class="mx-auto flex max-w-6xl flex-col gap-lg px-md py-lg">
<section class="space-y-md">
<h2 class="text-xl font-semibold text-text-strong">Buttons</h2>
<div class="flex flex-wrap gap-sm">
<button
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
>
Primary
</button>
<button
class="rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-fast ease-out hover:bg-info-strong"
>
Informational
</button>
<button
class="rounded-md bg-critical px-sm py-2 text-white shadow-sm transition duration-fast ease-out hover:bg-critical-strong"
>
Destructive
</button>
<button
class="rounded-md border border-border-subtle px-sm py-2 text-text-muted shadow-none transition duration-fast ease-out hover:bg-panel-hover hover:text-text"
>
Ghost
</button>
</div>
</section>
<section class="space-y-md">
<h2 class="text-xl font-semibold text-text-strong">Cards</h2>
<div class="grid gap-md md:grid-cols-3">
<article class="rounded-lg bg-card p-md text-text shadow-lg">
<h3 class="text-lg font-semibold">Default Card</h3>
<p class="mt-xs text-text-muted">
Uses
<code class="font-mono text-sm text-info">bg-card</code> and
tokenized shadows.
</p>
<button
class="mt-sm rounded-md bg-info px-sm py-2 text-surface shadow-sm transition duration-base ease-in-out hover:bg-info-strong"
>
Watch Now
</button>
</article>
<article
class="rounded-lg bg-card-private p-md text-text shadow-lg"
>
<h3 class="text-lg font-semibold">Private Card</h3>
<p class="mt-xs text-text-muted">
Emphasizes restricted content with
<code class="font-mono text-sm text-info">bg-card-private</code
>.
</p>
</article>
<article
class="rounded-lg bg-card-critical p-md text-text shadow-lg"
>
<h3 class="text-lg font-semibold">Critical Card</h3>
<p class="mt-xs text-text-muted">
Use for outage or abuse states.
</p>
</article>
</div>
</section>
<section class="space-y-md">
<h2 class="text-xl font-semibold text-text-strong">Form Inputs</h2>
<form class="grid gap-md md:grid-cols-2">
<label class="flex flex-col gap-xs">
<span class="text-sm font-medium text-text-muted"
>Display Name</span
>
<input
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
type="text"
placeholder="Satoshi Stack"
/>
</label>
<label class="flex flex-col gap-xs">
<span class="text-sm font-medium text-text-muted">Video URL</span>
<input
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
type="url"
placeholder="https://example.com/video.mp4"
/>
</label>
<label class="flex flex-col gap-xs md:col-span-2">
<span class="text-sm font-medium text-text-muted"
>Description</span
>
<textarea
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong shadow-sm focus:border-info focus:ring-2 focus:ring-info-strong"
rows="3"
placeholder="Describe what viewers should expect..."
></textarea>
</label>
</form>
</section>
<section class="space-y-md">
<h2 class="text-xl font-semibold text-text-strong">Modal Preview</h2>
<div
class="relative overflow-hidden rounded-xl bg-overlay p-lg text-text shadow-modal"
>
<div class="space-y-sm">
<h3 class="text-lg font-semibold">Upload Video</h3>
<p class="text-text-muted">
Motion tokens control the fade via
<code class="font-mono text-sm text-info">duration-base</code>
and
<code class="font-mono text-sm text-info">ease-out</code>
utilities.
</p>
</div>
<div class="mt-md flex flex-col gap-sm">
<input
class="rounded-md border border-border-subtle bg-white px-sm py-xs text-text-strong focus:border-info focus:ring-2 focus:ring-info"
type="text"
placeholder="Magnet URI"
/>
<div class="flex justify-end gap-sm">
<button
class="rounded-md border border-border px-sm py-2 text-text-muted transition duration-fast ease-out hover:text-text"
>
Cancel
</button>
<button
class="rounded-md bg-primary px-sm py-2 text-white shadow-md transition duration-fast ease-out hover:bg-secondary"
>
Publish
</button>
</div>
</div>
</div>
</section>
<section class="space-y-md">
<h2 class="text-xl font-semibold text-text-strong">
Typography Scale
</h2>
<div class="space-y-xs">
<p class="text-2xl font-semibold">Display / 2xl</p>
<p class="text-xl font-semibold">Headline / xl</p>
<p class="text-lg font-medium">Title / lg</p>
<p class="text-base">Body / base</p>
<p class="text-sm text-text-muted">Caption / sm</p>
<p class="text-xs uppercase tracking-wide text-text-muted">
Meta / xs
</p>
</div>
</section>
</main>
</div>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
# Manual QA — 2024-05-10
## Scope
* Login/logout state transitions via `authService`
* Video list rendering and playback fallback via `playbackService`
* Upload submissions (URL only, magnet only, combined) via `publishVideoNote`
## Environment
* Local static server: `python -m http.server 5173`
* Browser automation: Playwright (Chromium) via sandboxed runner
* Stubbed `window.nostr` for extension-less login flows
## Results
### Authentication
* Pre-login upload button hidden (`False`).
* Post-login upload button visible (`True`); profile button also visible.
* Post-logout upload button hidden again (`False`).
### Playback
* Hosted URL attempt emitted `session-start` analytics event. Hosted playback errored in sandbox (no magnet fallback available for URL-only sample).
* Second sample (URL + magnet) emitted `session-start`, `fallback`, `playViaWebTorrent`, and `sourcechange: torrent` events confirming magnet fallback wiring.
### Upload flows
* URL-only submission succeeded (normalized payload kept empty magnet and blank hints).
* Magnet-only submission appended WSS tracker hints plus provided `ws`/`xs` values.
* Combined submission retained HTTPS URL and normalized magnet with tracker hints.
## Notes
Automation invoked `app.authService.login`/`handleAuthLogin` and `app.publishVideoNote` directly to simulate modal interactions. Playback checks observed console analytics, not media rendering (hosted stream blocked by sandbox security).
+33
View File
@@ -0,0 +1,33 @@
# Bitvid Moderation Overview
## What we’re building
Bitvid is follow-centric. Your Home feed comes from people you follow (F1). Discovery can optionally expand to friends-of-friends (F2) and ranked sources. Moderation is **client-side** and **user-controlled** with optional admin lists.
## Core principles
- **Freedom to choose**: User picks filters. Defaults are safe but reversible.
- **Explain decisions**: Every blur/hide shows a “why” badge and a “show anyway” control.
- **Whitelist > blacklist**: Prefer showing content from trusted networks over fighting global spam.
- **Minimal central power**: Admin lists are opt-in; users can unsubscribe.
## Threat model (short)
- Spam/bots, impersonation, malware, NSFW thumbnails, illegal content, brigading/dogpiles, Sybil report attacks.
## Building blocks (Nostr)
- **Reports**: NIP-56 (`kind 1984`) with types like `nudity`, `spam`, `illegal`, `impersonation`, etc.
- **Lists**: NIP-51 (mute list 10000, categorized people 30000, bookmarks 30001).
- **Replies/threads**: NIP-10 (comments).
- **Counts**: NIP-45 (relay COUNT; optional fallbacks).
## Defaults (policy)
- Blur video thumbnails if **≥ 3** F1 friends report `nudity`.
- Disable autoplay preview if **≥ 2** F1 friends report `nudity`.
- Downrank author when any F1 has them in mute list (10000).
- Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content.
> You can override all defaults in **Settings → Safety & Moderation**.
## Files in this folder
- `web-of-trust.md` — how we compute trust signals and thresholds
- `nips.md` — exact NIPs and kinds Bitvid uses
- `relays.md` — relay compatibility and COUNT fallbacks
- `testing.md` — QA checklist + test vectors
+22
View File
@@ -0,0 +1,22 @@
# NIPs Bitvid uses (Moderation-Relevant)
## Required
- **NIP-56 — Reporting**
`kind 1984` events with a `type` tag such as `nudity`, `spam`, `illegal`, `impersonation`, `malware`, `profanity`, `other`.
Client may act on **reports from friends** (our trusted set).
- **NIP-51 — Lists**
- `10000` mute list (authors to mute).
- `30000` categorized people (used for admin/curation lists).
- `30001` bookmarks (not moderation, but used elsewhere).
- **NIP-10 — Replies & Mentions**
Threading for comments; moderation badges appear in the thread UI.
## Recommended
- **NIP-45 — COUNT**
Event counts; we use it opportunistically for faster totals with graceful fallback.
## Nice-to-have
- **NIP-36 — Sensitive Content / Content Warning**
If present, we pre-blur content even without reports; user can “show anyway”.
+25
View File
@@ -0,0 +1,25 @@
# Relay Compatibility & Fallbacks
## COUNT (NIP-45)
- Use COUNT when available to fetch totals efficiently.
- If a relay doesn’t support COUNT:
- Show “—” placeholder.
- Fall back to client-side counting when cheap (narrow queries only).
- Avoid heavy per-relay scans; cap by time window and result size.
## Query hygiene
- Prefer specific filters: `authors`, `kinds`, `#e`, `#p`, `since/until`.
- Batch requests across relays; debounce UI updates.
- Cache viewer’s F1 set (pubkeys you follow) and their report summaries.
## Media/thumbnail considerations
- Never block media fetch solely on relay count results.
- Thumbnails respect blur rules locally; “show anyway” toggles do not re-query relays.
## Timeouts & errors
- Per-relay timeout (e.g., 1500–2500 ms); proceed with partial results.
- Surface a subtle “partial results” indicator when some relays fail.
## Privacy notes
- Do not leak the viewer’s F1 set to third-party endpoints.
- If using a reputation provider, pass pubkeys in batches; avoid attaching viewer identity unless needed.
+71
View File
@@ -0,0 +1,71 @@
# QA & Test Vectors (Moderation)
## Quick checklist
- [ ] F1 set cached and used for “trusted reports”.
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
- [ ] “Show anyway” works and records only local pref.
- [ ] Mute list (10000) downranks/hides consistently.
- [ ] Admin lists (30000) take effect only when subscribed.
- [ ] COUNT fallback shows placeholders and doesn’t crash UI.
- [ ] Comment threads render with NIP-10; moderation badges show in context.
- [ ] Reports from muted reporters are ignored.
## Fixtures (create on test relays)
### A) NIP-56 report events
Create three distinct F1 reporter keys; each sends:
```json
{
"kind": 1984,
"tags": [
["e", "<VIDEO_EVENT_ID>", "nudity"],
["p", "<AUTHOR_PUBKEY>"]
],
"content": "test: thumbnail too revealing"
}
```
Expected:
* After 1 report → no blur; autoplay allowed.
* After 2 reports → autoplay blocked.
* After 3 reports → thumbnail blurred; reason chip shown.
### B) Mute list (10000)
```json
{
"kind": 10000,
"tags": [
["p", "<AUTHOR_PUBKEY>"]
]
}
```
Expected: author’s items are downranked/hidden for this viewer.
### C) Admin blacklist (30000)
```json
{
"kind": 30000,
"tags": [
["d", "bitvid:admin:blacklist"],
["p", "<AUTHOR_PUBKEY>"]
]
}
```
Expected: when viewer subscribes to this list, author is hard-hidden.
### D) COUNT fallback
* Use a relay that doesn’t support COUNT.
* Verify “—” placeholders; no spinner loops; no crashes.
## Automated checks (suggested)
* Unit test `trustedReportCount`.
* Integration test: emit events over a local relay; assert UI state.
* Regression test: ensure Home feed ignores reputation gating.
+79
View File
@@ -0,0 +1,79 @@
# Web-of-Trust Policy (Bitvid)
## Graph terms
- **F1 (friends)**: pubkeys you follow.
- **F2**: friends-of-friends (off by default for Home; allowed for Discovery).
- **Trusted report**: a NIP-56 report from an F1 account.
## Signals we use
- NIP-56 reports by type (`nudity`, `spam`, `illegal`, `impersonation`, etc.).
- NIP-51 lists:
- 10000 mute list → downrank/hide author content.
- 30000 categorized people → optional admin lists (see below).
- (Optional) reputation score from a reputation source (e.g., PageRank/DVM) for **Discovery** only.
## Default thresholds (can be tuned)
- `blurThumbnail = trustedReportCount(event,'nudity') >= 3`
- `hideAutoplay = trustedReportCount(event,'nudity') >= 2`
- `downrankIfMutedByF1 = true`
### Why these numbers?
- F1-only reports resist Sybil attacks.
- Blur is reversible; hiding autoplay reduces accidental exposure.
## Admin lists (opt-in)
- We recognize curated lists using `30000` events:
- `['d','bitvid:admin:blacklist']` → hard-hide when subscribed.
- `['d','bitvid:admin:whitelist']` → always show in Discovery.
- `['d','bitvid:admin:editors']` → trusted channel editors.
- Users can subscribe/unsubscribe any time.
## Pseudocode
```ts
type Hex = string;
interface Report {
reporter: Hex; // F1 must include this key
type: 'nudity'|'spam'|'illegal'|'impersonation'|'malware'|'profanity'|'other';
targetEvent: string;
}
function trustedReportCount(eventId: string, type: Report['type'], viewerFollows: Set<Hex>, reports: Report[]): number {
return reports.filter(r => r.targetEvent === eventId && r.type === type && viewerFollows.has(r.reporter)).length;
}
function shouldBlurThumb(eventId: string, ctx: Ctx): boolean {
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 3;
}
function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean {
return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= 2;
}
```
## UI rules
* Always show a reason chip: e.g., `Blurred · 3 friends reported “nudity” · Show anyway`.
* Respect per-viewer choices (global off, per-channel off).
* Never bury the override.
## Discovery (optional reputation)
* Home feed: **never** gated by reputation.
* Discovery/Trending: may require a minimum score (pluggable `ReputationSource`).
* Keep a toggle in Settings to disable reputation gating.
```ts
interface ReputationSource {
// returns 0..1 rank per pubkey for a given perspective
rank(pubkeys: Hex[], perspective?: Hex): Promise<Record<Hex, number>>;
}
```
## Anti-abuse hardening
* Count unique F1 reporters only (dedupe by pubkey).
* Minimum account age or minimum “being-followed” count for reporters (optional).
* Rate-limit rapid report bursts per reporter.
* Ignore reports from muted or blocked reporters.
+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.
+73
View File
@@ -0,0 +1,73 @@
# Nostr authentication storage
Bitvid keeps lightweight state in `localStorage` so the UI can remember which
accounts signed in recently. This document explains the storage schema so future
updates can evolve it without breaking existing users.
## Storage keys
- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
avatars. See `js/app.js` for TTL and eviction rules.
- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
with in this browser.
Both keys live in the main origin scope. Clearing one should not affect the
other.
## Saved profile schema
`bitvid:savedProfiles:v1` is a JSON object with the shape:
```json
{
"version": 1,
"entries": [
{
"pubkey": "<hex-encoded pubkey>",
"npub": "<bech32 npub, optional>",
"name": "<cached display name>",
"picture": "<cached avatar URL>",
"authType": "<auth strategy>"
}
],
"activePubkey": "<hex pubkey or null>"
}
```
Notes:
- `pubkey` always stores a lowercase 64-character hex string and is the primary
dedupe key.
- `npub` is optional. When omitted, the UI regenerates it on load using
`NostrTools.nip19.npubEncode`.
- `name` and `picture` are cached hints that keep the profile switcher snappy.
They may be empty strings and should be treated as hints, not the source of
truth.
- `activePubkey` tracks the last account that successfully authenticated in this
browser. It may be `null` when the user logs out but keeps saved entries.
### `authType` enum
`authType` describes how the profile authenticated:
- `"nip07"` – Browser extension flow (current default).
- `"nsec"` – Reserved for future direct key import or signer integrations. New
auth strategies should pick a distinct string and document how migration works
alongside any recovery tooling.
When the app reads stored entries it normalises unknown values back to
`"nip07"` but keeps recognised alternatives intact.
## Migration notes
Earlier builds only persisted a single `userPubKey` string. During startup the
app now:
1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
it seeds `savedProfiles` with that value and writes the new structure.
3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
is removed.
Future migrations should follow the same pattern: validate, normalise, write the
new format, then clean up legacy keys to avoid data loss.
+125
View File
@@ -0,0 +1,125 @@
# 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,
buildRepostEvent,
} 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.
### Accessing raw events
`NostrClient` now keeps a lightweight cache of parsed videos alongside a
separate cache of the untouched event payloads. Call
`nostrClient.fetchRawEventById(eventId)` when you need the original JSON that
was received from the relay. For convenience,
`nostrClient.getEventById(eventId, { includeRaw: true })` returns both shapes at
once:
```js
const { video, rawEvent } = await nostrClient.getEventById(eventId, {
includeRaw: true,
});
// `video` is the normalized BitVid object and `rawEvent` is the original
// Nostr event with `sig`, `id`, and relay metadata intact.
```
In DevTools the active client lives at `window.bitvidApp?.nostrClient`.
Use the `rawEvent` blob when implementing “Rebroadcast” style flows so the
client can republish exactly what was signed, including the original
`sig`. Republishers should prefer `rawEvent.sig` over re-signing unless the
payload actually changes; this avoids accidental drift in dedupe tags or
timestamps.
## 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 |
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | Empty content |
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
| 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 |
The `isPrivate` flag in Content Schema v3 marks cards that should stay off shared or public grids. Clients should suppress these events for everyone except the owner, even though the payload stays in clear text for compatibility.
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.
### NIP-71 rollout
BitVid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short
form) whenever the `FEATURE_PUBLISH_NIP71` flag is enabled. The builder converts
structured upload metadata—image variants, captions, segments, hashtags, and
participant pointers—into canonical tags so other clients can discover the same
video. The legacy kind `30078` post remains the source of truth during the
transition, and schema overrides can adjust either kind if relays need
experimentation.
### 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】
+53
View File
@@ -0,0 +1,53 @@
# Nostr Wallet Connect Coverage
This note cross-references Bitvid's current Nostr Wallet Connect (NIP-47) implementation with the official
specification so we can quickly spot regressions or missing features.
## URI handling
* `parseNwcUri()` accepts all aliases we see in the wild (`nostr+walletconnect://`, `walletconnect://`, and `nwc://`)
and rejects unknown schemes before continuing. It extracts the wallet pubkey, all relay query parameters, and the
32-byte secret required for signing. The helper lowercases the secret, reserializes the URI in canonical form, and
derives the client public key from the secret so downstream calls can sign events without leaking user keys.
## Connection lifecycle
* `ensureActiveState()` caches the parsed connection and keeps WebSocket state so repeated actions reuse the same
subscription. When a new URI is supplied we tear down the previous socket, update the relay list, and reset
encryption state before reconnecting.
* `connectSocket()` opens a WebSocket to the relay, subscribes to kind `23195` responses authored by the wallet and
addressed to the client pubkey, and hooks message/error/close callbacks so we can retry or surface failures.
## Request flow
* `ensureWallet()` validates that the user configured an NWC URI, connects to the relay if needed, and negotiates an
encryption scheme before returning the active context.
* `encryptRequestPayload()` serializes the JSON-RPC payload, encrypts it with the selected scheme, and creates the
Nostr event with kind `23194`, `pubkey` set to the client key, a `p` tag pointing at the wallet, and an
`encryption` tag mirroring the algorithm that was used. Events are signed with the secret from the connection URI
so relays will accept them.
* `sendPayment()` constructs the `pay_invoice` request, attaches a unique `id`, and dispatches it over the relay.
## Response flow
* `subscribeToResponses()` issues a `REQ` for kind `23195` events authored by the wallet and tagged for the client.
* `handleSocketMessage()` decrypts responses, matches them against pending `id`s (or the `e` tag), clears pending
timeouts, and surfaces either the `result` or `error.code`/`error.message` back to callers.
## Encryption negotiation
* `requestInfoEvent()` fetches the wallet's replaceable kind `13194` info note and passes the result to
`getWalletSupportedEncryption()` so we only pick schemes advertised by the wallet service.
* `getEncryptionCandidates()` prefers NIP-44 (`nip44_v2`) when both sides support it and falls back to NIP-04 when
necessary. We also remember schemes that failed so future retries skip them.
## Current gaps
* We only connect to the first relay supplied by the URI. If a wallet publishes multiple relays we should consider
retrying additional ones when the primary is offline.
* The client currently implements the `pay_invoice` command. Other optional commands (`make_invoice`,
`get_balance`, `multi_pay_invoice`, etc.) and notification handling (kinds `23196`/`23197`) would require follow-up
work.
* Wallet responses that signal `UNSUPPORTED_ENCRYPTION` as an error today bubble straight back to the caller. The
transport already retries once when we encounter that error during submission, but we could add richer UX (e.g.
prompting the user to reconnect) in the future.
+6
View File
@@ -17,5 +17,11 @@ Run this checklist before publishing releases or merging changes that touch uplo
5. **Cross-Browser Verification**
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
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.
+66 -34
View File
@@ -45,25 +45,33 @@
<meta name="theme-color" content="#0f172a" />
<!-- Styles -->
<script src="js/tracking.js" defer></script>
<link href="css/tailwind.generated.css" rel="stylesheet" />
<link href="css/style.css" rel="stylesheet" />
<link href="css/markdown.css" rel="stylesheet" />
<!--
Cache busting: keep these query strings in sync with config/asset-version.js
so browsers pick up the latest sidebar markup and styling without
hard-refreshing around CDN caches.
-->
<script src="js/tracking.js?v=2024.10.29" defer></script>
<link href="css/tailwind.generated.css?v=2024.10.29" rel="stylesheet" />
<link href="css/style.css?v=2024.10.29" rel="stylesheet" />
<link href="css/markdown.css?v=2024.10.29" rel="stylesheet" />
</head>
<body class="bg-gray-100">
<!--
<body class="sidebar-collapsed">
<!--
SIDEBAR CONTAINER:
The <aside id="sidebar"> inside handles visibility.
The <aside id="sidebar"> inside handles visibility.
By default, no special position or z-index on #sidebarContainer.
-->
<div id="sidebarContainer">
<!-- components/sidebar.html gets injected here -->
</div>
<!--
<div id="sidebarOverlay" class="md:hidden" aria-hidden="true"></div>
<!--
MAIN CONTENT:
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
On mobile, we also toggle .sidebar-open to shift the entire content.
Desktop spacing now follows the --sidebar-width custom property so the layout stays in
sync with the collapsible rail. On mobile we float the sidebar over the content and dim
the background overlay.
-->
<!--
`fade-in` is scrubbed by js/index.js after the first animation so that
@@ -71,7 +79,11 @@
elements that should animate exactly once on initial paint.
-->
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col fade-in">
<div
id="app"
class="px-4 py-8 min-h-screen flex flex-col fade-in"
data-sidebar-state="collapsed"
>
<!-- Header -->
<header class="mb-8 flex items-center w-full">
<!-- Mobile hamburger button (hidden on md+) -->
@@ -84,6 +96,10 @@
<button
id="mobileMenuBtn"
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60] fade-in"
type="button"
aria-expanded="false"
aria-controls="sidebar"
aria-label="Toggle navigation menu"
>
<img
src="assets/svg/mobile-sidebar-menu-icon.svg"
@@ -149,17 +165,36 @@
</div>
</header>
<!-- Error Container -->
<!-- Notification Portal -->
<div
id="errorContainer"
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
></div>
id="notificationPortal"
role="region"
aria-live="polite"
aria-label="System notifications"
>
<!-- Error Container -->
<div
id="errorContainer"
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
></div>
<!-- Success Container -->
<div
id="successContainer"
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
></div>
<!-- Status Container -->
<div
id="statusContainer"
class="hidden status-banner"
role="status"
aria-live="polite"
>
<span class="status-spinner" aria-hidden="true"></span>
<span class="status-message" data-status-message></span>
</div>
<!-- Success Container -->
<div
id="successContainer"
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
></div>
</div>
<!-- Dynamic views (like most-recent-videos.html, etc.) -->
<main id="viewContainer" class="flex-grow mb-8"></main>
@@ -179,20 +214,17 @@
</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 };
</script>
<script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script>
<script type="module" src="js/accessControl.js"></script>
<script type="module" src="js/webtorrent.js"></script>
<script type="module" src="js/nostr.js"></script>
<script type="module" src="js/viewManager.js"></script>
<script type="module" src="js/app.js"></script>
<script type="module" src="js/disclaimer.js"></script>
<script type="module" src="js/index.js"></script>
<!-- Bootstraps nostr-tools before other modules rely on nostrToolsReady -->
<script type="module" src="js/nostrToolsBootstrap.js?v=2024.10.29"></script>
<script type="module" src="js/config.js?v=2024.10.29"></script>
<script type="module" src="js/lists.js?v=2024.10.29"></script>
<script type="module" src="js/accessControl.js?v=2024.10.29"></script>
<script type="module" src="js/webtorrent.js?v=2024.10.29"></script>
<script type="module" src="js/nostr.js?v=2024.10.29"></script>
<script type="module" src="js/viewManager.js?v=2024.10.29"></script>
<script type="module" src="js/bootstrap.js?v=2024.10.29"></script>
<script type="module" src="js/disclaimer.js?v=2024.10.29"></script>
<script type="module" src="js/index.js?v=2024.10.29"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
</body>
+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) {
+113 -31
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",
@@ -38,6 +36,19 @@ function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i;
function isHexPubkey(value) {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim();
if (!trimmed) {
return false;
}
return HEX_PUBKEY_REGEX.test(trimmed);
}
function dedupeNpubs(values) {
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
return Array.from(
@@ -240,17 +251,67 @@ function encodeHexToNpub(hex) {
}
}
function normalizeParticipantTagValue(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const nip19 = window?.NostrTools?.nip19;
const lower = trimmed.toLowerCase();
if (lower.startsWith("npub")) {
if (nip19?.decode) {
try {
const decoded = nip19.decode(trimmed);
if (decoded?.type === "npub") {
return trimmed;
}
} catch (error) {
// Swallow decode errors so we can still fall back to the raw npub string.
}
}
return trimmed;
}
if (isHexPubkey(trimmed)) {
const encoded = encodeHexToNpub(trimmed);
if (encoded) {
return encoded;
}
if (!canDecodeNpub()) {
return trimmed;
}
}
if (nip19?.decode) {
try {
const decoded = nip19.decode(trimmed);
if (decoded?.type === "npub") {
return trimmed;
}
} catch (error) {
// Ignore decode failures; values that cannot be interpreted as npubs are dropped.
}
}
return "";
}
function extractNpubsFromEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
const hexKeys = event.tags
const candidates = event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
const npubs = hexKeys
.map((hex) => encodeHexToNpub(hex))
const npubs = candidates
.map((value) => normalizeParticipantTagValue(value))
.filter((npub) => typeof npub === "string" && npub);
return dedupeNpubs(npubs);
@@ -319,18 +380,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 +418,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 +452,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") {
@@ -556,3 +633,8 @@ export async function persistAdminState(actorNpub, updates) {
return persistNostrState(actorNpub, updates);
}
export const __adminListStoreTestHooks = Object.freeze({
extractNpubsFromEvent,
normalizeParticipantTagValue,
});
+4270 -6451
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
// js/applicationContext.js
let applicationInstance = null;
let readyPromise = Promise.resolve();
export function setApplication(app) {
applicationInstance = app || null;
}
export function getApplication() {
return applicationInstance;
}
export function setApplicationReady(promise) {
if (promise && typeof promise.then === "function") {
readyPromise = promise;
} else {
readyPromise = Promise.resolve(promise);
}
}
export function getApplicationReady() {
return readyPromise;
}
+30
View File
@@ -0,0 +1,30 @@
// js/bootstrap.js
import Application from "./app.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import { loadView } from "./viewManager.js";
function mergeServices(overrides = {}) {
const merged = { nostrService, r2Service };
if (overrides && typeof overrides === "object") {
if (overrides.nostrService) {
merged.nostrService = overrides.nostrService;
}
if (overrides.r2Service) {
merged.r2Service = overrides.r2Service;
}
}
return merged;
}
export function createApplication({ services, loadView: loadViewOverride } = {}) {
return new Application({
services: mergeServices(services),
loadView: typeof loadViewOverride === "function" ? loadViewOverride : loadView,
});
}
export default createApplication;
+1948 -529
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -2,8 +2,27 @@
import {
ADMIN_SUPER_NPUB,
ADMIN_DM_IMAGE_URL,
BITVID_WEBSITE_URL,
MAX_WALLET_DEFAULT_ZAP,
PLATFORM_FEE_PERCENT,
PLATFORM_LUD16_OVERRIDE,
DEFAULT_RELAY_URLS_OVERRIDE,
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,
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
} from "../config/instance-config.js";
export const isDevMode = true; // Set to false for production
@@ -14,6 +33,12 @@ export const isDevMode = true; // Set to false for production
export const ADMIN_LIST_MODE = "nostr";
export { ADMIN_SUPER_NPUB };
export { ADMIN_DM_IMAGE_URL };
export { BITVID_WEBSITE_URL };
export { MAX_WALLET_DEFAULT_ZAP };
export { PLATFORM_FEE_PERCENT };
export { PLATFORM_LUD16_OVERRIDE };
export { DEFAULT_RELAY_URLS_OVERRIDE };
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
@@ -42,3 +67,21 @@ 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 };
export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS };
+89 -3
View File
@@ -1,10 +1,14 @@
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,
FEATURE_PUBLISH_NIP71: 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 +21,10 @@ 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,
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
};
if (globalScope) {
@@ -92,6 +100,21 @@ 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 FEATURE_PUBLISH_NIP71 = coerceBoolean(
runtimeFlags.FEATURE_PUBLISH_NIP71,
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
);
export let WSS_TRACKERS = freezeTrackers(
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
);
@@ -118,6 +141,48 @@ 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, "FEATURE_PUBLISH_NIP71", {
configurable: true,
enumerable: true,
get() {
return FEATURE_PUBLISH_NIP71;
},
set(next) {
FEATURE_PUBLISH_NIP71 = coerceBoolean(
next,
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
);
},
});
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
configurable: true,
enumerable: true,
@@ -132,6 +197,9 @@ 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.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
export function setUrlFirstEnabled(next) {
@@ -144,6 +212,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 +229,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,
};
}
+232
View File
@@ -0,0 +1,232 @@
// js/feedEngine/engine.js
import { isPlainObject } from "./utils.js";
const DEFAULT_FEED_CONFIG = Object.freeze({
timeWindow: null,
actorFilters: [],
tagFilters: [],
sortOrder: "recent",
});
const DEFAULT_CONFIG_SCHEMA = Object.freeze({
timeWindow: {
type: "relative-window",
description:
"Restrict results to a rolling time window (e.g., last 24 hours).",
default: null,
},
actorFilters: {
type: "string[]",
description: "Optional list of author pubkeys to include in the feed.",
default: [],
},
tagFilters: {
type: "string[]",
description: "Optional list of tag identifiers to include in the feed.",
default: [],
},
sortOrder: {
type: "enum",
values: ["recent"],
description:
"Controls the final ordering of the feed. Currently only 'recent' is implemented.",
default: "recent",
},
});
function normalizeLogger(logger) {
if (typeof logger === "function") {
return logger;
}
if (logger && typeof logger.log === "function") {
return (...args) => logger.log(...args);
}
return () => {};
}
function normalizeArray(value) {
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry) => typeof entry === "function");
}
function normalizeDto(candidate) {
if (!candidate || typeof candidate !== "object") {
return null;
}
const video = candidate.video ?? null;
const pointer = candidate.pointer ?? null;
const metadata = isPlainObject(candidate.metadata)
? { ...candidate.metadata }
: {};
return { video, pointer, metadata };
}
function normalizeItems(items) {
if (!Array.isArray(items)) {
return [];
}
const normalized = [];
for (const candidate of items) {
const dto = normalizeDto(candidate);
if (dto) {
normalized.push(dto);
}
}
return normalized;
}
function createExecutionContext(entry, { config, hooks, runtime, logger }) {
const whyLog = [];
const resolvedLogger = normalizeLogger(logger);
const context = {
feedName: entry.name,
config: { ...entry.defaultConfig, ...(isPlainObject(config) ? config : {}) },
hooks: isPlainObject(hooks) ? { ...entry.hooks, ...hooks } : { ...entry.hooks },
runtime: isPlainObject(runtime) ? { ...runtime } : {},
log: (...args) => {
try {
resolvedLogger(`[feed:${entry.name}]`, ...args);
} catch (error) {
console.warn(`[feed:${entry.name}] logger threw`, error);
}
},
addWhy: (detail) => {
if (!isPlainObject(detail)) {
return;
}
const record = { feed: entry.name, ...detail };
whyLog.push(record);
return record;
},
getWhy: () => whyLog.slice(),
};
return context;
}
function normalizeDefinition(name, definition = {}) {
if (typeof name !== "string" || !name.trim()) {
throw new Error("Feed name must be a non-empty string");
}
if (typeof definition.source !== "function") {
throw new Error(`Feed \"${name}\" is missing a source function`);
}
const stages = normalizeArray(definition.stages);
const decorators = normalizeArray(definition.decorators);
const sorter =
typeof definition.sorter === "function" ? definition.sorter : null;
const hooks = isPlainObject(definition.hooks) ? { ...definition.hooks } : {};
const defaultConfig = {
...DEFAULT_FEED_CONFIG,
...(isPlainObject(definition.defaultConfig) ? definition.defaultConfig : {}),
};
const configSchema = isPlainObject(definition.configSchema)
? { ...DEFAULT_CONFIG_SCHEMA, ...definition.configSchema }
: { ...DEFAULT_CONFIG_SCHEMA };
return {
name,
source: definition.source,
stages,
sorter,
decorators,
hooks,
defaultConfig,
configSchema,
publicDefinition: Object.freeze({
name,
configDefaults: { ...defaultConfig },
configSchema: { ...configSchema },
}),
};
}
export function createFeedEngine({ logger } = {}) {
const feeds = new Map();
const resolvedLogger = normalizeLogger(logger);
function registerFeed(name, definition) {
if (feeds.has(name)) {
throw new Error(`Feed \"${name}\" is already registered`);
}
const entry = normalizeDefinition(name, definition);
feeds.set(name, entry);
return entry.publicDefinition;
}
async function runFeed(name, options = {}) {
const entry = feeds.get(name);
if (!entry) {
throw new Error(`Feed \"${name}\" is not registered`);
}
const context = createExecutionContext(entry, {
config: options.config,
hooks: options.hooks,
runtime: options.runtime,
logger: resolvedLogger,
});
const sourceResult = await entry.source(context);
let items = normalizeItems(await Promise.resolve(sourceResult));
for (const stage of entry.stages) {
const result = await stage(items, context);
if (Array.isArray(result)) {
items = normalizeItems(result);
} else if (result == null) {
items = normalizeItems(items);
}
}
if (entry.sorter) {
const sorted = await entry.sorter(items, context);
if (Array.isArray(sorted)) {
items = normalizeItems(sorted);
}
}
for (const decorator of entry.decorators) {
const decorated = await decorator(items, context);
if (Array.isArray(decorated)) {
items = normalizeItems(decorated);
}
}
return {
items,
videos: items.map((item) => item.video).filter(Boolean),
metadata: {
why: context.getWhy(),
config: context.config,
},
};
}
function listFeeds() {
return Array.from(feeds.values()).map((entry) => entry.publicDefinition);
}
function getFeedDefinition(name) {
const entry = feeds.get(name);
return entry ? entry.publicDefinition : null;
}
return {
registerFeed,
runFeed,
listFeeds,
getFeedDefinition,
};
}
export { DEFAULT_FEED_CONFIG, DEFAULT_CONFIG_SCHEMA };
+19
View File
@@ -0,0 +1,19 @@
// js/feedEngine/index.js
export {
createFeedEngine,
DEFAULT_FEED_CONFIG,
DEFAULT_CONFIG_SCHEMA,
} from "./engine.js";
export {
createDedupeByRootStage,
createBlacklistFilterStage,
createWatchHistorySuppressionStage,
} from "./stages.js";
export { createChronologicalSorter } from "./sorters.js";
export {
createActiveNostrSource,
createSubscriptionAuthorsSource,
createWatchHistoryPointerSource,
} from "./sources.js";
export { createWatchHistoryFeedDefinition, registerWatchHistoryFeed } from "./watchHistoryFeed.js";
+32
View File
@@ -0,0 +1,32 @@
// js/feedEngine/sorters.js
export function createChronologicalSorter({ direction = "desc" } = {}) {
const normalizedDirection = direction === "asc" ? "asc" : "desc";
return function chronologicalSorter(items = []) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
copy.sort((a, b) => {
const aTs = Number(a?.video?.created_at);
const bTs = Number(b?.video?.created_at);
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
const normalizedBTs = Number.isFinite(bTs) ? bTs : Number.NEGATIVE_INFINITY;
if (normalizedATs !== normalizedBTs) {
const diff = normalizedATs - normalizedBTs;
return normalizedDirection === "asc" ? diff : -diff;
}
const aId = typeof a?.video?.id === "string" ? a.video.id : "";
const bId = typeof b?.video?.id === "string" ? b.video.id : "";
return normalizedDirection === "asc"
? aId.localeCompare(bId)
: bId.localeCompare(aId);
});
return copy;
};
}
+198
View File
@@ -0,0 +1,198 @@
// js/feedEngine/sources.js
import nostrService from "../services/nostrService.js";
import watchHistoryService from "../watchHistoryService.js";
import { isPlainObject, toArray, toSet } from "./utils.js";
function resolveService(candidate, fallback) {
if (candidate && typeof candidate === "object") {
return candidate;
}
return fallback;
}
function normalizeAuthor(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed;
}
function normalizeActorCandidate(...values) {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
}
return "";
}
export function createActiveNostrSource({ service } = {}) {
const resolvedService = resolveService(service, nostrService);
return async function activeNostrSource(context = {}) {
const options = {
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
isAuthorBlocked:
typeof context?.runtime?.isAuthorBlocked === "function"
? context.runtime.isAuthorBlocked
: () => false,
};
let videos = [];
try {
videos = await Promise.resolve(
resolvedService.getFilteredActiveVideos(options)
);
} catch (error) {
context?.log?.("[active-source] Failed to resolve active videos", error);
videos = [];
}
return (Array.isArray(videos) ? videos : []).map((video) => ({
video,
metadata: {
source: "nostr:active",
},
}));
};
}
export function createSubscriptionAuthorsSource({ service } = {}) {
const resolvedService = resolveService(service, nostrService);
return async function subscriptionAuthorsSource(context = {}) {
const runtimeAuthors = toArray(
context?.runtime?.subscriptionAuthors || context?.runtime?.authors
).map(normalizeAuthor);
const configAuthors = toArray(context?.config?.actorFilters).map(
normalizeAuthor
);
const hookAuthors = [];
const hook = context?.hooks?.subscriptions;
if (isPlainObject(hook) && typeof hook.resolveAuthors === "function") {
try {
const resolved = await hook.resolveAuthors(context);
hookAuthors.push(...toArray(resolved).map(normalizeAuthor));
} catch (error) {
context?.log?.("[subscriptions-source] resolveAuthors threw", error);
}
}
const authors = new Set(
[...runtimeAuthors, ...configAuthors, ...hookAuthors].filter(Boolean)
);
if (!authors.size) {
return [];
}
const options = {
blacklistedEventIds: toSet(context?.runtime?.blacklistedEventIds),
isAuthorBlocked:
typeof context?.runtime?.isAuthorBlocked === "function"
? context.runtime.isAuthorBlocked
: () => false,
};
let videos = [];
try {
videos = await Promise.resolve(
resolvedService.getFilteredActiveVideos(options)
);
} catch (error) {
context?.log?.(
"[subscriptions-source] Failed to resolve videos from nostrService",
error
);
videos = [];
}
const filtered = (Array.isArray(videos) ? videos : []).filter((video) => {
const author = normalizeAuthor(video?.pubkey);
return author && authors.has(author);
});
return filtered.map((video) => ({
video,
metadata: {
source: "nostr:subscriptions",
matchedAuthor: normalizeAuthor(video?.pubkey),
},
}));
};
}
export function createWatchHistoryPointerSource({ service } = {}) {
const resolvedService = resolveService(service, watchHistoryService);
return async function watchHistoryPointerSource(context = {}) {
const actor = normalizeActorCandidate(
context?.config?.actor,
context?.runtime?.watchHistory?.actor,
context?.runtime?.actor
);
if (!resolvedService || typeof resolvedService.getQueuedPointers !== "function") {
return [];
}
let pointers = [];
try {
pointers = await Promise.resolve(
resolvedService.getQueuedPointers(actor || undefined)
);
} catch (error) {
context?.log?.(
"[watch-history-source] Failed to load queued pointers",
error
);
pointers = [];
}
const results = [];
const hook = context?.hooks?.watchHistory;
const resolveVideoHook =
isPlainObject(hook) && typeof hook.resolveVideo === "function"
? hook.resolveVideo
: null;
for (const pointer of Array.isArray(pointers) ? pointers : []) {
const dto = {
video: null,
pointer,
metadata: {
source: "watch-history",
actor: actor || null,
},
};
if (pointer && isPlainObject(pointer) && pointer.video) {
dto.video = pointer.video;
}
if (!dto.video && resolveVideoHook) {
try {
const resolvedVideo = await resolveVideoHook(pointer, context);
if (resolvedVideo) {
dto.video = resolvedVideo;
}
} catch (error) {
context?.log?.(
"[watch-history-source] resolveVideo hook threw",
error
);
}
}
results.push(dto);
}
return results;
};
}
+189
View File
@@ -0,0 +1,189 @@
// js/feedEngine/stages.js
import { getApplication } from "../applicationContext.js";
import nostrService from "../services/nostrService.js";
import { dedupeToNewestByRoot } from "../utils/videoDeduper.js";
import { isPlainObject, toSet } from "./utils.js";
function resolveDedupeFunction(customDedupe) {
if (typeof customDedupe === "function") {
return customDedupe;
}
const app = getApplication?.();
if (app && typeof app.dedupeVideosByRoot === "function") {
return (videos) => app.dedupeVideosByRoot(videos);
}
return (videos) => dedupeToNewestByRoot(videos);
}
export function createDedupeByRootStage({
stageName = "dedupe-by-root",
dedupe: customDedupe,
} = {}) {
const dedupeFn = resolveDedupeFunction(customDedupe);
return async function dedupeByRootStage(items = [], context = {}) {
const videos = items.map((item) => item?.video).filter(Boolean);
const dedupedVideos = dedupeFn(videos) || [];
const allowedIds = new Set();
for (const video of dedupedVideos) {
if (video && typeof video.id === "string") {
allowedIds.add(video.id);
}
}
if (!allowedIds.size) {
return items;
}
const keep = [];
for (const item of items) {
const video = item?.video;
const videoId = video && typeof video.id === "string" ? video.id : "";
if (!videoId || allowedIds.has(videoId)) {
keep.push(item);
continue;
}
const rootId =
typeof video?.videoRootId === "string" && video.videoRootId
? video.videoRootId
: videoId;
if (typeof item?.metadata === "object" && item.metadata) {
item.metadata.droppedByStage = stageName;
}
context?.addWhy?.({
stage: stageName,
type: "dedupe",
reason: "older-root-version",
videoId,
rootId,
});
}
return keep;
};
}
export function createBlacklistFilterStage({
stageName = "blacklist-filter",
shouldIncludeVideo,
} = {}) {
const includeFn =
typeof shouldIncludeVideo === "function"
? shouldIncludeVideo
: (video, options) => nostrService.shouldIncludeVideo(video, options);
return async function blacklistFilterStage(items = [], context = {}) {
const blacklist = toSet(context?.runtime?.blacklistedEventIds);
const isAuthorBlocked =
typeof context?.runtime?.isAuthorBlocked === "function"
? context.runtime.isAuthorBlocked
: () => false;
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
const results = [];
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
let include = false;
try {
include = includeFn(video, options) !== false;
} catch (error) {
context?.log?.(`[${stageName}] shouldIncludeVideo threw`, error);
include = false;
}
if (!include) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "blacklist",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
results.push(item);
}
return results;
};
}
function resolveSuppressionHook(context) {
if (!context || typeof context !== "object") {
return null;
}
const fromHooks = context?.hooks?.watchHistory;
if (isPlainObject(fromHooks) && typeof fromHooks.shouldSuppress === "function") {
return fromHooks.shouldSuppress;
}
const runtime = context?.runtime?.watchHistory;
if (isPlainObject(runtime) && typeof runtime.shouldSuppress === "function") {
return runtime.shouldSuppress;
}
return null;
}
export function createWatchHistorySuppressionStage({
stageName = "watch-history-suppression",
shouldSuppress,
} = {}) {
return async function watchHistorySuppressionStage(items = [], context = {}) {
const hook =
typeof shouldSuppress === "function" ? shouldSuppress : resolveSuppressionHook(context);
if (typeof hook !== "function") {
return items;
}
const results = [];
for (const item of items) {
let suppress = false;
try {
suppress = await hook(item, context);
} catch (error) {
context?.log?.(`[${stageName}] suppression hook threw`, error);
suppress = false;
}
if (suppress) {
const detail = {
stage: stageName,
type: "filter",
reason: "watch-history",
};
if (item?.video && typeof item.video.id === "string") {
detail.videoId = item.video.id;
}
if (item?.pointer) {
detail.pointer = item.pointer;
} else if (item?.metadata && item.metadata.pointerKey) {
detail.pointerKey = item.metadata.pointerKey;
}
context?.addWhy?.(detail);
continue;
}
results.push(item);
}
return results;
};
}
+29
View File
@@ -0,0 +1,29 @@
// js/feedEngine/utils.js
export function isPlainObject(value) {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function toSet(values) {
if (values instanceof Set) {
return new Set(values);
}
if (Array.isArray(values)) {
return new Set(values);
}
return new Set();
}
export function toArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return [...value];
}
return [value];
}
+527
View File
@@ -0,0 +1,527 @@
// js/feedEngine/watchHistoryFeed.js
import { pointerKey, normalizePointerInput } from "../nostr.js";
import watchHistoryService from "../watchHistoryService.js";
import nostrService from "../services/nostrService.js";
import { createWatchHistoryMetadataResolver } from "../watchHistoryMetadata.js";
import {
isWatchHistoryDebugEnabled,
logWatchHistoryDebug,
} from "../watchHistoryDebug.js";
import {
createBlacklistFilterStage,
createWatchHistorySuppressionStage,
} from "./stages.js";
import { isPlainObject } from "./utils.js";
const WATCH_HISTORY_FEED_LOG_NAMESPACE = "watchHistoryFeed";
function debugInfo(message, details) {
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "info", message, details);
}
function debugWarn(message, details) {
logWatchHistoryDebug(WATCH_HISTORY_FEED_LOG_NAMESPACE, "warn", message, details);
}
function normalizeActorCandidate(...values) {
for (const value of values) {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
}
return "";
}
function resolveWatchedAt(...candidates) {
for (const candidate of candidates) {
if (!Number.isFinite(candidate)) {
continue;
}
let normalized = Math.floor(Number(candidate));
if (normalized <= 0) {
continue;
}
if (normalized > 10_000_000_000) {
normalized = Math.floor(normalized / 1000);
}
if (normalized > 0) {
return normalized;
}
}
return 0;
}
function resolveResumeSeconds(candidate) {
const candidates = [
candidate?.resumeSeconds,
candidate?.resume,
candidate?.resumeAt,
candidate?.resumePosition,
candidate?.resumeTime,
candidate?.progress?.resume,
candidate?.progress?.seconds,
candidate?.progress?.position,
candidate?.pointer?.resumeSeconds,
candidate?.pointer?.resume,
candidate?.pointer?.resumeAt,
];
for (const entry of candidates) {
if (!Number.isFinite(entry)) {
continue;
}
const normalized = Math.max(0, Math.floor(entry));
if (normalized > 0) {
return normalized;
}
}
return null;
}
function resolveCompleted(candidate) {
const candidates = [
candidate?.completed,
candidate?.complete,
candidate?.progress?.completed,
candidate?.progress?.complete,
candidate?.pointer?.completed,
candidate?.pointer?.complete,
];
return candidates.some((value) => value === true);
}
function normalizeHistoryEntry(raw) {
const pointer = normalizePointerInput(raw?.pointer || raw);
if (!pointer) {
return null;
}
const key = pointerKey(pointer);
if (!key) {
return null;
}
const watchedAt = resolveWatchedAt(
raw?.watchedAt,
raw?.timestamp,
raw?.created_at,
pointer?.watchedAt,
raw?.metadata?.watchedAt,
pointer?.metadata?.watchedAt,
);
const resumeAt = (() => {
const fromPointer = Number.isFinite(pointer?.resumeAt)
? Math.max(0, Math.floor(pointer.resumeAt))
: Number.isFinite(pointer?.metadata?.resumeAt)
? Math.max(0, Math.floor(pointer.metadata.resumeAt))
: null;
if (fromPointer !== null) {
return fromPointer;
}
return resolveResumeSeconds(raw);
})();
const completed =
pointer?.completed === true ||
pointer?.metadata?.completed === true ||
resolveCompleted(raw);
const pointerVideo =
(pointer?.video && isPlainObject(pointer.video) ? pointer.video : null) ||
(pointer?.metadata?.video && isPlainObject(pointer.metadata.video)
? pointer.metadata.video
: null);
const pointerProfile =
(pointer?.profile && isPlainObject(pointer.profile) ? pointer.profile : null) ||
(pointer?.metadata?.profile && isPlainObject(pointer.metadata.profile)
? pointer.metadata.profile
: null);
return {
pointer,
pointerKey: key,
watchedAt,
video:
isPlainObject(raw?.video) && raw.video
? raw.video
: pointerVideo,
metadata: {
source: "watch-history",
pointerKey: key,
watchedAt,
resumeAt,
completed,
session: raw?.session === true || pointer.session === true,
video: pointerVideo,
profile: pointerProfile,
},
};
}
function createWatchHistorySource({ service = watchHistoryService } = {}) {
return async function watchHistorySource(context = {}) {
const actor = normalizeActorCandidate(
context?.runtime?.watchHistory?.actor,
context?.config?.actor,
context?.runtime?.actor,
);
if (!service || typeof service.loadLatest !== "function") {
return [];
}
let items = [];
try {
items = await Promise.resolve(service.loadLatest(actor || undefined));
} catch (error) {
context?.log?.("[watch-history-feed] Failed to load history", error);
items = [];
}
const results = [];
for (const raw of Array.isArray(items) ? items : []) {
const normalized = normalizeHistoryEntry(raw);
if (normalized) {
results.push(normalized);
}
}
results.sort((a, b) => {
if (a.watchedAt !== b.watchedAt) {
return b.watchedAt - a.watchedAt;
}
return a.pointerKey.localeCompare(b.pointerKey);
});
return results;
};
}
function createWatchHistoryHydratorStage({
service = watchHistoryService,
metadataResolver,
} = {}) {
const resolver = metadataResolver || createWatchHistoryMetadataResolver();
return async function watchHistoryHydratorStage(items = [], context = {}) {
const shouldStoreMetadata =
typeof service?.shouldStoreMetadata === "function"
? service.shouldStoreMetadata() !== false
: true;
const results = [];
for (const item of Array.isArray(items) ? items : []) {
if (!item || typeof item !== "object") {
continue;
}
const pointerKeyValue =
typeof item.pointerKey === "string" && item.pointerKey
? item.pointerKey
: pointerKey(item.pointer);
const pointerType =
typeof item?.pointer?.type === "string" ? item.pointer.type : null;
const pointerValue =
typeof item?.pointer?.value === "string" ? item.pointer.value : null;
const watchedAtValue = Number.isFinite(item?.watchedAt)
? Math.max(0, Math.floor(Number(item.watchedAt)))
: Number.isFinite(item?.metadata?.watchedAt)
? Math.max(0, Math.floor(Number(item.metadata.watchedAt)))
: 0;
let video = item.video || null;
let profile = item.metadata?.profile || null;
if (isWatchHistoryDebugEnabled()) {
debugInfo("Hydrating watch history item.", {
pointerKey: pointerKeyValue || null,
pointerType,
pointerValue,
hasInitialVideo: Boolean(video),
hasInitialProfile: Boolean(profile),
watchedAt: watchedAtValue || null,
});
}
if (pointerKeyValue && typeof service?.getLocalMetadata === "function") {
try {
const stored = service.getLocalMetadata(pointerKeyValue);
if (stored) {
const hadVideo = Boolean(video);
const hadProfile = Boolean(profile);
video = video || stored.video || null;
profile = profile || stored.profile || null;
if (isWatchHistoryDebugEnabled()) {
debugInfo("Loaded cached metadata for pointer.", {
pointerKey: pointerKeyValue,
hadVideo,
hadProfile,
cachedVideo: Boolean(stored.video),
cachedProfile: Boolean(stored.profile),
resultingVideo: Boolean(video),
resultingProfile: Boolean(profile),
});
}
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to read cached metadata",
error,
);
if (isWatchHistoryDebugEnabled()) {
debugWarn("Failed to read cached metadata for pointer.", {
pointerKey: pointerKeyValue || null,
error,
});
}
}
}
if (!video && resolver?.resolveVideo) {
if (isWatchHistoryDebugEnabled()) {
debugInfo("Resolving video via metadata resolver.", {
pointerKey: pointerKeyValue || null,
pointerType,
pointerValue,
});
}
try {
video = await resolver.resolveVideo(item.pointer);
if (isWatchHistoryDebugEnabled()) {
if (video) {
debugInfo("Metadata resolver returned video candidate.", {
pointerKey: pointerKeyValue || null,
videoId: typeof video.id === "string" ? video.id : null,
title:
typeof video.title === "string" && video.title.trim()
? video.title
: null,
invalid: Boolean(video.invalid),
});
} else {
debugWarn("Metadata resolver returned no video candidate.", {
pointerKey: pointerKeyValue || null,
});
}
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to resolve video from pointer",
error,
);
if (isWatchHistoryDebugEnabled()) {
debugWarn("Metadata resolver threw while resolving video.", {
pointerKey: pointerKeyValue || null,
error,
});
}
}
}
if (video?.pubkey && !profile && resolver?.resolveProfile) {
if (isWatchHistoryDebugEnabled()) {
debugInfo("Resolving profile for video author.", {
pointerKey: pointerKeyValue || null,
pubkey: video.pubkey,
});
}
try {
profile = resolver.resolveProfile(video.pubkey) || null;
if (isWatchHistoryDebugEnabled()) {
if (profile) {
const label =
profile.display_name ||
profile.name ||
profile.username ||
profile.nip05 ||
null;
debugInfo("Resolved profile for video author.", {
pointerKey: pointerKeyValue || null,
pubkey: video.pubkey,
label: label || null,
});
} else {
debugWarn("Resolver returned no profile for video author.", {
pointerKey: pointerKeyValue || null,
pubkey: video.pubkey,
});
}
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to resolve profile for pointer",
error,
);
if (isWatchHistoryDebugEnabled()) {
debugWarn("Profile resolver threw while hydrating metadata.", {
pointerKey: pointerKeyValue || null,
pubkey: video.pubkey,
error,
});
}
}
}
const metadata = {
...(isPlainObject(item.metadata) ? item.metadata : {}),
pointerKey: pointerKeyValue,
watchedAt: watchedAtValue || null,
resumeAt: item.metadata?.resumeAt ?? null,
completed: item.metadata?.completed ?? false,
session: item.metadata?.session === true,
video: video || null,
profile: profile || null,
};
if (isWatchHistoryDebugEnabled()) {
const summary = {
pointerKey: pointerKeyValue || null,
pointerType,
pointerValue,
watchedAt: watchedAtValue || null,
videoId: typeof video?.id === "string" ? video.id : null,
title:
typeof video?.title === "string" && video.title?.trim()
? video.title
: null,
invalid: Boolean(video?.invalid),
hasProfile: Boolean(profile),
};
if (video) {
debugInfo("Hydrated watch history entry metadata.", summary);
} else {
debugWarn("Hydrated entry is missing resolved video metadata.", summary);
}
}
const nextItem = {
...item,
pointerKey: pointerKeyValue,
watchedAt: watchedAtValue,
video: video || null,
metadata,
};
results.push(nextItem);
if (pointerKeyValue) {
if (shouldStoreMetadata) {
try {
service?.setLocalMetadata?.(pointerKeyValue, {
video: metadata.video,
profile: metadata.profile,
});
if (isWatchHistoryDebugEnabled()) {
debugInfo("Stored metadata cache entry for pointer.", {
pointerKey: pointerKeyValue,
cachedVideo: Boolean(metadata.video),
cachedProfile: Boolean(metadata.profile),
});
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to persist metadata cache",
error,
);
if (isWatchHistoryDebugEnabled()) {
debugWarn("Failed to persist metadata cache entry.", {
pointerKey: pointerKeyValue,
error,
});
}
}
} else if (typeof service?.removeLocalMetadata === "function") {
try {
service.removeLocalMetadata(pointerKeyValue);
if (isWatchHistoryDebugEnabled()) {
debugInfo("Cleared metadata cache entry for pointer.", {
pointerKey: pointerKeyValue,
});
}
} catch (error) {
context?.log?.(
"[watch-history-feed] Failed to clear cached metadata",
error,
);
if (isWatchHistoryDebugEnabled()) {
debugWarn("Failed to clear cached metadata entry.", {
pointerKey: pointerKeyValue,
error,
});
}
}
}
}
}
return results;
};
}
function createWatchHistorySorter() {
return function watchHistorySorter(items = []) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
copy.sort((a, b) => {
if (a?.watchedAt !== b?.watchedAt) {
return (b?.watchedAt || 0) - (a?.watchedAt || 0);
}
const aKey = typeof a?.pointerKey === "string" ? a.pointerKey : "";
const bKey = typeof b?.pointerKey === "string" ? b.pointerKey : "";
return aKey.localeCompare(bKey);
});
return copy;
};
}
export function createWatchHistoryFeedDefinition({
service = watchHistoryService,
nostr = nostrService,
metadataResolverFactory = createWatchHistoryMetadataResolver,
shouldIncludeVideo,
} = {}) {
const metadataResolver = metadataResolverFactory({ nostr });
const blacklistStage = createBlacklistFilterStage({
shouldIncludeVideo:
typeof shouldIncludeVideo === "function"
? shouldIncludeVideo
: (video, options) =>
(nostr?.shouldIncludeVideo
? nostr.shouldIncludeVideo(video, options)
: true),
});
return {
source: createWatchHistorySource({ service }),
stages: [
createWatchHistoryHydratorStage({ service, metadataResolver }),
blacklistStage,
createWatchHistorySuppressionStage(),
],
sorter: createWatchHistorySorter(),
defaultConfig: {
actorFilters: [],
},
};
}
export function registerWatchHistoryFeed(engine, options = {}) {
if (!engine || typeof engine.registerFeed !== "function") {
return null;
}
const definition = createWatchHistoryFeedDefinition(options);
return engine.registerFeed("watch-history", definition);
}
+80 -59
View File
@@ -1,7 +1,26 @@
import { createCardObserver } from "./dom/cardObserver.js";
import { infoHashFromMagnet } from "./magnets.js";
import { TorrentClient, torrentClient } from "./webtorrent.js";
const containerState = new WeakMap();
const badgeUpdateListeners = new Set();
function notifyBadgeUpdate(payload) {
if (!payload) {
return;
}
badgeUpdateListeners.forEach((listener) => {
if (typeof listener !== "function") {
return;
}
try {
listener(payload);
} catch (err) {
console.warn("[gridHealth] badge listener failed", err);
}
});
}
const ROOT_MARGIN = "200px 0px";
function now() {
@@ -249,28 +268,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 +386,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;
@@ -513,7 +498,8 @@ function setBadge(card, state, details) {
});
badge.setAttribute("aria-label", tooltip);
badge.setAttribute("title", tooltip);
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
const ariaLive = entry.role === "alert" ? "assertive" : "polite";
badge.setAttribute("aria-live", ariaLive);
badge.setAttribute("role", entry.role);
badge.dataset.streamHealthState = normalizedState;
if (hasPeerCount) {
@@ -521,6 +507,20 @@ function setBadge(card, state, details) {
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
const payload = {
card,
state: normalizedState,
peers: hasPeerCount ? peersValue : null,
reason: details && typeof details.reason === "string" ? details.reason : null,
checkedAt:
details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null,
text: badge.textContent,
tooltip,
role: entry.role,
ariaLive,
};
notifyBadgeUpdate(payload);
}
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
@@ -539,6 +539,10 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
return;
}
if (!(pendingByCard instanceof WeakMap)) {
return;
}
if (pendingByCard.has(card)) {
return;
}
@@ -590,36 +594,53 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) {
});
}
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;
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;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
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" });
}
});
processObserverEntries(state.observer.takeRecords(), state);
}
},
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 refreshHealthBadges(container) {
export function attachHealthBadges(container, options = {}) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = containerState.get(container);
if (!state) {
return;
if (options && typeof options.onUpdate === "function") {
badgeUpdateListeners.add(options.onUpdate);
}
const records = state.observer.takeRecords();
processObserverEntries(records, state);
gridCardObserver.observe(container);
}
export function refreshHealthBadges(container) {
gridCardObserver.refresh(container);
}
+1962
View File
File diff suppressed because it is too large. Load diff
+449 -151
View File
@@ -1,7 +1,50 @@
// js/index.js
import { validateInstanceConfig } from "../config/validate-config.js";
import { ASSET_VERSION } from "../config/asset-version.js";
import "./bufferPolyfill.js";
import { trackPageView } from "./analytics.js";
import Application from "./app.js";
import { setApplication, setApplicationReady } from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import { loadView, viewInitRegistry } from "./viewManager.js";
validateInstanceConfig();
let application = null;
let applicationReadyPromise = Promise.resolve();
function startApplication() {
if (application) {
return applicationReadyPromise;
}
application = new Application({
services: {
nostrService,
r2Service,
},
loadView,
});
setApplication(application);
const startupPromise = (async () => {
await application.init();
if (typeof application.start === "function") {
await application.start();
}
})();
applicationReadyPromise = startupPromise;
setApplicationReady(startupPromise);
startupPromise.catch((error) => {
console.error("Application failed to initialize:", error);
});
return startupPromise;
}
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
@@ -60,13 +103,26 @@ document.addEventListener("animationend", handleFadeInAnimationComplete, true);
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
// 1) Load modals (login, application, etc.)
const withAssetVersion = (url) => {
if (typeof url !== "string" || url.length === 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}v=${encodeURIComponent(ASSET_VERSION)}`;
};
const fetchPartial = async (url) => {
const response = await fetch(withAssetVersion(url), { cache: "no-store" });
if (!response.ok) {
throw new Error("Failed to load " + url);
}
return response.text();
};
async function loadModal(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
const html = await fetchPartial(url);
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
const sanitizedHtml = html.replace(
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
@@ -84,11 +140,7 @@ async function loadModal(url) {
// 2) Load sidebar
async function loadSidebar(url, containerId) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
const html = await fetchPartial(url);
document.getElementById(containerId).innerHTML = html;
console.log(url, "loaded into", containerId);
} catch (err) {
@@ -99,11 +151,7 @@ async function loadSidebar(url, containerId) {
// 3) Load the disclaimer (now separate)
async function loadDisclaimer(url, containerId) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("Failed to load " + url);
}
const html = await response.text();
const html = await fetchPartial(url);
document.getElementById(containerId).insertAdjacentHTML("beforeend", html);
console.log(url, "disclaimer loaded into", containerId);
} catch (err) {
@@ -111,138 +159,376 @@ async function loadDisclaimer(url, containerId) {
}
}
// 4) Load everything: modals, sidebar, disclaimers
Promise.all([
// Existing modals
loadModal("components/login-modal.html"),
loadModal("components/application-form.html"),
loadModal("components/content-appeals-form.html"),
async function bootstrapInterface() {
await Promise.all([
loadModal("components/login-modal.html"),
loadModal("components/application-form.html"),
loadModal("components/content-appeals-form.html"),
loadModal("components/general-feedback-form.html"),
loadModal("components/feature-request-form.html"),
loadModal("components/bug-fix-form.html"),
]);
// New forms
loadModal("components/general-feedback-form.html"),
loadModal("components/feature-request-form.html"),
loadModal("components/bug-fix-form.html"),
])
.then(() => {
console.log("Modals loaded.");
return loadSidebar("components/sidebar.html", "sidebarContainer");
})
.then(() => {
console.log("Sidebar loaded.");
console.log("Modals loaded.");
// Attach mobile menu button toggle logic (for sidebar)
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
const sidebar = document.getElementById("sidebar");
const app = document.getElementById("app");
if (mobileMenuBtn && sidebar && app) {
mobileMenuBtn.addEventListener("click", () => {
sidebar.classList.toggle("sidebar-open");
app.classList.toggle("sidebar-open");
});
await loadSidebar("components/sidebar.html", "sidebarContainer");
console.log("Sidebar loaded.");
const sidebar = document.getElementById("sidebar");
const collapseToggle = document.getElementById("sidebarCollapseToggle");
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
sidebar.setAttribute("data-footer-state", "collapsed");
}
if (!collapseToggle) {
console.warn("Sidebar collapse toggle not found; skipping density controls.");
}
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
const sidebarOverlay = document.getElementById("sidebarOverlay");
const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebarCollapsed";
const SIDEBAR_WIDTH_EXPANDED = "16rem";
const SIDEBAR_WIDTH_COLLAPSED = "4rem";
const DEFAULT_SIDEBAR_COLLAPSED = true;
let isSidebarCollapsed = DEFAULT_SIDEBAR_COLLAPSED;
let isFooterDropupExpanded = false;
let syncFooterDropupFn = null;
const readStoredSidebarCollapsed = () => {
try {
const storedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
if (storedValue === null) {
return DEFAULT_SIDEBAR_COLLAPSED;
}
return storedValue === "true";
} catch (error) {
console.warn("Unable to read sidebar collapse state from storage:", error);
return DEFAULT_SIDEBAR_COLLAPSED;
}
};
// Attach "More" button toggle logic for footer links
const footerDropdownButton = document.getElementById(
"footerDropdownButton"
const persistSidebarCollapsed = (collapsed) => {
try {
localStorage.setItem(
SIDEBAR_COLLAPSED_STORAGE_KEY,
collapsed ? "true" : "false",
);
} catch (error) {
console.warn("Unable to persist sidebar collapse state:", error);
}
};
const applySidebarDensity = (collapsed) => {
const widthTargets = [document.documentElement, document.body].filter(
(element) => element instanceof HTMLElement,
);
if (footerDropdownButton) {
footerDropdownButton.addEventListener("click", () => {
const footerLinksContainer = document.getElementById(
"footerLinksContainer"
);
if (!footerLinksContainer) return;
footerLinksContainer.classList.toggle("hidden");
if (footerLinksContainer.classList.contains("hidden")) {
footerDropdownButton.innerHTML = "More &#9660;";
} else {
footerDropdownButton.innerHTML = "Less &#9650;";
}
});
if (sidebar instanceof HTMLElement) {
widthTargets.push(sidebar);
}
const appShell = document.getElementById("app");
if (appShell instanceof HTMLElement) {
widthTargets.push(appShell);
}
const state = collapsed ? "collapsed" : "expanded";
const nextWidth = collapsed
? SIDEBAR_WIDTH_COLLAPSED
: SIDEBAR_WIDTH_EXPANDED;
// Load and set up sidebar navigation
return import("./sidebar.js").then((module) => {
module.setupSidebarNavigation();
widthTargets.forEach((element) => {
element.style.setProperty("--sidebar-width", nextWidth);
});
})
.then(() => {
// Now load the disclaimer
return loadDisclaimer("components/disclaimer.html", "modalContainer");
})
.then(() => {
console.log("Disclaimer loaded.");
// 1) Login button => open login modal
const loginNavBtn = document.getElementById("loginButton");
if (loginNavBtn) {
loginNavBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
}
});
if (appShell instanceof HTMLElement) {
appShell.dataset.sidebarState = state;
}
// 2) Close login modal
const closeLoginBtn = document.getElementById("closeLoginModal");
if (closeLoginBtn) {
closeLoginBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
[sidebar, document.body]
.filter((element) => element instanceof HTMLElement)
.forEach((element) => {
element.classList.toggle("sidebar-collapsed", collapsed);
element.classList.toggle("sidebar-expanded", !collapsed);
element.setAttribute("data-state", state);
});
if (collapseToggle) {
const actionLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
collapseToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
collapseToggle.setAttribute("data-state", state);
collapseToggle.setAttribute("aria-label", actionLabel);
collapseToggle.setAttribute("title", actionLabel);
}
// 3) "Application Form" => open application form
const openAppFormBtn = document.getElementById("openApplicationModal");
if (openAppFormBtn) {
openAppFormBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
}
});
if (
collapsed &&
isFooterDropupExpanded &&
typeof syncFooterDropupFn === "function"
) {
syncFooterDropupFn(false);
}
};
const syncSidebarDensityToViewport = (isDesktopViewport) => {
if (!collapseToggle) {
applySidebarDensity(false);
return;
}
// 4) Close application form
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
if (closeNostrFormBtn) {
closeNostrFormBtn.addEventListener("click", () => {
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.add("hidden");
}
// If user hasn't seen disclaimer, show it
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
}
}
});
if (isDesktopViewport) {
isSidebarCollapsed = readStoredSidebarCollapsed();
applySidebarDensity(isSidebarCollapsed);
return;
}
// Once everything is loaded, handle the query params (modal? v?) & disclaimers
handleQueryParams();
applySidebarDensity(false);
};
// Listen for hash changes
window.addEventListener("hashchange", handleHashChange);
if (collapseToggle) {
isSidebarCollapsed = readStoredSidebarCollapsed();
}
// Also run once on initial load
handleHashChange();
const setSidebarState = (isOpen) => {
if (sidebar) {
sidebar.classList.toggle("sidebar-open", isOpen);
}
document.body.classList.toggle("sidebar-open", isOpen);
if (mobileMenuBtn) {
mobileMenuBtn.setAttribute("aria-expanded", isOpen ? "true" : "false");
}
};
return import("./disclaimer.js");
})
.then(({ default: disclaimerModal }) => {
disclaimerModal.init();
disclaimerModal.show();
const closeSidebar = () => setSidebarState(false);
const isMobileViewport = () => {
if (typeof window.matchMedia === "function") {
return window.matchMedia("(max-width: 767px)").matches;
}
return window.innerWidth < 768;
};
const toggleSidebar = () => {
if (!sidebar) return;
const isMobile = isMobileViewport();
if (!isMobile) return;
const shouldOpen = !sidebar.classList.contains("sidebar-open");
setSidebarState(shouldOpen);
};
let desktopQuery = null;
const isDesktopViewport = () => {
if (desktopQuery) {
return desktopQuery.matches;
}
return window.innerWidth >= 768;
};
if (collapseToggle) {
collapseToggle.addEventListener("click", (event) => {
const desktop = isDesktopViewport();
if (!desktop) {
event.preventDefault();
return;
}
event.preventDefault();
const nextCollapsed = !isSidebarCollapsed;
isSidebarCollapsed = nextCollapsed;
applySidebarDensity(nextCollapsed);
persistSidebarCollapsed(nextCollapsed);
});
}
if (mobileMenuBtn && sidebar) {
mobileMenuBtn.addEventListener("click", toggleSidebar);
}
if (sidebarOverlay) {
sidebarOverlay.addEventListener("click", closeSidebar);
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && sidebar && sidebar.classList.contains("sidebar-open")) {
closeSidebar();
}
});
if (typeof window.matchMedia === "function") {
desktopQuery = window.matchMedia("(min-width: 768px)");
syncSidebarDensityToViewport(desktopQuery.matches);
const onDesktopChange = (event) => {
if (event.matches) {
closeSidebar();
syncSidebarDensityToViewport(true);
return;
}
syncSidebarDensityToViewport(false);
};
if (typeof desktopQuery.addEventListener === "function") {
desktopQuery.addEventListener("change", onDesktopChange);
} else if (typeof desktopQuery.addListener === "function") {
desktopQuery.addListener(onDesktopChange);
}
} else {
syncSidebarDensityToViewport(window.innerWidth >= 768);
}
const footerDropdownButton = document.getElementById("footerDropdownButton");
const footerLinksContainer = document.getElementById("footerLinksContainer");
const footerDropdownLabel = document.getElementById("footerDropdownText");
const footerDropdownIcon = document.getElementById("footerDropdownIcon");
if (footerDropdownButton && footerLinksContainer) {
const sidebarFooter = footerDropdownButton.closest(".sidebar-footer");
syncFooterDropupFn = (expanded) => {
const nextState = expanded ? "expanded" : "collapsed";
const actionLabel = expanded ? "Show fewer sidebar links" : "Show more sidebar links";
footerDropdownButton.setAttribute("aria-expanded", expanded ? "true" : "false");
footerDropdownButton.dataset.state = nextState;
footerDropdownButton.setAttribute("aria-label", actionLabel);
footerDropdownButton.setAttribute("title", actionLabel);
if (footerDropdownLabel) {
footerDropdownLabel.textContent = expanded ? "Less" : "More";
}
if (footerDropdownIcon) {
footerDropdownIcon.classList.toggle("is-rotated", expanded);
}
footerLinksContainer.classList.remove("hidden");
footerLinksContainer.setAttribute("aria-hidden", expanded ? "false" : "true");
footerLinksContainer.dataset.state = nextState;
if (sidebar) {
sidebar.setAttribute("data-footer-state", nextState);
}
if (sidebarFooter instanceof HTMLElement) {
sidebarFooter.dataset.footerState = nextState;
}
isFooterDropupExpanded = expanded;
};
footerDropdownButton.addEventListener("click", (event) => {
event.preventDefault();
const expanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
if (isSidebarCollapsed && !expanded) {
const nextCollapsed = false;
isSidebarCollapsed = nextCollapsed;
applySidebarDensity(nextCollapsed);
persistSidebarCollapsed(nextCollapsed);
}
if (typeof syncFooterDropupFn === "function") {
syncFooterDropupFn(!expanded);
}
});
const initialExpanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
if (typeof syncFooterDropupFn === "function") {
syncFooterDropupFn(initialExpanded);
}
}
try {
const sidebarModule = await import("./sidebar.js");
if (typeof sidebarModule.setupSidebarNavigation === "function") {
sidebarModule.setupSidebarNavigation({ closeSidebar });
}
} catch (error) {
console.error("Failed to set up sidebar navigation:", error);
}
await loadDisclaimer("components/disclaimer.html", "modalContainer");
console.log("Disclaimer loaded.");
const loginNavBtn = document.getElementById("loginButton");
if (loginNavBtn) {
loginNavBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
}
});
}
const closeLoginBtn = document.getElementById("closeLoginModal");
if (closeLoginBtn) {
closeLoginBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
});
}
const openAppFormBtn = document.getElementById("openApplicationModal");
if (openAppFormBtn) {
openAppFormBtn.addEventListener("click", () => {
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
}
});
}
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
if (closeNostrFormBtn) {
closeNostrFormBtn.addEventListener("click", () => {
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.add("hidden");
}
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
disclaimerModal.classList.remove("hidden");
}
}
});
}
handleQueryParams();
window.addEventListener("hashchange", handleHashChange);
await handleHashChange();
const { default: disclaimerModal } = await import("./disclaimer.js");
disclaimerModal.init();
disclaimerModal.show();
}
async function initializeInterface() {
startApplication();
try {
await bootstrapInterface();
} catch (error) {
console.error("Failed to bootstrap Bitvid interface:", error);
}
}
function onDomReady() {
initializeInterface().catch((error) => {
console.error("Unhandled error during Bitvid initialization:", error);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onDomReady, { once: true });
} else {
onDomReady();
}
/* -------------------------------------------
HELPER FUNCTIONS FOR QUERY AND HASH
-------------------------------------------- */
@@ -257,6 +543,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 +652,46 @@ function handleQueryParams() {
}
}
function handleHashChange() {
async function handleHashChange() {
console.log("handleHashChange called, current hash =", window.location.hash);
try {
await applicationReadyPromise;
} catch (error) {
console.warn(
"Proceeding with hash handling despite application initialization failure:",
error
);
}
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 {
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") {
await 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") {
await 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,
};
}
+116 -35
View File
@@ -1,4 +1,54 @@
import parseMagnet from "https://esm.sh/[email protected].7";
const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
function decodeBase32ToHex(value) {
if (typeof value !== "string" || !value) {
return "";
}
const normalized = value.toLowerCase();
let bits = 0;
let buffer = 0;
const bytes = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) {
return "";
}
buffer = (buffer << 5) | index;
bits += 5;
if (bits >= 8) {
bits -= 8;
const byte = (buffer >> bits) & 0xff;
bytes.push(byte);
}
}
if (bytes.length === 0) {
return "";
}
return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function extractInfoHashFromXt(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const lower = trimmed.toLowerCase();
if (!lower.startsWith("urn:btih:")) {
return "";
}
const payload = trimmed.slice(9);
return normalizeInfoHash(payload);
}
function normalizeInfoHash(candidate) {
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
@@ -9,13 +59,8 @@ function normalizeInfoHash(candidate) {
return trimmed.toLowerCase();
}
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
try {
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to normalize base32 info hash", err);
}
const hex = decodeBase32ToHex(trimmed);
return hex ? hex.toLowerCase() : null;
}
return null;
}
@@ -28,39 +73,75 @@ export function infoHashFromMagnet(magnet) {
if (direct) {
return direct;
}
try {
const parsed = parseMagnet(magnet);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
} catch (err) {
console.warn("Failed to parse magnet for info hash", err);
return null;
}
const parsed = parseMagnetLite(magnet);
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
return hash ? hash.toLowerCase() : null;
}
export function trackersFromMagnet(magnet) {
if (typeof magnet !== "string") {
return [];
}
try {
const parsed = parseMagnet(magnet);
if (!parsed || !Array.isArray(parsed.announce)) {
return [];
}
const deduped = new Set();
parsed.announce.forEach((url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
deduped.add(trimmed);
});
return Array.from(deduped);
} catch (err) {
console.warn("Failed to parse magnet trackers", err);
const parsed = parseMagnetLite(magnet);
if (!parsed || !Array.isArray(parsed.announce)) {
return [];
}
const deduped = new Set();
parsed.announce.forEach((url) => {
if (typeof url !== "string") {
return;
}
const trimmed = url.trim();
if (!trimmed) {
return;
}
deduped.add(trimmed);
});
return Array.from(deduped);
}
function parseMagnetLite(magnet) {
if (typeof magnet !== "string") {
return { infoHash: "", announce: [] };
}
const trimmed = magnet.trim();
if (!trimmed) {
return { infoHash: "", announce: [] };
}
const lower = trimmed.toLowerCase();
const queryIndex = lower.indexOf("?");
const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : "";
const params = paramsPart.split("&");
const announce = [];
let infoHash = "";
for (const param of params) {
if (!param) {
continue;
}
const [rawKey, ...rawValueParts] = param.split("=");
const rawValue = rawValueParts.join("=");
const key = decodeURIComponent((rawKey || "").replace(/\+/g, " ")).toLowerCase();
const value = decodeURIComponent((rawValue || "").replace(/\+/g, " "));
if (!key) {
continue;
}
if (key === "xt") {
const derived = extractInfoHashFromXt(value);
if (derived && !infoHash) {
infoHash = derived;
}
} else if (key === "tr") {
if (value) {
announce.push(value);
}
}
}
return { infoHash, announce };
}
+7714 -278
View File
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
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",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
REPOST: "repost",
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.REPOST]: {
type: NOTE_TYPES.REPOST,
label: "NIP-18 repost",
kind: 6,
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "empty",
description: "Content field intentionally empty for pure repost events.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
type: NOTE_TYPES.NIP71_VIDEO,
label: "NIP-71 video (normal)",
kind: 21,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the video body; tags carry structured metadata.",
},
},
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
label: "NIP-71 video (short)",
kind: 22,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the short-form video; structured fields live in tags.",
},
},
[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 buildRepostEvent({
pubkey,
created_at,
eventId = "",
eventRelay = "",
address = "",
addressRelay = "",
authorPubkey = "",
additionalTags = [],
}) {
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
const tags = [];
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
const normalizedEventRelay = typeof eventRelay === "string" ? eventRelay.trim() : "";
if (normalizedEventId) {
if (normalizedEventRelay) {
tags.push(["e", normalizedEventId, normalizedEventRelay]);
} else {
tags.push(["e", normalizedEventId]);
}
}
const normalizedAddress = typeof address === "string" ? address.trim() : "";
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
if (normalizedAddress) {
if (normalizedAddressRelay) {
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
} else {
tags.push(["a", normalizedAddress]);
}
}
const normalizedAuthorPubkey =
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
if (normalizedAuthorPubkey) {
tags.push(["p", normalizedAuthorPubkey]);
}
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 ?? 6,
pubkey,
created_at,
tags,
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 });
}
+512
View File
@@ -0,0 +1,512 @@
const REMOTE_IMPORT_TIMEOUT = 4500;
const LOCAL_IMPORT_TIMEOUT = 2500;
const SCRIPT_FALLBACK_TIMEOUT = 6000;
const CDN_BUNDLE_URL =
"https://cdn.jsdelivr.net/npm/[email protected]/lib/nostr.bundle.min.js";
const LOCAL_BUNDLE_RELATIVE_URL = "../vendor/nostr-tools.bundle.min.js";
const moduleUrl =
typeof import.meta !== "undefined" && import.meta?.url ? import.meta.url : null;
const resolveRelativeUrl = (relativePath) => {
if (!relativePath) {
return relativePath;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(relativePath)) {
return relativePath;
}
if (moduleUrl) {
try {
return new URL(relativePath, moduleUrl).href;
} catch (error) {
// Fall through to other resolution strategies
}
}
if (typeof window !== "undefined" && window?.location?.href) {
try {
return new URL(relativePath, window.location.href).href;
} catch (error) {
return relativePath;
}
}
return relativePath;
};
const LOCAL_BUNDLE_URL = resolveRelativeUrl(LOCAL_BUNDLE_RELATIVE_URL);
let bootstrapPromise = null;
const getGlobalScope = () => {
if (typeof window !== "undefined") {
return window;
}
if (typeof globalThis !== "undefined") {
return globalThis;
}
return null;
};
const hasWorkingNip04 = (candidate) =>
!!(
candidate &&
typeof candidate === "object" &&
typeof candidate.encrypt === "function" &&
typeof candidate.decrypt === "function"
);
const toSerializableError = (error) => {
if (!error) {
return null;
}
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
};
}
if (typeof error === "string") {
return { message: error };
}
try {
return { ...error };
} catch (serializationError) {
return { message: String(error) };
}
};
const withTimeout = (factory, ms, label) =>
new Promise((resolve) => {
const start = Date.now();
let settled = false;
let timeoutId;
const finalize = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
resolve({
label,
duration: Date.now() - start,
...result,
});
};
const runner = Promise.resolve().then(factory);
timeoutId = setTimeout(() => {
finalize({
ok: false,
timedOut: true,
error: new Error(
`[bitvid][nostr-tools] ${label} timed out after ${ms}ms`
),
});
runner.catch(() => {});
}, ms);
runner.then(
(value) => finalize({ ok: true, value, timedOut: false }),
(error) => finalize({ ok: false, error, timedOut: false })
);
});
const discoverLocalEntrypoints = (scope) => {
if (!scope || typeof scope !== "object") {
return [];
}
const entrypoints = [];
const configuredEntrypoints = scope.__BITVID_LOCAL_NOSTR_TOOLS_ENTRY__;
if (typeof configuredEntrypoints === "string") {
entrypoints.push(configuredEntrypoints);
} else if (Array.isArray(configuredEntrypoints)) {
configuredEntrypoints
.filter((candidate) => typeof candidate === "string" && candidate)
.forEach((candidate) => entrypoints.push(candidate));
}
if (scope.document) {
const localScripts = scope.document.querySelectorAll(
"script[data-nostr-tools-entry]"
);
localScripts.forEach((script) => {
const candidate = script.getAttribute("data-nostr-tools-entry");
if (candidate) {
entrypoints.push(candidate);
}
});
}
return Array.from(new Set(entrypoints));
};
const extractToolsFromModule = (moduleLike) => {
if (!moduleLike || typeof moduleLike !== "object") {
return null;
}
const { default: defaultExport, ...named } = moduleLike;
if (defaultExport && typeof defaultExport === "object") {
return { ...defaultExport, ...named };
}
return { ...named };
};
const ensureGlobalReadyPromise = (scope, promise) => {
if (!scope || typeof scope !== "object") {
return;
}
try {
Object.defineProperty(scope, "nostrToolsReady", {
configurable: true,
enumerable: false,
writable: true,
value: promise,
});
} catch (error) {
scope.nostrToolsReady = promise;
}
};
const loadScriptBundle = (scope, url, label) => {
if (!scope?.document) {
return Promise.resolve({
label,
ok: false,
timedOut: false,
duration: 0,
error: new Error("Document is unavailable for script loading."),
});
}
return withTimeout(
() =>
new Promise((resolve, reject) => {
const { document } = scope;
const target = document.head || document.body || document.documentElement;
if (!target) {
reject(new Error("Unable to locate a DOM target for script injection."));
return;
}
const script = document.createElement("script");
script.async = true;
script.src = url;
script.crossOrigin = "anonymous";
const cleanup = () => {
script.onload = null;
script.onerror = null;
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
script.onload = () => {
cleanup();
resolve(scope?.NostrTools || null);
};
script.onerror = (event) => {
cleanup();
if (event?.error instanceof Error) {
reject(event.error);
} else {
reject(new Error(`nostr-tools bundle failed to load: ${url}`));
}
};
target.appendChild(script);
}),
SCRIPT_FALLBACK_TIMEOUT,
label
);
};
export function bootstrapNostrTools() {
if (bootstrapPromise) {
return bootstrapPromise;
}
const scope = getGlobalScope();
bootstrapPromise = (async () => {
if (!scope) {
const error = {
ok: false,
reason: "Global scope is unavailable.",
attempts: [],
};
return error;
}
const attempts = [];
const remoteImports = [
withTimeout(
() => import("https://esm.sh/[email protected]"),
REMOTE_IMPORT_TIMEOUT,
"esm-main"
),
withTimeout(
() =>
import(
"https://esm.sh/[email protected]?target=es2022&exports=nip04"
),
REMOTE_IMPORT_TIMEOUT,
"esm-nip04"
),
];
const localEntrypoints = discoverLocalEntrypoints(scope);
const localImports = localEntrypoints.map((entry, index) =>
withTimeout(
() => import(entry),
LOCAL_IMPORT_TIMEOUT,
`local-${index}`
)
);
const dynamicResults = await Promise.allSettled([
...remoteImports,
...localImports,
]);
const resolvedModules = [];
let resolvedNip04 = null;
dynamicResults.forEach((settledResult) => {
if (settledResult.status !== "fulfilled") {
return;
}
const result = settledResult.value;
attempts.push({
target: result.label,
ok: result.ok,
timedOut: !!result.timedOut,
duration: result.duration,
error: result.ok ? null : toSerializableError(result.error),
});
if (!result.ok) {
return;
}
if (result.label === "esm-nip04" && hasWorkingNip04(result.value?.nip04)) {
resolvedNip04 = result.value.nip04;
return;
}
const extracted = extractToolsFromModule(result.value);
if (extracted && Object.keys(extracted).length > 0) {
resolvedModules.push({ source: result.label, tools: extracted });
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
resolvedNip04 = extracted.nip04;
}
}
});
dynamicResults.forEach((settledResult) => {
if (settledResult.status !== "fulfilled") {
const { reason } = settledResult;
attempts.push({
target: "unknown",
ok: false,
timedOut: false,
duration: 0,
error: toSerializableError(reason),
});
return;
}
const result = settledResult.value;
if (result && !attempts.find((entry) => entry.target === result.label)) {
attempts.push({
target: result.label,
ok: result.ok,
timedOut: !!result.timedOut,
duration: result.duration,
error: result.ok ? null : toSerializableError(result.error),
});
}
});
const allDynamicTimedOut =
attempts.length > 0 &&
attempts.every(
(attempt) =>
attempt &&
attempt.target &&
attempt.target.startsWith("esm")
? attempt.timedOut
: true
);
const shouldAttemptScriptFallback =
!!scope.document && (resolvedModules.length === 0 || allDynamicTimedOut);
if (shouldAttemptScriptFallback) {
const scriptSources = [
LOCAL_BUNDLE_URL
? { url: LOCAL_BUNDLE_URL, label: "local-bundle" }
: null,
{ url: CDN_BUNDLE_URL, label: "cdn-bundle" },
].filter(Boolean);
for (const source of scriptSources) {
const scriptLoadResult = await loadScriptBundle(
scope,
source.url,
source.label
);
attempts.push({
target: scriptLoadResult.label,
ok: scriptLoadResult.ok,
timedOut: !!scriptLoadResult.timedOut,
duration: scriptLoadResult.duration,
error: scriptLoadResult.ok
? null
: toSerializableError(scriptLoadResult.error),
});
if (!scriptLoadResult.ok) {
continue;
}
const extracted = extractToolsFromModule(scriptLoadResult.value);
if (extracted && Object.keys(extracted).length > 0) {
resolvedModules.push({ source: source.label, tools: extracted });
if (!resolvedNip04 && hasWorkingNip04(extracted?.nip04)) {
resolvedNip04 = extracted.nip04;
}
break;
}
}
}
const existingGlobalTools = scope?.NostrTools;
if (existingGlobalTools && typeof existingGlobalTools === "object") {
resolvedModules.push({ source: "global", tools: existingGlobalTools });
if (!resolvedNip04 && hasWorkingNip04(existingGlobalTools?.nip04)) {
resolvedNip04 = existingGlobalTools.nip04;
}
}
if (resolvedModules.length === 0 && !resolvedNip04) {
const failure = {
ok: false,
reason: "Failed to resolve any nostr-tools helpers.",
attempts,
};
console.warn("[bitvid] nostr-tools bootstrap failed", failure);
return failure;
}
const toolSources = resolvedModules.map((entry) => entry.tools);
const canonicalTools = Object.assign({}, ...toolSources);
const normalizedGeneratePrivateKey =
typeof canonicalTools.generatePrivateKey === "function"
? canonicalTools.generatePrivateKey
: typeof canonicalTools.generateSecretKey === "function"
? canonicalTools.generateSecretKey
: undefined;
const normalizedGenerateSecretKey =
typeof canonicalTools.generateSecretKey === "function"
? canonicalTools.generateSecretKey
: typeof canonicalTools.generatePrivateKey === "function"
? canonicalTools.generatePrivateKey
: undefined;
if (normalizedGeneratePrivateKey) {
canonicalTools.generatePrivateKey = normalizedGeneratePrivateKey;
}
if (normalizedGenerateSecretKey) {
canonicalTools.generateSecretKey = normalizedGenerateSecretKey;
}
if (resolvedNip04) {
canonicalTools.nip04 = resolvedNip04;
console.info("[bitvid] Initialized nostr nip04 helpers.");
} else {
console.warn(
"[bitvid] NIP-04 helpers unavailable after bootstrap attempts."
);
}
const frozenCanonical = Object.freeze({ ...canonicalTools });
try {
Object.defineProperty(scope, "__BITVID_CANONICAL_NOSTR_TOOLS__", {
configurable: true,
enumerable: false,
writable: false,
value: frozenCanonical,
});
} catch (error) {
scope.__BITVID_CANONICAL_NOSTR_TOOLS__ = frozenCanonical;
}
let activeTools = canonicalTools;
const mergeWithCanonical = (candidate) => {
if (!candidate || typeof candidate !== "object") {
return activeTools;
}
const merged = Object.assign({}, frozenCanonical, candidate);
if (!merged.nip04 && frozenCanonical.nip04) {
merged.nip04 = frozenCanonical.nip04;
}
if (
typeof merged.generatePrivateKey !== "function" &&
typeof merged.generateSecretKey === "function"
) {
merged.generatePrivateKey = merged.generateSecretKey;
}
if (
typeof merged.generateSecretKey !== "function" &&
typeof merged.generatePrivateKey === "function"
) {
merged.generateSecretKey = merged.generatePrivateKey;
}
return merged;
};
const applyActive = (candidate) => {
activeTools = mergeWithCanonical(candidate);
return activeTools;
};
try {
Object.defineProperty(scope, "NostrTools", {
configurable: true,
enumerable: true,
get() {
return activeTools;
},
set(value) {
applyActive(value);
},
});
} catch (error) {
console.warn("[bitvid] Failed to install NostrTools guard.", error);
}
applyActive(existingGlobalTools);
scope.NostrTools = canonicalTools;
return frozenCanonical;
})()
.catch((error) => ({
ok: false,
reason: "nostr-tools bootstrap threw unexpectedly.",
error: toSerializableError(error),
}));
ensureGlobalReadyPromise(scope, bootstrapPromise);
return bootstrapPromise;
}
const nostrToolsReady = bootstrapNostrTools();
export { nostrToolsReady };
export default nostrToolsReady;
await nostrToolsReady;
+368
View File
@@ -0,0 +1,368 @@
const DB_NAME = "bitvidSettings";
const DB_VERSION = 1;
const STORE_NAME = "kv";
const KEY_PREFIX = "nwcSettings:";
const LOCALSTORAGE_PREFIX = "bitvid:nwcSettings:";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const MIN_DEFAULT_ZAP = 0;
const MAX_DEFAULT_ZAP = 100000000;
const DEFAULT_SETTINGS = Object.freeze({
nwcUri: "",
defaultZap: null,
lastChecked: null,
version: "",
});
function createClone(value) {
return { ...value };
}
function isIndexedDbAvailable() {
try {
return typeof indexedDB !== "undefined";
} catch (error) {
return false;
}
}
function openSettingsDb() {
return new Promise((resolve, reject) => {
if (!isIndexedDbAvailable()) {
resolve(null);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error || new Error("Failed to open settings DB"));
};
});
}
function normalizePubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1") && typeof window !== "undefined") {
try {
const decoded = window?.NostrTools?.nip19?.decode?.(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
// Ignore decode errors and fall through to return null.
}
}
return null;
}
function resolveStorageKeys(pubkey) {
const normalized = normalizePubkey(pubkey);
if (!normalized) {
throw new Error("Invalid pubkey for NWC settings.");
}
return {
normalized,
dbKey: `${KEY_PREFIX}${normalized}`,
fallbackKey: `${LOCALSTORAGE_PREFIX}${normalized}`,
};
}
function sanitizeUri(value) {
return typeof value === "string" ? value.trim() : "";
}
function sanitizeDefaultZap(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
}
const rounded = Math.round(numeric);
if (!Number.isFinite(rounded)) {
return null;
}
return Math.min(MAX_DEFAULT_ZAP, Math.max(MIN_DEFAULT_ZAP, rounded));
}
function sanitizeTimestamp(value) {
if (!Number.isFinite(value)) {
return null;
}
const rounded = Math.round(value);
if (!Number.isFinite(rounded)) {
return null;
}
return Math.max(0, rounded);
}
function sanitizeVersion(value) {
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function normalizeSettings(raw) {
const base = createClone(DEFAULT_SETTINGS);
if (!raw || typeof raw !== "object") {
return base;
}
if (Object.prototype.hasOwnProperty.call(raw, "nwcUri")) {
base.nwcUri = sanitizeUri(raw.nwcUri);
}
if (Object.prototype.hasOwnProperty.call(raw, "defaultZap")) {
base.defaultZap = sanitizeDefaultZap(raw.defaultZap);
}
if (Object.prototype.hasOwnProperty.call(raw, "lastChecked")) {
base.lastChecked = sanitizeTimestamp(raw.lastChecked);
}
if (Object.prototype.hasOwnProperty.call(raw, "version")) {
base.version = sanitizeVersion(raw.version);
}
return base;
}
function mergeSettings(existing, partial) {
const source = partial && typeof partial === "object" ? partial : {};
return normalizeSettings({ ...existing, ...source });
}
function emitToast(message, { variant = "error", error } = {}) {
const text = typeof message === "string" ? message.trim() : "";
if (!text) {
return;
}
if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") {
try {
window.dispatchEvent(
new CustomEvent("bitvid:toast", {
detail: {
message: text,
variant,
source: "nwc-settings",
error,
},
})
);
} catch (eventError) {
console.warn("[nwcSettings] Failed to dispatch toast event", eventError);
}
}
}
function notifyFailure(message, error) {
const text = message || "NWC settings storage error.";
console.warn(`[nwcSettings] ${text}`, error);
emitToast(text, { variant: "error", error });
}
function notifyWarning(message, error) {
const text = message || "NWC settings storage warning.";
console.warn(`[nwcSettings] ${text}`, error);
emitToast(text, { variant: "warning", error });
}
async function readFromIndexedDb(dbKey) {
try {
const db = await openSettingsDb();
if (!db) {
return null;
}
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const request = store.get(dbKey);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(request.error || new Error("Failed to load NWC settings."));
};
});
} catch (error) {
notifyWarning("Unable to read wallet settings from IndexedDB.", error);
return null;
}
}
function readFromLocalStorage(fallbackKey) {
try {
if (typeof localStorage === "undefined") {
return null;
}
const raw = localStorage.getItem(fallbackKey);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch (error) {
notifyFailure(
"Unable to read wallet settings from local storage fallback.",
error
);
return null;
}
}
async function writeToIndexedDb(dbKey, value) {
try {
const db = await openSettingsDb();
if (!db) {
return false;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.put(value, dbKey);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(request.error || new Error("Failed to persist NWC settings."));
};
});
return true;
} catch (error) {
notifyWarning("Unable to save wallet settings to IndexedDB.", error);
return false;
}
}
function writeToLocalStorage(fallbackKey, value) {
try {
if (typeof localStorage === "undefined") {
return false;
}
localStorage.setItem(fallbackKey, JSON.stringify(value));
return true;
} catch (error) {
notifyWarning("Unable to save wallet settings to local storage fallback.", error);
return false;
}
}
async function deleteFromIndexedDb(dbKey) {
try {
const db = await openSettingsDb();
if (!db) {
return false;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const request = store.delete(dbKey);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(request.error || new Error("Failed to clear NWC settings."));
};
});
return true;
} catch (error) {
notifyWarning("Unable to remove wallet settings from IndexedDB.", error);
return false;
}
}
function deleteFromLocalStorage(fallbackKey) {
try {
if (typeof localStorage === "undefined") {
return false;
}
localStorage.removeItem(fallbackKey);
return true;
} catch (error) {
notifyWarning("Unable to remove wallet settings from local storage fallback.", error);
return false;
}
}
export function createDefaultNwcSettings() {
return createClone(DEFAULT_SETTINGS);
}
export async function loadNwcSettings(pubkey) {
const { dbKey, fallbackKey } = resolveStorageKeys(pubkey);
const stored = await readFromIndexedDb(dbKey);
if (stored) {
return normalizeSettings(stored);
}
const fallback = readFromLocalStorage(fallbackKey);
if (fallback) {
return normalizeSettings(fallback);
}
return normalizeSettings(null);
}
export async function saveNwcSettings(pubkey, partial = {}) {
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
const existing = await readFromIndexedDb(dbKey);
const fallbackExisting = existing || readFromLocalStorage(fallbackKey);
const merged = mergeSettings(fallbackExisting || DEFAULT_SETTINGS, partial);
const idbPersisted = await writeToIndexedDb(dbKey, merged);
const localPersisted = writeToLocalStorage(fallbackKey, merged);
if (!idbPersisted && !localPersisted) {
notifyFailure(
"Failed to persist wallet settings. Recent changes were not saved.",
new Error(`Unable to save settings for ${normalized}`)
);
throw new Error("Unable to persist NWC settings.");
}
return merged;
}
export async function clearNwcSettings(pubkey) {
const { normalized, dbKey, fallbackKey } = resolveStorageKeys(pubkey);
const idbCleared = await deleteFromIndexedDb(dbKey);
const localCleared = deleteFromLocalStorage(fallbackKey);
if (!idbCleared && !localCleared) {
notifyFailure(
"Failed to clear wallet settings from browser storage.",
new Error(`Unable to clear settings for ${normalized}`)
);
throw new Error("Unable to clear NWC settings.");
}
return true;
}
+372
View File
@@ -0,0 +1,372 @@
// js/payments/lnurl.js
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const CHARKEY = new Map(
Array.from(CHARSET).map((char, index) => [char, index])
);
const GENERATORS = [
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
];
const DEFAULT_COMMENT_MAX = 0;
function hrpExpand(hrp) {
const chars = Array.from(hrp);
const left = chars.map((char) => char.charCodeAt(0) >> 5);
const right = chars.map((char) => char.charCodeAt(0) & 31);
return [...left, 0, ...right];
}
function polymod(values) {
let chk = 1;
for (const value of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < GENERATORS.length; i += 1) {
if (((top >> i) & 1) !== 0) {
chk ^= GENERATORS[i];
}
}
}
return chk;
}
function verifyChecksum(hrp, data) {
return polymod([...hrpExpand(hrp), ...data]) === 1;
}
function bech32Decode(input) {
if (typeof input !== "string") {
throw new Error("LNURL must be a string.");
}
const trimmed = input.trim();
if (!trimmed) {
throw new Error("LNURL cannot be empty.");
}
const lower = trimmed.toLowerCase();
const upper = trimmed.toUpperCase();
if (trimmed !== lower && trimmed !== upper) {
throw new Error("LNURL cannot mix upper and lower case characters.");
}
const normalized = lower;
const separatorIndex = normalized.lastIndexOf("1");
if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) {
throw new Error("Invalid LNURL bech32 payload.");
}
const hrp = normalized.slice(0, separatorIndex);
const dataPart = normalized.slice(separatorIndex + 1);
const data = [];
for (const char of dataPart) {
if (!CHARKEY.has(char)) {
throw new Error("LNURL payload includes invalid characters.");
}
data.push(CHARKEY.get(char));
}
if (!verifyChecksum(hrp, data)) {
throw new Error("LNURL checksum is invalid.");
}
return {
prefix: hrp,
words: data.slice(0, -6),
};
}
function convertWords(words, fromBits, toBits, { pad = true } = {}) {
let value = 0;
let bits = 0;
const maxValue = (1 << toBits) - 1;
const result = [];
for (const word of words) {
if (word < 0 || word >> fromBits !== 0) {
throw new Error("Invalid bech32 word value.");
}
value = (value << fromBits) | word;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.push((value >> bits) & maxValue);
}
}
if (pad) {
if (bits > 0) {
result.push((value << (toBits - bits)) & maxValue);
}
} else if (bits >= fromBits || ((value << (toBits - bits)) & maxValue) !== 0) {
throw new Error("Excess padding in bech32 payload.");
}
return result;
}
function decodeLnurlBech32(encoded) {
const { prefix, words } = bech32Decode(encoded);
if (prefix !== "lnurl") {
throw new Error("Only bech32 LNURL encodings are supported.");
}
const bytes = convertWords(words, 5, 8, { pad: false });
const decoder = new TextDecoder("utf-8", { fatal: true });
return decoder.decode(Uint8Array.from(bytes));
}
function ensureFetchFunction(fetcher) {
if (typeof fetcher === "function") {
return fetcher;
}
if (typeof fetch === "function") {
return fetch;
}
if (typeof globalThis.fetch === "function") {
return globalThis.fetch.bind(globalThis);
}
throw new Error("Fetch API is not available in this environment.");
}
function sanitizeUrl(url) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
throw new Error("LNURL endpoint is missing.");
}
return trimmed;
}
export function resolveLightningAddress(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
throw new Error("Lightning address is required.");
}
if (trimmed.toLowerCase().startsWith("lnurl")) {
const decoded = decodeLnurlBech32(trimmed);
return {
type: "lud06",
url: sanitizeUrl(decoded),
address: trimmed,
};
}
if (trimmed.includes("@")) {
const [namePart, domainPart] = trimmed.split("@");
const name = (namePart || "").trim();
const domain = (domainPart || "").trim();
if (!name || !domain) {
throw new Error("Invalid Lightning address format.");
}
const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(
name.toLowerCase()
)}`;
return {
type: "lud16",
url,
address: `${name}@${domain}`,
};
}
if (/^https?:\/\//i.test(trimmed)) {
return {
type: "url",
url: trimmed,
address: trimmed,
};
}
throw new Error("Unsupported Lightning address format.");
}
function normalizeNumeric(value, fallback = 0) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return numeric;
}
export async function fetchPayServiceData(url, { fetcher } = {}) {
const targetUrl = sanitizeUrl(url);
const fetchFn = ensureFetchFunction(fetcher);
const response = await fetchFn(targetUrl, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to load LNURL metadata (${response.status}).`);
}
let payload;
try {
payload = await response.json();
} catch (error) {
throw new Error("LNURL endpoint did not return JSON.");
}
if (!payload || typeof payload !== "object") {
throw new Error("LNURL metadata response was invalid.");
}
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
const reason =
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "LNURL endpoint returned an error.";
throw new Error(reason);
}
const callback = sanitizeUrl(payload.callback);
const minSendable = Math.max(0, normalizeNumeric(payload.minSendable, 0));
const maxSendable = Math.max(minSendable, normalizeNumeric(payload.maxSendable, minSendable));
const commentAllowed = Math.max(0, Math.round(normalizeNumeric(payload.commentAllowed, DEFAULT_COMMENT_MAX)));
const allowsNostr = payload.allowsNostr === true;
const nostrPubkey =
typeof payload.nostrPubkey === "string" ? payload.nostrPubkey.trim() : "";
let metadata = [];
if (typeof payload.metadata === "string" && payload.metadata.trim()) {
try {
const parsed = JSON.parse(payload.metadata);
if (Array.isArray(parsed)) {
metadata = parsed;
}
} catch (error) {
// Ignore metadata parse errors – it is optional.
}
}
return {
tag: typeof payload.tag === "string" ? payload.tag : "payRequest",
callback,
minSendable,
maxSendable,
metadata,
commentAllowed,
allowsNostr,
nostrPubkey,
raw: payload,
};
}
export function validateInvoiceAmount(metadata, amountSats) {
if (!metadata || typeof metadata !== "object") {
throw new Error("LNURL metadata is required to validate invoice amounts.");
}
const amount = Math.round(Number(amountSats));
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("Zap amount must be a positive integer.");
}
const amountMsats = amount * 1000;
const min = Math.max(0, Math.round(Number(metadata.minSendable || 0)));
const max = Math.max(min, Math.round(Number(metadata.maxSendable || amountMsats)));
if (min && amountMsats < min) {
throw new Error(`Amount is below the minimum sendable value (${min / 1000} sats).`);
}
if (max && amountMsats > max) {
throw new Error(`Amount exceeds the maximum sendable value (${max / 1000} sats).`);
}
return { amountMsats };
}
function truncateComment(comment, limit) {
const trimmed = typeof comment === "string" ? comment.trim() : "";
if (!limit || limit <= 0) {
return "";
}
if (trimmed.length <= limit) {
return trimmed;
}
return trimmed.slice(0, limit);
}
export async function requestInvoice(
metadata,
{ amountSats, amountMsats, comment, zapRequest, fetcher } = {}
) {
if (!metadata || typeof metadata !== "object") {
throw new Error("LNURL metadata is required to request an invoice.");
}
const fetchFn = ensureFetchFunction(fetcher);
const targetAmount = Number.isFinite(amountMsats)
? Math.round(amountMsats)
: validateInvoiceAmount(metadata, amountSats).amountMsats;
const callbackUrl = new URL(metadata.callback);
callbackUrl.searchParams.set("amount", String(targetAmount));
if (comment && metadata.commentAllowed > 0) {
const truncated = truncateComment(comment, metadata.commentAllowed);
if (truncated) {
callbackUrl.searchParams.set("comment", truncated);
}
}
if (zapRequest) {
callbackUrl.searchParams.set("nostr", zapRequest);
}
const response = await fetchFn(callbackUrl.toString(), {
method: "GET",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch LNURL invoice (${response.status}).`);
}
let payload;
try {
payload = await response.json();
} catch (error) {
throw new Error("LNURL invoice response was not JSON.");
}
if (!payload || typeof payload !== "object") {
throw new Error("LNURL invoice response was invalid.");
}
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
const reason =
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "LNURL invoice request failed.";
throw new Error(reason);
}
const invoice = typeof payload.pr === "string" ? payload.pr.trim() : "";
if (!invoice) {
throw new Error("LNURL endpoint did not return an invoice.");
}
return {
invoice,
raw: payload,
};
}
export const __TESTING__ = Object.freeze({
bech32Decode,
decodeLnurlBech32,
convertWords,
});
File diff suppressed because it is too large. Load diff
+325
View File
@@ -0,0 +1,325 @@
// js/payments/platformAddress.js
import { PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB } from "../config.js";
import { nostrClient } from "../nostr.js";
import { setProfileCacheEntry } from "../state/cache.js";
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const CACHE_TTL_MS = 10 * 60 * 1000;
let cachedAddress = null;
const globalScope =
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: null;
const nostrToolsReadySource =
globalScope &&
globalScope.nostrToolsReady &&
typeof globalScope.nostrToolsReady.then === "function"
? globalScope.nostrToolsReady
: nostrToolsReady;
function normalizeToolkitCandidate(candidate) {
if (
candidate &&
typeof candidate === "object" &&
candidate.ok !== false &&
typeof candidate.then !== "function"
) {
return candidate;
}
return null;
}
function readToolkitFromScope(scope = globalScope) {
if (!scope || typeof scope !== "object") {
return null;
}
const candidates = [];
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
if (canonical) {
candidates.push(canonical);
}
const direct = scope.NostrTools;
if (direct) {
candidates.push(direct);
}
const nestedWindow =
scope.window && scope.window !== scope && typeof scope.window === "object"
? scope.window
: null;
if (nestedWindow) {
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
}
if (nestedWindow.NostrTools) {
candidates.push(nestedWindow.NostrTools);
}
}
for (const candidate of candidates) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
const __platformNostrToolsBootstrap = await (async () => {
try {
const result = await nostrToolsReadySource;
if (result && typeof result === "object" && result.ok === false) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: result,
};
}
const normalized = normalizeToolkitCandidate(result);
if (normalized) {
return { toolkit: normalized, failure: null };
}
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: null,
};
} catch (error) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: error,
};
}
})();
let cachedNostrTools = __platformNostrToolsBootstrap.toolkit || null;
const nostrToolsInitializationFailure =
__platformNostrToolsBootstrap.failure || null;
if (!cachedNostrTools && nostrToolsInitializationFailure) {
console.warn(
"[platformAddress] nostr-tools helpers unavailable after bootstrap.",
nostrToolsInitializationFailure
);
}
function rememberNostrTools(candidate) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
cachedNostrTools = normalized;
}
}
function getCachedNostrTools() {
const fallback = readToolkitFromScope();
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
rememberNostrTools(fallback);
} else if (!cachedNostrTools && fallback) {
rememberNostrTools(fallback);
}
return cachedNostrTools || fallback || null;
}
async function ensureNostrTools() {
if (cachedNostrTools) {
return cachedNostrTools;
}
try {
const result = await nostrToolsReadySource;
rememberNostrTools(result);
} catch (error) {
console.warn(
"[platformAddress] Failed to resolve nostr-tools helpers.",
error
);
}
if (!cachedNostrTools) {
rememberNostrTools(readToolkitFromScope());
}
return cachedNostrTools || null;
}
function getGlobalWindow() {
if (typeof window !== "undefined") {
return window;
}
if (typeof globalThis !== "undefined") {
return globalThis;
}
return {};
}
function getNostrTools() {
const win = getGlobalWindow();
const tools = getCachedNostrTools();
if (tools?.nip04) {
return tools;
}
const canonical = win?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
if (canonical && tools && !tools.nip04 && canonical.nip04) {
try {
tools.nip04 = canonical.nip04;
return tools;
} catch (error) {
return { ...canonical, ...tools, nip04: canonical.nip04 };
}
}
return tools || canonical || null;
}
function decodeAdminPubkey() {
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
console.warn("[platformAddress] Failed to decode ADMIN_SUPER_NPUB", error);
}
}
return null;
}
function shouldUseOverride() {
return typeof PLATFORM_LUD16_OVERRIDE === "string" && PLATFORM_LUD16_OVERRIDE.trim();
}
function getOverrideAddress() {
const trimmed = PLATFORM_LUD16_OVERRIDE.trim();
return trimmed || null;
}
function isCacheFresh(entry) {
if (!entry) {
return false;
}
if (typeof entry.expiresAt !== "number") {
return false;
}
return entry.expiresAt > Date.now();
}
async function fetchAdminMetadata(adminPubkey) {
if (!adminPubkey) {
return null;
}
let pool;
try {
pool = await nostrClient.ensurePool();
} catch (error) {
console.warn("[platformAddress] Failed to initialize Nostr pool", error);
return null;
}
if (!pool) {
return null;
}
const relayUrls = Array.isArray(nostrClient?.relays)
? nostrClient.relays
: [];
if (relayUrls.length === 0) {
console.warn("[platformAddress] Nostr client is not ready.");
return null;
}
try {
const events = await pool.list(relayUrls, [
{ kinds: [0], authors: [adminPubkey], limit: 1 },
]);
if (!Array.isArray(events) || events.length === 0) {
return null;
}
let newest = null;
for (const event of events) {
if (!event || event.pubkey !== adminPubkey || !event.content) {
continue;
}
if (!newest || event.created_at > newest.created_at) {
newest = event;
}
}
if (!newest?.content) {
return null;
}
const data = JSON.parse(newest.content);
const lightningAddress =
typeof data.lud16 === "string" && data.lud16.trim()
? data.lud16.trim()
: typeof data.lud06 === "string" && data.lud06.trim()
? data.lud06.trim()
: "";
if (lightningAddress) {
setProfileCacheEntry(adminPubkey, data, { persist: false });
}
return lightningAddress || null;
} catch (error) {
console.warn("[platformAddress] Failed to fetch admin metadata", error);
return null;
}
}
export async function getPlatformLightningAddress({ forceRefresh = false } = {}) {
if (shouldUseOverride()) {
return getOverrideAddress();
}
if (!forceRefresh && isCacheFresh(cachedAddress)) {
return cachedAddress.value;
}
const adminPubkey = decodeAdminPubkey();
if (!adminPubkey) {
return null;
}
if (forceRefresh !== true && cachedAddress?.value) {
return cachedAddress.value;
}
const lightningAddress = await fetchAdminMetadata(adminPubkey);
if (lightningAddress) {
cachedAddress = {
value: lightningAddress,
expiresAt: Date.now() + CACHE_TTL_MS,
};
}
return lightningAddress || cachedAddress?.value || null;
}
export function __resetPlatformAddressCache() {
cachedAddress = null;
}
+74
View File
@@ -0,0 +1,74 @@
// js/payments/platformFee.js
import { PLATFORM_FEE_PERCENT } from "../config.js";
export function parsePercentValue(value) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : NaN;
}
if (typeof value === "bigint") {
return Number(value);
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return NaN;
}
if (trimmed.includes("/")) {
const [firstRaw, secondRaw] = trimmed.split("/").map((part) => part.trim());
if (firstRaw && secondRaw) {
const first = Number(firstRaw);
const second = Number(secondRaw);
if (Number.isFinite(first) && Number.isFinite(second) && first >= 0 && second >= 0) {
const total = first + second;
if (total > 0) {
return (second / total) * 100;
}
}
}
}
const percentStripped = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed;
const directNumeric = Number(percentStripped);
if (Number.isFinite(directNumeric)) {
return directNumeric;
}
const match = percentStripped.match(/-?\d+(?:\.\d+)?/);
if (match) {
const fallback = Number(match[0]);
return Number.isFinite(fallback) ? fallback : NaN;
}
return NaN;
}
return Number.isFinite(value) ? Number(value) : NaN;
}
export function clampPercent(value) {
const numeric = parsePercentValue(value);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.min(100, Math.max(0, numeric));
}
export function getDefaultPlatformFeePercent() {
const parsed = parsePercentValue(PLATFORM_FEE_PERCENT);
if (!Number.isFinite(parsed)) {
return 0;
}
return clampPercent(parsed);
}
export function resolvePlatformFeePercent(overrideValue) {
const parsedOverride = parsePercentValue(overrideValue);
if (Number.isFinite(parsedOverride)) {
return clampPercent(parsedOverride);
}
return getDefaultPlatformFeePercent();
}
+164
View File
@@ -0,0 +1,164 @@
import { resolvePlatformFeePercent } from "./platformFee.js";
import {
resolveLightningAddress as defaultResolveLightningAddress,
fetchPayServiceData as defaultFetchPayServiceData,
validateInvoiceAmount,
} from "./lnurl.js";
const METADATA_CACHE_TTL_MS = 5 * 60 * 1000;
const lightningMetadataCache = new Map();
const lightningMetadataByUrl = new Map();
let cachedPlatformLightningAddress = "";
export function normalizeLightningAddressKey(address) {
return typeof address === "string" ? address.trim().toLowerCase() : "";
}
export function isMetadataEntryFresh(entry) {
if (!entry || typeof entry.fetchedAt !== "number") {
return false;
}
return Date.now() - entry.fetchedAt < METADATA_CACHE_TTL_MS;
}
export function rememberLightningMetadata(entry) {
if (!entry || !entry.key) {
return entry;
}
lightningMetadataCache.set(entry.key, entry);
if (entry.resolved?.url) {
lightningMetadataByUrl.set(entry.resolved.url, entry);
}
return entry;
}
export function getCachedLightningEntry(address) {
const key = normalizeLightningAddressKey(address);
if (!key) {
return null;
}
const entry = lightningMetadataCache.get(key);
if (!entry) {
return null;
}
if (entry.metadata && isMetadataEntryFresh(entry)) {
return entry;
}
if (entry.metadata && !entry.promise) {
return entry;
}
return entry.metadata ? entry : null;
}
export function getCachedMetadataByUrl(url) {
if (!url) {
return null;
}
return lightningMetadataByUrl.get(url) || null;
}
export async function fetchLightningMetadata(
address,
{
resolveLightningAddress = defaultResolveLightningAddress,
fetchPayServiceData = defaultFetchPayServiceData,
} = {}
) {
const key = normalizeLightningAddressKey(address);
if (!key) {
throw new Error("Lightning address is required.");
}
const cached = lightningMetadataCache.get(key);
if (cached && cached.metadata && isMetadataEntryFresh(cached)) {
return cached;
}
if (cached?.promise) {
return cached.promise;
}
const fetchPromise = (async () => {
const resolved = cached?.resolved || (await resolveLightningAddress(address));
const metadata = await fetchPayServiceData(resolved.url);
const entry = {
key,
address: resolved.address || address,
resolved,
metadata,
fetchedAt: Date.now(),
};
rememberLightningMetadata(entry);
return entry;
})();
lightningMetadataCache.set(key, {
...(cached || {}),
key,
promise: fetchPromise,
});
try {
return await fetchPromise;
} catch (error) {
const current = lightningMetadataCache.get(key);
if (current?.promise === fetchPromise) {
lightningMetadataCache.delete(key);
}
throw error;
}
}
export function calculateZapShares(amount, overrideFee = null) {
const numericAmount = Math.max(0, Math.round(Number(amount) || 0));
const feePercent = resolvePlatformFeePercent(
typeof overrideFee === "undefined" ? null : overrideFee
);
const platformShare = Math.floor((numericAmount * feePercent) / 100);
const creatorShare = numericAmount - platformShare;
return {
total: numericAmount,
creatorShare,
platformShare,
feePercent,
};
}
export function describeShareType(type) {
if (type === "platform") {
return "Platform";
}
if (type === "creator") {
return "Creator";
}
return "Lightning";
}
export function formatMinRequirement(metadata) {
if (!metadata || typeof metadata.minSendable !== "number") {
return null;
}
if (metadata.minSendable <= 0) {
return null;
}
return Math.ceil(metadata.minSendable / 1000);
}
export function setCachedPlatformLightningAddress(address) {
cachedPlatformLightningAddress =
typeof address === "string" ? address.trim() : "";
return cachedPlatformLightningAddress;
}
export function getCachedPlatformLightningAddress() {
return cachedPlatformLightningAddress;
}
export function clearZapCaches() {
lightningMetadataCache.clear();
lightningMetadataByUrl.clear();
cachedPlatformLightningAddress = "";
}
export { validateInvoiceAmount };
+464
View File
@@ -0,0 +1,464 @@
// js/payments/zapSplit.js
import { ADMIN_SUPER_NPUB } from "../config.js";
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import {
resolveLightningAddress,
fetchPayServiceData,
validateInvoiceAmount,
requestInvoice,
} from "./lnurl.js";
import { ensureWallet, sendPayment } from "./nwcClient.js";
import { getPlatformLightningAddress } from "./platformAddress.js";
import {
clampPercent,
parsePercentValue,
resolvePlatformFeePercent,
} from "./platformFee.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const ZAP_KIND = 9734;
const DEFAULT_DEPS = Object.freeze({
lnurl: Object.freeze({
resolveLightningAddress,
fetchPayServiceData,
validateInvoiceAmount,
requestInvoice,
}),
wallet: Object.freeze({ ensureWallet, sendPayment }),
platformAddress: Object.freeze({ getPlatformLightningAddress }),
});
const globalScope =
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: null;
const nostrToolsReadySource =
globalScope &&
globalScope.nostrToolsReady &&
typeof globalScope.nostrToolsReady.then === "function"
? globalScope.nostrToolsReady
: nostrToolsReady;
function normalizeToolkitCandidate(candidate) {
if (
candidate &&
typeof candidate === "object" &&
candidate.ok !== false &&
typeof candidate.then !== "function"
) {
return candidate;
}
return null;
}
function readToolkitFromScope(scope = globalScope) {
if (!scope || typeof scope !== "object") {
return null;
}
const candidates = [];
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
if (canonical) {
candidates.push(canonical);
}
const direct = scope.NostrTools;
if (direct) {
candidates.push(direct);
}
const nestedWindow =
scope.window && scope.window !== scope && typeof scope.window === "object"
? scope.window
: null;
if (nestedWindow) {
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
}
if (nestedWindow.NostrTools) {
candidates.push(nestedWindow.NostrTools);
}
}
for (const candidate of candidates) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
const __zapNostrToolsBootstrap = await (async () => {
try {
const result = await nostrToolsReadySource;
if (result && typeof result === "object" && result.ok === false) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: result,
};
}
const normalized = normalizeToolkitCandidate(result);
if (normalized) {
return { toolkit: normalized, failure: null };
}
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: null,
};
} catch (error) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: error,
};
}
})();
let cachedNostrTools = __zapNostrToolsBootstrap.toolkit || null;
const nostrToolsInitializationFailure = __zapNostrToolsBootstrap.failure || null;
if (!cachedNostrTools && nostrToolsInitializationFailure) {
console.warn(
"[zapSplit] nostr-tools helpers unavailable after bootstrap.",
nostrToolsInitializationFailure
);
}
function rememberNostrTools(candidate) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
cachedNostrTools = normalized;
}
}
function getCachedNostrTools() {
const fallback = readToolkitFromScope();
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
rememberNostrTools(fallback);
} else if (!cachedNostrTools && fallback) {
rememberNostrTools(fallback);
}
return cachedNostrTools || fallback || null;
}
async function ensureNostrTools() {
if (cachedNostrTools) {
return cachedNostrTools;
}
try {
const result = await nostrToolsReadySource;
rememberNostrTools(result);
} catch (error) {
console.warn("[zapSplit] Failed to resolve nostr-tools helpers.", error);
}
if (!cachedNostrTools) {
rememberNostrTools(readToolkitFromScope());
}
return cachedNostrTools || null;
}
function getOverridePlatformFee() {
return resolvePlatformFeePercent(globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__);
}
function sanitizeAmount(amount) {
const numeric = Number(amount);
if (!Number.isFinite(numeric)) {
throw new Error("Zap amount must be a positive integer.");
}
const rounded = Math.round(numeric);
if (rounded <= 0) {
throw new Error("Zap amount must be greater than zero.");
}
return rounded;
}
function sanitizeComment(comment) {
if (typeof comment !== "string") {
return "";
}
return comment.trim();
}
function sanitizeAddress(address) {
const trimmed = typeof address === "string" ? address.trim() : "";
return trimmed || "";
}
function mergeDependencies(overrides = {}) {
const lnurlDeps = {
...DEFAULT_DEPS.lnurl,
...(overrides.lnurl || {}),
};
const walletDeps = {
...DEFAULT_DEPS.wallet,
...(overrides.wallet || {}),
};
const platformDeps = {
...DEFAULT_DEPS.platformAddress,
...(overrides.platformAddress || {}),
};
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
}
function getNostrTools() {
const tools = getCachedNostrTools();
if (tools?.nip04) {
return tools;
}
const scope = globalScope || (typeof window !== "undefined" ? window : globalThis);
const canonical = scope?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
if (canonical && tools && !tools.nip04 && canonical.nip04) {
try {
tools.nip04 = canonical.nip04;
return tools;
} catch (error) {
return { ...canonical, ...tools, nip04: canonical.nip04 };
}
}
return tools || canonical || null;
}
function decodeAdminPubkey() {
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
return null;
}
}
return null;
}
function derivePointerTag(videoEvent) {
if (!videoEvent || typeof videoEvent !== "object") {
return null;
}
if (!Array.isArray(videoEvent.tags)) {
return null;
}
const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]);
if (!dTag) {
return null;
}
if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") {
return null;
}
const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`;
return ["a", value];
}
function buildZapRequest({
wallet,
recipientPubkey,
videoEvent,
amountSats,
comment,
lnurl,
}) {
const tools = getNostrTools();
if (!tools?.getEventHash || !tools?.signEvent) {
throw new Error("NostrTools is required to sign zap requests.");
}
const tags = [];
if (recipientPubkey) {
tags.push(["p", recipientPubkey]);
}
if (videoEvent?.id) {
tags.push(["e", videoEvent.id]);
}
const pointerTag = derivePointerTag(videoEvent);
if (pointerTag) {
tags.push(pointerTag);
}
if (lnurl) {
tags.push(["lnurl", lnurl]);
}
if (Number.isFinite(amountSats)) {
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
}
const event = {
kind: ZAP_KIND,
content: comment || "",
created_at: Math.floor(Date.now() / 1000),
tags,
pubkey: wallet?.clientPubkey || "",
};
event.id = tools.getEventHash(event);
event.sig = tools.signEvent(event, wallet?.secretKey);
return JSON.stringify(event);
}
function determineRecipientPubkey({ recipientType, metadata, videoEvent }) {
if (metadata?.nostrPubkey && HEX64_REGEX.test(metadata.nostrPubkey)) {
return metadata.nostrPubkey.toLowerCase();
}
if (recipientType === "creator" && typeof videoEvent?.pubkey === "string") {
return videoEvent.pubkey;
}
if (recipientType === "platform") {
return decodeAdminPubkey();
}
return null;
}
async function processShare({
recipientType,
address,
amountSats,
comment,
videoEvent,
wallet,
deps,
}) {
const resolved = deps.lnurl.resolveLightningAddress(address);
const metadata = await deps.lnurl.fetchPayServiceData(resolved.url);
const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats);
let zapRequest = null;
if (metadata.allowsNostr === true || metadata.nostrPubkey) {
const recipientPubkey = determineRecipientPubkey({
recipientType,
metadata,
videoEvent,
});
if (recipientPubkey) {
zapRequest = buildZapRequest({
wallet,
recipientPubkey,
videoEvent,
amountSats,
comment,
lnurl: resolved.url,
});
}
}
const invoice = await deps.lnurl.requestInvoice(metadata, {
amountMsats,
comment,
zapRequest,
});
const payment = await deps.wallet.sendPayment(invoice.invoice, {
amountSats,
zapRequest,
});
return {
recipientType,
address,
amount: amountSats,
lnurl: resolved,
metadata,
invoice,
payment,
zapRequest,
};
}
export async function splitAndZap(
{ videoEvent, amountSats, comment = "", walletSettings } = {},
dependencies = {}
) {
if (!videoEvent || typeof videoEvent !== "object") {
throw new Error("A video event is required to zap.");
}
const amount = sanitizeAmount(amountSats);
const note = sanitizeComment(comment);
const deps = mergeDependencies(dependencies);
const wallet = await deps.wallet.ensureWallet({ settings: walletSettings });
const creatorAddress = sanitizeAddress(videoEvent.lightningAddress);
if (!creatorAddress) {
throw new Error("This creator has not configured a Lightning address yet.");
}
const platformFee = clampPercent(getOverridePlatformFee());
const platformShare = Math.floor((amount * platformFee) / 100);
const creatorShare = amount - platformShare;
let platformAddress = null;
if (platformShare > 0) {
platformAddress = sanitizeAddress(
await deps.platformAddress.getPlatformLightningAddress({ forceRefresh: false })
);
if (!platformAddress) {
throw new Error("Platform Lightning address is unavailable.");
}
}
const receipts = [];
if (creatorShare > 0) {
receipts.push(
await processShare({
recipientType: "creator",
address: creatorAddress,
amountSats: creatorShare,
comment: note,
videoEvent,
wallet,
deps,
})
);
}
if (platformShare > 0) {
receipts.push(
await processShare({
recipientType: "platform",
address: platformAddress,
amountSats: platformShare,
comment: note,
videoEvent,
wallet,
deps,
})
);
}
return {
totalAmount: amount,
creatorShare,
platformShare,
receipts,
};
}
export const __TESTING__ = Object.freeze({
buildZapRequest,
determineRecipientPubkey,
derivePointerTag,
mergeDependencies,
parsePercentValue,
clampPercent,
});
+705
View File
@@ -0,0 +1,705 @@
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"];
const FAST_RELAY_FETCH_LIMIT = 3;
const FAST_RELAY_TIMEOUT_MS = 2500;
const BACKGROUND_RELAY_TIMEOUT_MS = 6000;
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 applyEvents = (events, { skipIfEmpty = false } = {}) => {
if (!Array.isArray(events) || !events.length) {
if (skipIfEmpty) {
return null;
}
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
const sorted = events
.filter((event) => event && event.pubkey === normalizedPubkey)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
if (!sorted.length) {
if (skipIfEmpty) {
return null;
}
this.lastEvent = null;
this.lastLoadSource = "default";
this.setEntries(this.defaultEntries, { allowEmpty: false });
return { ok: true, source: "default", events: [] };
}
const newest = sorted[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 };
};
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const timeoutError = new Error(
`Timed out fetching relay list from ${relayUrl} after ${timeoutMs}ms`
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
Promise.resolve()
.then(() => nostrClient.pool.list([relayUrl], [filter]))
.then((result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalizedPubkey)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No relay list events returned from ${relayUrl}`
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
reject(emptyError);
return;
}
resolve({ relayUrl, events });
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
reject(wrapped);
});
});
};
const fastRelays = targetRelays.slice(0, FAST_RELAY_FETCH_LIMIT);
const backgroundRelays = targetRelays.slice(fastRelays.length);
const fastPromises = fastRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, FAST_RELAY_TIMEOUT_MS, true)
);
const backgroundPromises = backgroundRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, BACKGROUND_RELAY_TIMEOUT_MS, false)
);
const background = Promise.allSettled([
...fastPromises,
...backgroundPromises,
])
.then((outcomes) => {
const aggregated = [];
outcomes.forEach((outcome) => {
if (outcome.status === "fulfilled") {
const events = Array.isArray(outcome.value?.events)
? outcome.value.events
: [];
if (events.length) {
aggregated.push(...events);
}
} else {
const reason = outcome.reason;
if (reason?.code === "timeout") {
if (isDevMode) {
console.warn(
`[relayManager] Relay ${reason.relay} timed out while loading relay list (${reason.timeoutMs}ms)`
);
}
} else if (isDevMode) {
console.warn(
`[relayManager] Relay ${reason?.relay || "unknown"} failed while loading relay list:`,
reason
);
}
}
});
if (!aggregated.length) {
return;
}
applyEvents(aggregated, { skipIfEmpty: true });
})
.catch((error) => {
if (isDevMode) {
console.warn("[relayManager] Background relay refresh failed", error);
}
});
let fastResult = null;
if (fastPromises.length) {
try {
fastResult = await Promise.any(fastPromises);
} catch (error) {
if (error instanceof AggregateError) {
error.errors?.forEach((err) => {
if (err?.code === "timeout" && isDevMode) {
console.warn(
`[relayManager] Relay ${err.relay} timed out while loading relay list (${err.timeoutMs}ms)`
);
}
});
} else if (isDevMode) {
console.warn("[relayManager] Fast relay fetch failed", error);
}
}
}
if (fastResult?.events?.length) {
const result = applyEvents(fastResult.events);
background.catch(() => {});
return result;
}
const fallback = applyEvents([], { skipIfEmpty: false });
background.catch(() => {});
return fallback;
}
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);
}
+954
View File
@@ -0,0 +1,954 @@
// js/services/authService.js
import {
setPubkey,
setCurrentUserNpub,
getPubkey,
getCurrentUserNpub,
} from "../state/appState.js";
import {
getSavedProfiles,
getActiveProfilePubkey,
setActiveProfilePubkey,
mutateSavedProfiles,
persistSavedProfiles,
loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage,
syncSavedProfileFromCache as syncSavedProfileFromCacheState,
loadProfileCacheFromStorage as hydrateProfileCacheFromStorage,
persistProfileCacheToStorage as persistProfileCacheState,
getProfileCacheEntry as getCachedProfileEntry,
setProfileCacheEntry as setCachedProfileEntry,
} from "../state/cache.js";
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logger) {
this.logger(`AuthService listener for "${eventName}" threw`, error);
}
}
}
}
}
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const FALLBACK_PROFILE = {
name: "Unknown",
picture: "assets/svg/default-profile.svg",
about: "",
website: "",
banner: "",
lud16: "",
lud06: "",
};
const FAST_PROFILE_RELAY_LIMIT = 3;
const FAST_PROFILE_TIMEOUT_MS = 2500;
const BACKGROUND_PROFILE_TIMEOUT_MS = 6000;
export default class AuthService {
constructor({ nostrClient, userBlocks, relayManager, logger } = {}) {
this.nostrClient = nostrClient || null;
this.userBlocks = userBlocks || null;
this.relayManager = relayManager || null;
if (typeof logger === "function") {
this.logger = logger;
} else if (logger && typeof logger.log === "function") {
this.logger = (...args) => logger.log(...args);
} else {
this.logger = () => {};
}
this.emitter = new SimpleEventEmitter((message, error) => {
try {
this.logger(message, error);
} catch (logError) {
console.warn("[AuthService] logger threw", logError);
}
});
}
log(...args) {
try {
this.logger(...args);
} catch (error) {
console.warn("[AuthService] logger threw", error);
}
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
emit(eventName, detail) {
this.emitter.emit(eventName, detail);
}
hydrateFromStorage() {
hydrateProfileCacheFromStorage();
const { profiles } = hydrateSavedProfilesFromStorage();
if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) {
const first = profiles[0]?.pubkey || null;
if (first) {
setActiveProfilePubkey(first, { persist: true });
}
}
this.emitProfileList("hydrate");
}
persistProfileCache() {
persistProfileCacheState();
}
getProfileCacheEntry(pubkey) {
return getCachedProfileEntry(pubkey);
}
setProfileCacheEntry(pubkey, profile, { persist = true, reason = "manual" } = {}) {
const entry = setCachedProfileEntry(pubkey, profile, { persist });
if (entry) {
const normalized = this.normalizeHexPubkey(pubkey);
if (normalized) {
const updated = syncSavedProfileFromCacheState(normalized, {
persist,
});
if (updated) {
this.emitProfileList("cache-sync");
}
this.emit("profile:updated", {
pubkey: normalized,
profile: entry.profile || FALLBACK_PROFILE,
reason,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
});
}
}
return entry;
}
cloneSavedProfiles() {
const saved = getSavedProfiles();
return Array.isArray(saved) ? saved.map((entry) => ({ ...entry })) : [];
}
emitProfileList(reason = "update") {
this.emit("profile:updated", {
reason,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
});
}
safeEncodeNpub(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
try {
return window?.NostrTools?.nip19?.npubEncode(trimmed) || null;
} catch (error) {
return null;
}
}
safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (error) {
return null;
}
return null;
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
}
async requestLogin(options = {}) {
if (!this.nostrClient || typeof this.nostrClient.login !== "function") {
throw new Error("Nostr login is not available.");
}
const result = await this.nostrClient.login(options);
const pubkey =
typeof result === "string"
? result
: result && typeof result === "object"
? result.pubkey || result.publicKey || ""
: "";
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
if (!trimmed) {
return { pubkey: null };
}
if (options?.autoApply === false) {
return { pubkey: trimmed };
}
const detail = await this.login(trimmed, {
persistActive: options?.persistActive !== false,
});
return { pubkey: trimmed, detail };
}
async handleUploadSubmit(payload, { publish } = {}) {
if (!this.getActivePubkey()) {
await this.requestLogin({ allowAccountSelection: true });
if (!this.getActivePubkey()) {
throw new Error("Login required to publish videos.");
}
}
if (typeof publish !== "function") {
throw new Error("A publish callback must be provided to handleUploadSubmit.");
}
return publish(payload);
}
getActivePubkey() {
return getPubkey();
}
async login(pubkey, options = {}) {
const normalizedOptions =
options && typeof options === "object" ? options : { persistActive: true };
const persistActive =
normalizedOptions.persistActive === false ? false : true;
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
const normalized = this.normalizeHexPubkey(pubkey);
const trimmed = typeof pubkey === "string" ? pubkey.trim() : "";
const nextPubkey = normalized || trimmed || null;
if (!nextPubkey) {
throw new Error("A valid pubkey is required for login.");
}
const identityChanged = previousPubkey !== nextPubkey;
if (normalized) {
setPubkey(normalized);
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = normalized;
}
} else {
setPubkey(nextPubkey);
if (this.nostrClient && typeof this.nostrClient === "object") {
this.nostrClient.pubkey = trimmed ? trimmed.toLowerCase() : "";
}
}
const npub = this.safeEncodeNpub(nextPubkey);
setCurrentUserNpub(npub);
let savedProfilesMutated = false;
mutateSavedProfiles((profiles) => {
const draft = Array.isArray(profiles) ? profiles.slice() : [];
const existingIndex = draft.findIndex((entry) => {
const normalizedEntry = this.normalizeHexPubkey(entry?.pubkey);
return normalizedEntry && normalizedEntry === normalized;
});
const cacheEntry = this.getProfileCacheEntry(normalized || nextPubkey);
const cachedProfile = cacheEntry?.profile || {};
const nextEntry = {
pubkey: normalized || nextPubkey,
npub: npub || (cachedProfile.npub ?? null),
name: cachedProfile.name || draft[existingIndex]?.name || "",
picture: cachedProfile.picture || draft[existingIndex]?.picture || "",
authType: "nip07",
};
if (existingIndex >= 0) {
const currentEntry = draft[existingIndex] || {};
const changed =
currentEntry.npub !== nextEntry.npub ||
currentEntry.name !== nextEntry.name ||
currentEntry.picture !== nextEntry.picture ||
currentEntry.authType !== nextEntry.authType;
if (changed) {
draft[existingIndex] = nextEntry;
savedProfilesMutated = true;
}
} else {
draft.push(nextEntry);
savedProfilesMutated = true;
}
return draft;
}, { persist: false, persistActive: false });
if (persistActive) {
setActiveProfilePubkey(normalized || nextPubkey, { persist: true });
}
if (!persistActive && savedProfilesMutated) {
persistSavedProfiles({ persistActive: false });
} else if (persistActive || savedProfilesMutated) {
persistSavedProfiles({ persistActive: true });
}
if (savedProfilesMutated) {
this.emitProfileList("login");
}
const activeLoginPubkey = getPubkey();
const normalizedLoginPubkey =
this.normalizeHexPubkey(activeLoginPubkey) || activeLoginPubkey || null;
const detail = {
pubkey: activeLoginPubkey,
npub: getCurrentUserNpub(),
previousPubkey: previousPubkey || null,
identityChanged,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
postLogin: {
pubkey: normalizedLoginPubkey,
blocksLoaded: false,
relaysLoaded: false,
profile: null,
},
postLoginPromise: null,
postLoginError: null,
};
const postLoginPromise = Promise.resolve()
.then(() => this.applyPostLoginState())
.then((postLogin) => {
detail.postLogin = postLogin;
detail.postLoginError = null;
try {
this.emitProfileList("post-login");
} catch (error) {
this.log("[AuthService] Failed to emit profile list after login", error);
}
this.emit("auth:post-login", {
...detail,
postLogin,
postLoginError: null,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
});
return postLogin;
})
.catch((error) => {
this.log("[AuthService] applyPostLoginState failed", error);
detail.postLoginError = error;
this.emit("auth:post-login", {
...detail,
postLogin: detail.postLogin,
postLoginError: error,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
});
return null;
});
detail.postLoginPromise = postLoginPromise;
try {
Object.defineProperty(detail, "__handled", {
configurable: true,
enumerable: false,
writable: true,
value: false,
});
} catch (error) {
// Ignore descriptor errors (e.g., frozen objects) and fall back to direct assignment.
detail.__handled = false;
}
this.emit("auth:login", detail);
return detail;
}
async applyPostLoginState() {
const activePubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
const detail = {
pubkey: activePubkey || null,
blocksLoaded: false,
relaysLoaded: false,
profile: null,
};
if (!activePubkey) {
return detail;
}
const schedule = (callback) => Promise.resolve().then(() => callback());
const operations = [];
if (this.userBlocks && typeof this.userBlocks.loadBlocks === "function") {
operations.push({
name: "blocksLoaded",
promise: schedule(() => this.userBlocks.loadBlocks(activePubkey)),
onFulfilled: () => true,
onRejected: (error) => {
this.log("[AuthService] Failed to load block list", error);
return false;
},
});
}
if (this.relayManager && typeof this.relayManager.loadRelayList === "function") {
operations.push({
name: "relaysLoaded",
promise: schedule(() => this.relayManager.loadRelayList(activePubkey)),
onFulfilled: () => true,
onRejected: (error) => {
this.log("[AuthService] Failed to load relay list", error);
return false;
},
});
}
operations.push({
name: "profile",
promise: schedule(() => this.loadOwnProfile(activePubkey)),
onFulfilled: (value) => value,
onRejected: (error) => {
this.log("[AuthService] Failed to load own profile", error);
return null;
},
});
const settled = await Promise.allSettled(
operations.map((operation) => operation.promise)
);
settled.forEach((result, index) => {
const operation = operations[index];
if (!operation) {
return;
}
if (result.status === "fulfilled") {
detail[operation.name] = operation.onFulfilled(result.value);
} else {
detail[operation.name] = operation.onRejected(result.reason);
}
});
return detail;
}
async logout() {
if (this.nostrClient && typeof this.nostrClient.logout === "function") {
try {
this.nostrClient.logout();
} catch (error) {
this.log("[AuthService] nostrClient.logout threw", error);
}
}
const previousPubkey = this.normalizeHexPubkey(getPubkey()) || getPubkey();
setPubkey(null);
setCurrentUserNpub(null);
setActiveProfilePubkey(null, { persist: true });
if (this.userBlocks && typeof this.userBlocks.reset === "function") {
try {
this.userBlocks.reset();
} catch (error) {
this.log("[AuthService] userBlocks.reset threw", error);
}
}
if (this.relayManager && typeof this.relayManager.reset === "function") {
try {
this.relayManager.reset();
} catch (error) {
this.log("[AuthService] relayManager.reset threw", error);
}
}
persistSavedProfiles({ persistActive: true });
const detail = {
previousPubkey: previousPubkey || null,
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
};
this.emit("auth:logout", detail);
return detail;
}
removeSavedProfile(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return { removed: false };
}
const { changed } = mutateSavedProfiles((profiles) =>
profiles.filter((entry) => entry?.pubkey !== normalized),
{
persist: true,
persistActive: true,
});
if (changed && getActiveProfilePubkey() === normalized) {
setActiveProfilePubkey(null, { persist: true });
}
if (changed) {
this.emitProfileList("remove-saved-profile");
}
return { removed: changed };
}
async switchProfile(pubkey) {
const normalizedTarget = this.normalizeHexPubkey(pubkey);
if (!normalizedTarget) {
throw new Error("Unable to switch profiles: invalid account.");
}
const normalizedActive = this.normalizeHexPubkey(getActiveProfilePubkey());
if (normalizedActive && normalizedActive === normalizedTarget) {
return { switched: false, reason: "already-active" };
}
const requestOptions = {
allowAccountSelection: true,
expectPubkey: normalizedTarget,
persistActive: true,
};
const autoApply = requestOptions.autoApply !== false;
let requestResult;
try {
requestResult = await this.requestLogin(requestOptions);
} catch (error) {
throw error;
}
const requestedPubkey =
this.normalizeHexPubkey(requestResult?.pubkey) ||
(typeof requestResult?.pubkey === "string"
? requestResult.pubkey.trim()
: "");
let detail = requestResult?.detail || null;
if (!detail && !autoApply) {
const fallbackTarget =
this.normalizeHexPubkey(requestedPubkey) || requestedPubkey || normalizedTarget;
detail = await this.login(fallbackTarget, { persistActive: true });
}
mutateSavedProfiles((profiles) => {
const draft = profiles.slice();
const index = draft.findIndex(
(entry) => this.normalizeHexPubkey(entry?.pubkey) === normalizedTarget
);
if (index > 0) {
const [moved] = draft.splice(index, 1);
draft.unshift(moved);
}
return draft;
}, { persist: true, persistActive: true });
const savedProfiles = this.cloneSavedProfiles();
const activeProfilePubkey = getActiveProfilePubkey();
if (detail && typeof detail === "object") {
detail.savedProfiles = savedProfiles;
detail.activeProfilePubkey = activeProfilePubkey;
}
this.emitProfileList("switch-profile");
return { switched: true, detail };
}
async loadOwnProfile(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return FALLBACK_PROFILE;
}
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
return FALLBACK_PROFILE;
}
const relays = Array.isArray(this.nostrClient.relays)
? this.nostrClient.relays.filter((url) => typeof url === "string" && url)
: [];
if (!relays.length) {
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
persist: true,
reason: "load-own-profile:fallback",
});
return FALLBACK_PROFILE;
}
const filter = [{ kinds: [0], authors: [normalized], limit: 1 }];
const selectNewestEvent = (eventList) => {
if (!Array.isArray(eventList) || !eventList.length) {
return null;
}
const sorted = eventList
.filter((event) => event && event.pubkey === normalized)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
return sorted.length ? sorted[0] : null;
};
const buildProfileFromEvent = (event) => {
if (!event?.content) {
return FALLBACK_PROFILE;
}
try {
const data = JSON.parse(event.content);
return {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
} catch (error) {
this.log("[AuthService] Failed to parse profile metadata", error);
return FALLBACK_PROFILE;
}
};
const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
const timeoutError = new Error(
`Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`
);
timeoutError.code = "timeout";
timeoutError.relay = relayUrl;
timeoutError.timeoutMs = timeoutMs;
reject(timeoutError);
}, timeoutMs);
Promise.resolve()
.then(() => this.nostrClient.pool.list([relayUrl], filter))
.then((result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalized)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
`No profile events returned from ${relayUrl}`
);
emptyError.code = "empty";
emptyError.relay = relayUrl;
reject(emptyError);
return;
}
resolve({ relayUrl, events });
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
const wrapped =
error instanceof Error ? error : new Error(String(error));
wrapped.relay = relayUrl;
reject(wrapped);
});
});
};
const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT);
const backgroundRelays = relays.slice(fastRelays.length);
const fastPromises = fastRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true)
);
const backgroundPromises = backgroundRelays.map((relayUrl) =>
fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false)
);
const background = Promise.allSettled([
...fastPromises,
...backgroundPromises,
])
.then((outcomes) => {
const aggregated = [];
outcomes.forEach((outcome) => {
if (outcome.status === "fulfilled") {
const events = Array.isArray(outcome.value?.events)
? outcome.value.events
: [];
if (events.length) {
aggregated.push(...events);
}
} else if (outcome.status === "rejected") {
const reason = outcome.reason;
if (reason?.code === "timeout") {
this.log(
`[AuthService] Relay ${reason.relay} timed out loading profile (background)`
);
} else {
this.log(
`[AuthService] Relay ${reason?.relay || "unknown"} failed loading profile (background)`,
reason
);
}
}
});
const newest = selectNewestEvent(aggregated);
if (!newest) {
return;
}
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:background",
});
})
.catch((error) => {
this.log("[AuthService] Background profile refresh failed", error);
});
let fastResult = null;
if (fastPromises.length) {
try {
fastResult = await Promise.any(fastPromises);
} catch (error) {
if (error instanceof AggregateError) {
error.errors?.forEach((err) => {
if (err?.code === "timeout") {
this.log(
`[AuthService] Relay ${err.relay} timed out loading profile`
);
}
});
} else {
this.log("[AuthService] Failed to load profile from fast relays", error);
}
}
}
if (fastResult?.events?.length) {
const newest = selectNewestEvent(fastResult.events);
if (newest) {
const profile = buildProfileFromEvent(newest);
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: "load-own-profile:fast",
});
background.catch(() => {});
return profile;
}
}
this.setProfileCacheEntry(normalized, FALLBACK_PROFILE, {
persist: true,
reason: "load-own-profile:fallback",
});
background.catch(() => {});
return FALLBACK_PROFILE;
}
async fetchProfile(pubkey, { forceRefresh = false } = {}) {
const normalized = this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return null;
}
const cacheEntry = this.getProfileCacheEntry(normalized);
if (cacheEntry && !forceRefresh) {
this.emit("profile:updated", {
pubkey: normalized,
profile: cacheEntry.profile || FALLBACK_PROFILE,
reason: "cache-hit",
savedProfiles: this.cloneSavedProfiles(),
activeProfilePubkey: getActiveProfilePubkey(),
});
return cacheEntry.profile;
}
if (!this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
return cacheEntry?.profile || FALLBACK_PROFILE;
}
try {
const results = await Promise.all(
this.nostrClient.relays.map((relayUrl) =>
this.nostrClient.pool.list([relayUrl], [
{ kinds: [0], authors: [normalized], limit: 1 },
])
)
);
const events = results.flat();
let newest = null;
for (const event of events) {
if (!event || event.pubkey !== normalized || !event.content) {
continue;
}
if (!newest || event.created_at > newest.created_at) {
newest = event;
}
}
if (newest?.content) {
const data = JSON.parse(newest.content);
const profile = {
name: data.display_name || data.name || FALLBACK_PROFILE.name,
picture: data.picture || FALLBACK_PROFILE.picture,
about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about,
website:
typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website,
banner:
typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner,
lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16,
lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06,
};
this.setProfileCacheEntry(normalized, profile, {
persist: true,
reason: forceRefresh ? "force-refresh" : "fetch-profile",
});
return profile;
}
} catch (error) {
this.log("[AuthService] Failed to fetch profile", error);
}
return cacheEntry?.profile || FALLBACK_PROFILE;
}
async fetchAndRenderProfile(pubkey, forceRefresh = false) {
return this.fetchProfile(pubkey, { forceRefresh });
}
syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
const normalized = this.normalizeHexPubkey(pubkey) ||
(typeof pubkey === "string" ? pubkey.trim() : "");
if (!normalized) {
return false;
}
const updated = syncSavedProfileFromCacheState(normalized, { persist });
if (updated) {
this.emitProfileList("cache-sync");
}
return updated;
}
loadProfileCacheFromStorage() {
hydrateProfileCacheFromStorage();
}
loadSavedProfilesFromStorage() {
const result = hydrateSavedProfilesFromStorage();
this.emitProfileList("hydrate");
return result;
}
}
+231
View File
@@ -0,0 +1,231 @@
import { isDevMode } from "../config.js";
const DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS = 24;
const COUNT_UNSUPPORTED_TITLE = "Relay does not support NIP-45 COUNT queries.";
function toPositiveInteger(value, fallback) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return fallback;
}
return Math.floor(numeric);
}
export default class DiscussionCountService {
constructor({
maxVideos = DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS,
logger = typeof console !== "undefined" ? console : null,
} = {}) {
this.maxVideos = toPositiveInteger(maxVideos, DEFAULT_MAX_DISCUSSION_COUNT_VIDEOS);
this.logger = logger;
this.videoDiscussionCountCache = new Map();
this.inFlightDiscussionCounts = new Map();
}
clearCache() {
this.videoDiscussionCountCache.clear();
this.inFlightDiscussionCounts.clear();
}
getCachedCount(videoId) {
if (!videoId) {
return undefined;
}
return this.videoDiscussionCountCache.get(videoId);
}
refreshCounts(videos = [], options = {}) {
const { videoListRoot = null, nostrClient = null } = options;
if (!Array.isArray(videos) || !videos.length) {
return;
}
const root =
videoListRoot && typeof videoListRoot.querySelector === "function"
? videoListRoot
: null;
if (!root || !nostrClient?.pool || typeof nostrClient.countEventsAcrossRelays !== "function") {
return;
}
const eligibleVideos = videos
.filter(
(video) =>
video &&
typeof video.id === "string" &&
video.id &&
video.enableComments !== false
)
.slice(0, this.maxVideos);
eligibleVideos.forEach((video) => {
const container = root.querySelector(`[data-discussion-count="${video.id}"]`);
if (!container) {
return;
}
const cachedCount = this.getCachedCount(video.id);
if (typeof cachedCount === "number") {
this.updateDiscussionCountElement(container, cachedCount);
return;
}
const filters = this.buildDiscussionCountFilters(video);
if (!filters.length) {
this.markDiscussionCountError(container, { unsupported: true });
return;
}
if (this.inFlightDiscussionCounts.has(video.id)) {
this.setDiscussionCountPending(container);
return;
}
this.setDiscussionCountPending(container);
const request = Promise.resolve()
.then(() => nostrClient.countEventsAcrossRelays(filters))
.then((result) => {
const perRelay = Array.isArray(result?.perRelay)
? result.perRelay.filter((entry) => entry && entry.ok)
: [];
if (!perRelay.length) {
this.markDiscussionCountError(container, { unsupported: true });
return result;
}
const total = Number(result?.total);
const normalized =
Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0;
this.videoDiscussionCountCache.set(video.id, normalized);
this.updateDiscussionCountElement(container, normalized);
return result;
})
.catch((error) => {
if (isDevMode && this.logger?.warn) {
this.logger.warn(
`[counts] Failed to fetch discussion count for ${video.id}:`,
error
);
}
this.markDiscussionCountError(container);
throw error;
})
.finally(() => {
this.inFlightDiscussionCounts.delete(video.id);
});
this.inFlightDiscussionCounts.set(video.id, request);
});
}
buildDiscussionCountFilters(video) {
if (!video || typeof video !== "object") {
return [];
}
const filters = [];
const eventId = typeof video.id === "string" ? video.id.trim() : "";
if (eventId) {
filters.push({ kinds: [1], "#e": [eventId] });
}
const address = this.getVideoAddressPointer(video);
if (address) {
filters.push({ kinds: [1], "#a": [address] });
}
return filters;
}
getVideoAddressPointer(video) {
if (!video || typeof video !== "object") {
return "";
}
const tags = Array.isArray(video.tags) ? video.tags : [];
const dTag = tags.find(
(tag) =>
Array.isArray(tag) &&
tag.length >= 2 &&
tag[0] === "d" &&
typeof tag[1] === "string" &&
tag[1].trim()
);
if (!dTag) {
return "";
}
const pubkey = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
if (!pubkey) {
return "";
}
const identifier = dTag[1].trim();
if (!identifier) {
return "";
}
const kind =
Number.isFinite(video.kind) && video.kind > 0
? Math.floor(video.kind)
: 30078;
return `${kind}:${pubkey}:${identifier}`;
}
setDiscussionCountPending(element) {
if (!element) {
return;
}
element.dataset.countState = "pending";
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "…";
}
element.removeAttribute("title");
}
updateDiscussionCountElement(element, count) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (!valueEl) {
return;
}
const numeric = Number(count);
const safeValue =
Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0;
element.dataset.countState = "ready";
valueEl.textContent = safeValue.toLocaleString();
element.removeAttribute("title");
}
markDiscussionCountError(element, { unsupported = false } = {}) {
if (!element) {
return;
}
const valueEl = element.querySelector("[data-discussion-count-value]");
if (valueEl) {
valueEl.textContent = "—";
}
element.dataset.countState = unsupported ? "unsupported" : "error";
if (unsupported) {
element.title = COUNT_UNSUPPORTED_TITLE;
} else {
element.removeAttribute("title");
}
}
}
export { COUNT_UNSUPPORTED_TITLE };
+509
View File
@@ -0,0 +1,509 @@
import {
nostrClient,
convertEventToVideo,
} from "../nostr.js";
import { accessControl } from "../accessControl.js";
import {
getVideosMap as getStoredVideosMap,
setVideosMap as setStoredVideosMap,
getVideoSubscription as getStoredVideoSubscription,
setVideoSubscription as setStoredVideoSubscription,
} from "../state/appState.js";
const VIDEO_KIND = 30078;
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (error) {
if (this.logger) {
try {
this.logger(`nostrService listener for "${eventName}" threw`, error);
} catch (logError) {
console.warn("[nostrService] listener logger threw", logError);
}
}
}
}
}
}
function normalizeLogger(logger) {
if (typeof logger === "function") {
return logger;
}
if (logger && typeof logger.log === "function") {
return (...args) => logger.log(...args);
}
return () => {};
}
function ensureSet(value) {
if (value instanceof Set) {
return value;
}
if (Array.isArray(value)) {
return new Set(value);
}
return new Set();
}
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return "";
}
const trimmed = pubkey.trim();
return trimmed ? trimmed.toLowerCase() : "";
}
function normalizeUntil(timestamp) {
const value = Number(timestamp);
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const seconds = Math.floor(value);
return seconds > 0 ? seconds : 0;
}
export class NostrService {
constructor({ logger } = {}) {
this.nostrClient = nostrClient;
this.accessControl = accessControl;
this.logger = normalizeLogger(logger);
this.emitter = new SimpleEventEmitter((message, error) => {
try {
this.logger(message, error);
} catch (logError) {
console.warn("[nostrService] logger threw", logError);
}
});
this.videosMap = null;
}
log(...args) {
try {
this.logger(...args);
} catch (error) {
console.warn("[nostrService] logger threw", error);
}
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
emit(eventName, detail) {
this.emitter.emit(eventName, detail);
}
ensureVideosMap() {
if (this.videosMap instanceof Map) {
return this.videosMap;
}
let map = getStoredVideosMap();
if (!(map instanceof Map)) {
map = new Map();
setStoredVideosMap(map);
}
this.videosMap = map;
return this.videosMap;
}
getVideosMap() {
return this.ensureVideosMap();
}
getVideoSubscription() {
return getStoredVideoSubscription();
}
setVideoSubscription(subscription) {
setStoredVideoSubscription(subscription || null);
this.emit("subscription:changed", { subscription: subscription || null });
}
clearVideoSubscription() {
const current = getStoredVideoSubscription();
if (current && typeof current.unsub === "function") {
try {
current.unsub();
} catch (error) {
console.warn("[nostrService] Failed to unsubscribe from video feed:", error);
}
}
this.setVideoSubscription(null);
}
cacheVideos(videos = []) {
if (!Array.isArray(videos) || !videos.length) {
return;
}
const map = this.ensureVideosMap();
for (const video of videos) {
if (video && typeof video.id === "string" && video.id) {
map.set(video.id, video);
}
}
setStoredVideosMap(map);
this.emit("videos:cache", { size: map.size });
}
shouldIncludeVideo(video, {
blacklistedEventIds = new Set(),
isAuthorBlocked = () => false,
} = {}) {
if (!video || typeof video !== "object") {
return false;
}
if (blacklistedEventIds.has(video.id)) {
return false;
}
if (typeof video.pubkey === "string" && video.pubkey) {
try {
if (isAuthorBlocked(video.pubkey)) {
return false;
}
} catch (error) {
console.warn("[nostrService] isAuthorBlocked handler threw", error);
}
}
if (video.isPrivate === true) {
const normalizedVideoPubkey = normalizeHexPubkey(video.pubkey);
const normalizedViewerPubkey = normalizeHexPubkey(
this.nostrClient?.pubkey
);
if (
!normalizedViewerPubkey ||
!normalizedVideoPubkey ||
normalizedVideoPubkey !== normalizedViewerPubkey
) {
return false;
}
}
if (this.accessControl && typeof this.accessControl.canAccess === "function") {
try {
if (!this.accessControl.canAccess(video)) {
return false;
}
} catch (error) {
console.warn("[nostrService] access control check failed", error);
return false;
}
}
return true;
}
filterVideos(videos = [], options = {}) {
const blacklist = ensureSet(options.blacklistedEventIds);
const isAuthorBlocked =
typeof options.isAuthorBlocked === "function"
? options.isAuthorBlocked
: () => false;
return videos.filter((video) =>
this.shouldIncludeVideo(video, {
blacklistedEventIds: blacklist,
isAuthorBlocked,
})
);
}
async ensureAccessControlReady() {
if (!this.accessControl || typeof this.accessControl.ensureReady !== "function") {
return;
}
try {
await this.accessControl.ensureReady();
} catch (error) {
console.warn(
"[nostrService] Failed to ensure access control lists are ready:",
error
);
}
}
getFilteredActiveVideos(options = {}) {
const all = this.nostrClient.getActiveVideos();
return this.filterVideos(all, options);
}
async loadVideos({
forceFetch = false,
blacklistedEventIds,
isAuthorBlocked,
onVideos,
} = {}) {
await this.ensureAccessControlReady();
if (forceFetch) {
this.clearVideoSubscription();
}
const applyAndNotify = (videos, reason) => {
const filtered = this.filterVideos(videos, {
blacklistedEventIds,
isAuthorBlocked,
});
this.cacheVideos(filtered);
if (typeof onVideos === "function") {
try {
onVideos(filtered, { reason });
} catch (error) {
console.warn("[nostrService] onVideos handler threw", error);
}
}
this.emit("videos:updated", { videos: filtered, reason });
return filtered;
};
const cached = this.nostrClient.getActiveVideos();
const initial = applyAndNotify(cached, "cache");
if (!getStoredVideoSubscription()) {
const subscription = this.nostrClient.subscribeVideos(() => {
const updated = this.nostrClient.getActiveVideos();
applyAndNotify(updated, "subscription");
});
this.setVideoSubscription(subscription);
this.emit("subscription:started", { subscription });
}
return initial;
}
async fetchVideos(options = {}) {
try {
const videos = await this.nostrClient.fetchVideos();
const filtered = this.filterVideos(videos, options);
this.cacheVideos(filtered);
this.emit("videos:fetched", { videos: filtered });
return filtered;
} catch (error) {
console.error("[nostrService] Failed to fetch videos:", error);
return [];
}
}
async loadOlderVideos(lastTimestamp, {
blacklistedEventIds,
isAuthorBlocked,
limit = 150,
} = {}) {
const until = normalizeUntil(lastTimestamp) - 1;
if (until <= 0 || !this.nostrClient?.pool || !Array.isArray(this.nostrClient?.relays)) {
return [];
}
const filter = {
kinds: [VIDEO_KIND],
"#t": ["video"],
until,
limit,
};
const collected = new Map();
try {
const events = await this.nostrClient.pool.list(this.nostrClient.relays, [filter]);
for (const event of Array.isArray(events) ? events : []) {
try {
const video = convertEventToVideo(event);
if (video.invalid) {
continue;
}
if (
this.nostrClient &&
typeof this.nostrClient.applyRootCreatedAt === "function"
) {
this.nostrClient.applyRootCreatedAt(video);
}
if (collected.has(video.id)) {
continue;
}
collected.set(video.id, video);
this.nostrClient.allEvents.set(video.id, video);
} catch (error) {
console.warn("[nostrService] Failed to convert older event", error);
}
}
} catch (error) {
console.error("[nostrService] Failed to load older videos:", error);
return [];
}
const videos = Array.from(collected.values());
const filtered = this.filterVideos(videos, {
blacklistedEventIds,
isAuthorBlocked,
});
this.cacheVideos(filtered);
this.emit("videos:older", { videos: filtered, until });
return filtered;
}
async publishVideoNote(publishPayload, pubkey) {
const detail = { pubkey };
if (publishPayload && typeof publishPayload === "object") {
detail.payload = publishPayload;
if (publishPayload.legacyFormData) {
detail.legacyFormData = publishPayload.legacyFormData;
}
if (publishPayload.nip71) {
detail.nip71 = publishPayload.nip71;
}
} else {
detail.formData = publishPayload;
}
let nip71Result = null;
let legacyResult = null;
try {
legacyResult = await this.nostrClient.publishVideo(
publishPayload,
pubkey
);
} catch (error) {
detail.error = error;
throw error;
}
let shouldAttemptNip71 = true;
const pointerIdentifiers = {
eventId: legacyResult?.id,
};
if (Array.isArray(legacyResult?.tags)) {
const dTag = legacyResult.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string"
);
if (dTag) {
pointerIdentifiers.dTag = dTag[1];
}
}
if (legacyResult?.content) {
try {
const parsed = JSON.parse(legacyResult.content);
if (parsed && typeof parsed.videoRootId === "string") {
pointerIdentifiers.videoRootId = parsed.videoRootId;
}
if (parsed && parsed.isPrivate) {
shouldAttemptNip71 = false;
}
} catch (parseError) {
this.log("[nostrService] Failed to parse legacy publish payload", parseError);
}
}
if (!pointerIdentifiers.videoRootId) {
shouldAttemptNip71 = false;
}
if (shouldAttemptNip71) {
try {
nip71Result = await this.nostrClient.publishNip71Video(
publishPayload,
pubkey,
pointerIdentifiers
);
} catch (nip71Error) {
detail.nip71Error = nip71Error;
this.log("[nostrService] NIP-71 publish failed", nip71Error);
}
}
const result = { legacy: legacyResult, nip71: nip71Result };
detail.result = result;
this.emit("videos:published", detail);
return result;
}
async handleEditVideoSubmit({ originalEvent, updatedData, pubkey }) {
const result = await this.nostrClient.editVideo(originalEvent, updatedData, pubkey);
this.emit("videos:edited", { originalEvent, updatedData, pubkey, result });
return result;
}
async handleFullDeleteVideo({ videoRootId, pubkey, confirm = true } = {}) {
const result = await this.nostrClient.deleteAllVersions(videoRootId, pubkey, {
confirm,
});
if (result) {
this.emit("videos:deleted", { videoRootId, pubkey });
}
return result;
}
async getOldEventById(eventId) {
const map = this.ensureVideosMap();
if (map.has(eventId)) {
return map.get(eventId);
}
const cached = this.nostrClient.allEvents.get(eventId);
if (cached && !cached.deleted) {
map.set(eventId, cached);
setStoredVideosMap(map);
return cached;
}
const fetched = await this.nostrClient.getEventById(eventId);
if (fetched && !fetched.deleted) {
map.set(eventId, fetched);
setStoredVideosMap(map);
return fetched;
}
return null;
}
}
const nostrService = new NostrService();
export default nostrService;
+324
View File
@@ -0,0 +1,324 @@
import {
loadNwcSettings,
saveNwcSettings,
clearNwcSettings,
createDefaultNwcSettings,
} from "../nwcSettings.js";
import { MAX_WALLET_DEFAULT_ZAP } from "../config.js";
const NWC_URI_SCHEME = "nostr+walletconnect://";
function isFunction(value) {
return typeof value === "function";
}
export default class NwcSettingsService {
constructor({
normalizeHexPubkey,
getActivePubkey,
payments = null,
logger = console,
notifyError,
maxWalletDefaultZap = MAX_WALLET_DEFAULT_ZAP,
loadSettings = loadNwcSettings,
saveSettings = saveNwcSettings,
clearSettings = clearNwcSettings,
createDefaultSettings = createDefaultNwcSettings,
} = {}) {
this.normalizeHexPubkey = isFunction(normalizeHexPubkey)
? normalizeHexPubkey
: (value) => (typeof value === "string" ? value.trim() : null);
this.getActivePubkey = isFunction(getActivePubkey)
? getActivePubkey
: () => null;
this.payments = payments || null;
this.logger = logger || console;
this.notifyError = isFunction(notifyError) ? notifyError : null;
this.maxWalletDefaultZap =
Number.isFinite(maxWalletDefaultZap) && maxWalletDefaultZap > 0
? maxWalletDefaultZap
: MAX_WALLET_DEFAULT_ZAP;
this.loadSettings = isFunction(loadSettings)
? loadSettings
: loadNwcSettings;
this.saveSettings = isFunction(saveSettings)
? saveSettings
: saveNwcSettings;
this.clearSettings = isFunction(clearSettings)
? clearSettings
: clearNwcSettings;
this.createDefaultSettings = isFunction(createDefaultSettings)
? createDefaultSettings
: () => createDefaultNwcSettings();
this.cache = new Map();
}
cloneSettings(settings) {
if (!settings || typeof settings !== "object") {
return this.createDefaultNwcSettings();
}
return { ...settings };
}
setPayments(payments) {
this.payments = payments || null;
}
setActivePubkeyGetter(fn) {
if (isFunction(fn)) {
this.getActivePubkey = fn;
}
}
clearCache() {
this.cache.clear();
}
getNormalizedPubkey(pubkey) {
try {
return this.normalizeHexPubkey(pubkey);
} catch (error) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn("[nwcSettings] Failed to normalize pubkey:", error);
}
return null;
}
}
async hydrateNwcSettingsForPubkey(pubkey) {
const normalized = this.getNormalizedPubkey(pubkey);
if (!normalized) {
return this.createDefaultNwcSettings();
}
try {
const settings = await this.loadSettings(normalized);
const record =
settings && typeof settings === "object"
? { ...settings }
: this.createDefaultNwcSettings();
this.cache.set(normalized, record);
return this.cloneSettings(record);
} catch (error) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
`[nwcSettings] Failed to load settings for ${normalized}:`,
error,
);
}
const fallback = this.createDefaultNwcSettings();
this.cache.set(normalized, fallback);
return this.cloneSettings(fallback);
}
}
getActiveNwcSettings() {
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
if (!normalized) {
return this.createDefaultNwcSettings();
}
const cached = this.cache.get(normalized);
return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings();
}
createDefaultNwcSettings() {
return this.createDefaultSettings();
}
hasActiveWalletConnection() {
const settings = this.getActiveNwcSettings();
const candidate =
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
return candidate.length > 0;
}
validateWalletUri(uri, { requireValue = false } = {}) {
const value = typeof uri === "string" ? uri.trim() : "";
if (!value) {
if (requireValue) {
return {
valid: false,
sanitized: "",
message: "Enter a wallet connect URI before continuing.",
};
}
return { valid: true, sanitized: "" };
}
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
return {
valid: false,
sanitized: value,
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
};
}
return { valid: true, sanitized: value };
}
async updateActiveNwcSettings(partial = {}) {
const normalized = this.getNormalizedPubkey(this.getActivePubkey());
if (!normalized) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
"[nwcSettings] Cannot update settings without an active pubkey.",
);
}
return this.createDefaultNwcSettings();
}
try {
const updated = await this.saveSettings(normalized, partial);
const record =
updated && typeof updated === "object"
? { ...updated }
: this.createDefaultNwcSettings();
this.cache.set(normalized, record);
return this.cloneSettings(record);
} catch (error) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
`[nwcSettings] Failed to save settings for ${normalized}:`,
error,
);
}
return this.getActiveNwcSettings();
}
}
async handleProfileWalletPersist({
nwcUri,
defaultZap,
lastChecked,
} = {}) {
const partial = {};
if (nwcUri !== undefined) {
partial.nwcUri = nwcUri;
}
if (defaultZap !== undefined) {
partial.defaultZap = defaultZap;
}
if (lastChecked !== undefined) {
partial.lastChecked = lastChecked;
}
if (!Object.keys(partial).length) {
return this.getActiveNwcSettings();
}
return this.updateActiveNwcSettings(partial);
}
async ensureWallet({ nwcUri, defaultZap } = {}) {
const activeSettings = this.getActiveNwcSettings();
const candidateUri =
typeof nwcUri === "string" && nwcUri.trim()
? nwcUri.trim()
: activeSettings.nwcUri || "";
const { valid, sanitized, message } = this.validateWalletUri(candidateUri, {
requireValue: true,
});
if (!valid) {
throw new Error(message || "Invalid wallet URI provided.");
}
const merged = {
...activeSettings,
nwcUri: sanitized,
};
if (typeof defaultZap === "number" && Number.isFinite(defaultZap)) {
const rounded = Math.max(0, Math.round(defaultZap));
merged.defaultZap = Math.min(this.maxWalletDefaultZap, rounded);
} else if (defaultZap === null) {
merged.defaultZap = null;
}
if (this.payments && isFunction(this.payments.ensureWallet)) {
return this.payments.ensureWallet({ settings: merged });
}
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
"[wallet] Falling back to stub ensureWallet implementation. Returning settings without performing a connection test.",
);
}
return merged;
}
async clearStoredNwcSettings(pubkey, { silent = false } = {}) {
const normalized = this.getNormalizedPubkey(pubkey);
if (!normalized) {
return false;
}
try {
await this.clearSettings(normalized);
} catch (error) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
`[nwcSettings] Failed to clear settings for ${normalized}:`,
error,
);
}
if (!silent && this.notifyError) {
this.notifyError("Failed to clear wallet settings for this account.");
}
try {
await this.saveSettings(normalized, this.createDefaultNwcSettings());
} catch (persistError) {
if (this.logger && isFunction(this.logger.warn)) {
this.logger.warn(
`[nwcSettings] Failed to overwrite settings for ${normalized}:`,
persistError,
);
}
}
this.cache.delete(normalized);
return false;
}
this.cache.delete(normalized);
return true;
}
async onLogin({ pubkey, previousPubkey, identityChanged } = {}) {
if (identityChanged) {
this.onIdentityChanged();
}
const normalizedActive = this.getNormalizedPubkey(
pubkey !== undefined ? pubkey : this.getActivePubkey(),
);
const normalizedPrevious = this.getNormalizedPubkey(previousPubkey);
if (
normalizedPrevious &&
(!normalizedActive || normalizedPrevious !== normalizedActive)
) {
await this.clearStoredNwcSettings(normalizedPrevious, { silent: true });
}
if (normalizedActive) {
await this.hydrateNwcSettingsForPubkey(normalizedActive);
}
return this.getActiveNwcSettings();
}
async onLogout({ pubkey, previousPubkey } = {}) {
const normalizedPrevious = this.getNormalizedPubkey(
previousPubkey !== undefined ? previousPubkey : pubkey,
);
if (normalizedPrevious) {
await this.clearStoredNwcSettings(normalizedPrevious, { silent: false });
}
this.clearCache();
return this.createDefaultNwcSettings();
}
onIdentityChanged() {
this.clearCache();
}
}
+768
View File
@@ -0,0 +1,768 @@
// js/services/playbackService.js
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
this.listeners = new Map();
}
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
const handlers = this.listeners.get(eventName);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(eventName);
}
};
}
emit(eventName, detail) {
const handlers = this.listeners.get(eventName);
if (!handlers || !handlers.size) {
return;
}
for (const handler of Array.from(handlers)) {
try {
handler(detail);
} catch (err) {
if (this.logger) {
this.logger("[PlaybackService] Listener error", err);
}
}
}
}
}
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
const DEFAULT_UNSUPPORTED_BTITH_MESSAGE =
"This magnet link is missing a compatible BitTorrent v1 info hash.";
export class PlaybackService {
constructor({
logger,
torrentClient,
deriveTorrentPlaybackConfig,
isValidMagnetUri,
urlFirstEnabled = true,
analyticsCallbacks = {},
} = {}) {
if (typeof logger === "function") {
this.logger = logger;
} else if (logger && typeof logger.log === "function") {
this.logger = (...args) => logger.log(...args);
} else {
this.logger = () => {};
}
this.torrentClient = torrentClient;
this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig;
this.isValidMagnetUri = isValidMagnetUri;
this.urlFirstEnabled = !!urlFirstEnabled;
this.analyticsCallbacks = analyticsCallbacks || {};
this.currentSession = null;
}
log(...args) {
try {
this.logger(...args);
} catch (err) {
console.warn("[PlaybackService] logger threw", err);
}
}
handleAnalyticsEvent(eventName, detail) {
const callbacks = this.analyticsCallbacks || {};
if (typeof callbacks.onEvent === "function") {
try {
callbacks.onEvent(eventName, detail);
} catch (err) {
this.log("[PlaybackService] onEvent callback threw", err);
}
}
const specific = callbacks[eventName];
if (typeof specific === "function") {
try {
specific(detail);
} catch (err) {
this.log(`[PlaybackService] ${eventName} callback threw`, err);
}
}
}
prepareVideoElement(videoElement) {
if (!videoElement) {
return;
}
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
videoElement.muted = !userWantsUnmuted;
if (!videoElement.dataset.autoplayBound) {
videoElement.addEventListener("volumechange", () => {
localStorage.setItem(
"unmutedAutoplay",
(!videoElement.muted).toString()
);
});
videoElement.dataset.autoplayBound = "true";
}
}
registerUrlPlaybackWatchdogs(
videoElement,
{ stallMs = 8000, onSuccess, onFallback } = {}
) {
if (!videoElement || typeof onFallback !== "function") {
return () => {};
}
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
this.log(
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
);
let active = true;
let stallTimerId = null;
const listeners = [];
const cleanup = () => {
if (!active) {
return;
}
active = false;
if (stallTimerId) {
clearTimeout(stallTimerId);
stallTimerId = null;
}
for (const [eventName, handler] of listeners) {
videoElement.removeEventListener(eventName, handler);
}
};
const triggerFallback = (reason) => {
if (!active) {
return;
}
this.log(
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
);
cleanup();
onFallback(reason);
};
const handleSuccess = () => {
if (!active) {
return;
}
this.log(
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
);
cleanup();
if (typeof onSuccess === "function") {
onSuccess();
}
};
const resetTimer = () => {
if (!active || !normalizedStallMs) {
return;
}
if (stallTimerId) {
clearTimeout(stallTimerId);
}
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
};
const addListener = (eventName, handler) => {
videoElement.addEventListener(eventName, handler);
listeners.push([eventName, handler]);
};
addListener("error", () => triggerFallback("error"));
addListener("abort", () => triggerFallback("abort"));
addListener("stalled", () => triggerFallback("stalled"));
addListener("playing", handleSuccess);
addListener("ended", handleSuccess);
const timerEvents = [
"timeupdate",
"progress",
"loadeddata",
"canplay",
"canplaythrough",
"suspend",
"waiting",
];
for (const eventName of timerEvents) {
addListener(eventName, resetTimer);
}
if (normalizedStallMs) {
resetTimer();
}
return () => {
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
cleanup();
};
}
cleanupWatchdog() {
if (this.currentSession) {
this.currentSession.cleanupWatchdog();
}
}
createSession(options = {}) {
const session = new PlaybackSession(this, options);
this.currentSession = session;
return session;
}
}
class PlaybackSession extends SimpleEventEmitter {
constructor(service, options = {}) {
super((message, ...args) => service.log(message, ...args));
this.service = service;
this.options = options;
this.watchdogCleanup = null;
this.result = { source: null };
this.finished = false;
this.startPromise = null;
this.sanitizedUrl =
typeof options.url === "string" ? options.url.trim() : "";
this.trimmedMagnet =
typeof options.magnet === "string" ? options.magnet.trim() : "";
this.requestSignature =
typeof options.requestSignature === "string"
? options.requestSignature
: JSON.stringify({
url: this.sanitizedUrl,
magnet: this.trimmedMagnet,
});
if (typeof service.deriveTorrentPlaybackConfig === "function") {
this.playbackConfig = service.deriveTorrentPlaybackConfig({
magnet: this.trimmedMagnet,
url: this.sanitizedUrl,
logger: (message) => service.log(message),
appProtocol: options.appProtocol,
});
} else {
this.playbackConfig = {
magnet: this.trimmedMagnet,
fallbackMagnet: "",
provided: !!this.trimmedMagnet,
usedInfoHash: false,
originalInput: this.trimmedMagnet,
didMutate: false,
infoHash: "",
};
}
const magnetIsUsable =
typeof service.isValidMagnetUri === "function"
? service.isValidMagnetUri(this.playbackConfig.magnet)
: false;
this.magnetForPlayback = magnetIsUsable
? this.playbackConfig.magnet
: "";
this.fallbackMagnet = magnetIsUsable
? this.playbackConfig.fallbackMagnet
: "";
this.magnetProvided = !!this.playbackConfig.provided;
}
isActive() {
return !this.finished;
}
matchesRequestSignature(signature) {
if (!this.isActive()) {
return false;
}
if (typeof signature !== "string" || !signature) {
return false;
}
return signature === this.requestSignature;
}
getResult() {
return this.result;
}
getPlaybackConfig() {
return this.playbackConfig;
}
getMagnetForPlayback() {
return this.magnetForPlayback;
}
getFallbackMagnet() {
return this.fallbackMagnet;
}
getMagnetProvided() {
return this.magnetProvided;
}
cleanupWatchdog() {
if (typeof this.watchdogCleanup === "function") {
try {
this.watchdogCleanup();
} catch (err) {
this.service.log("[PlaybackSession] Watchdog cleanup threw", err);
} finally {
this.watchdogCleanup = null;
}
}
}
registerWatchdogs(videoElement, options) {
this.cleanupWatchdog();
this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs(
videoElement,
options
);
}
attachDebugListeners(videoElement) {
if (!videoElement) {
return () => {};
}
const debugEvents = [
"loadedmetadata",
"loadeddata",
"canplay",
"canplaythrough",
"play",
"playing",
"pause",
"stalled",
"suspend",
"waiting",
"ended",
"error",
];
const handlers = [];
for (const eventName of debugEvents) {
const handler = () => {
const { readyState, networkState, currentTime, paused, error } =
videoElement;
let suffix =
`readyState=${readyState} networkState=${networkState} currentTime=${
Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime
} paused=${paused}`;
if (eventName === "error" && error) {
const errorMessage = error.message || "";
suffix += ` code=${error.code || ""} message=${errorMessage}`;
}
this.service.log(
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
);
};
videoElement.addEventListener(eventName, handler);
handlers.push([eventName, handler]);
}
return () => {
if (!handlers.length) {
return;
}
for (const [eventName, handler] of handlers) {
videoElement.removeEventListener(eventName, handler);
}
handlers.length = 0;
};
}
async start() {
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = this.execute();
return this.startPromise;
}
async execute() {
const {
videoElement,
waitForCleanup,
cancelPendingViewLogging,
clearActiveIntervals,
showModalWithPoster,
teardownVideoElement,
probeUrl,
playViaWebTorrent,
autoplay,
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
} = this.options;
this.result = { source: null };
this.finished = false;
const detail = {
urlProvided: !!this.sanitizedUrl,
magnetProvided: this.magnetProvided,
magnetUsable: !!this.magnetForPlayback,
};
this.service.handleAnalyticsEvent("session-start", detail);
this.emit("session-start", detail);
try {
if (typeof waitForCleanup === "function") {
await waitForCleanup();
}
if (typeof cancelPendingViewLogging === "function") {
cancelPendingViewLogging();
}
if (!videoElement) {
throw new Error("Video element is not ready for playback.");
}
if (typeof showModalWithPoster === "function") {
await Promise.resolve(showModalWithPoster());
}
this.emit("status", { message: "Preparing video..." });
if (typeof clearActiveIntervals === "function") {
clearActiveIntervals();
}
this.service.prepareVideoElement(videoElement);
if (
this.service.torrentClient &&
typeof this.service.torrentClient.cleanup === "function"
) {
await this.service.torrentClient.cleanup();
}
const activeVideoEl =
typeof teardownVideoElement === "function"
? teardownVideoElement(videoElement) || videoElement
: videoElement;
if (!activeVideoEl) {
throw new Error("Video element is not ready for playback.");
}
this.options.videoElement = activeVideoEl;
this.emit("video-prepared", { videoElement: activeVideoEl });
this.emit("view-logging-request", { videoElement: activeVideoEl });
const cleanupDebugListeners = this.attachDebugListeners(activeVideoEl);
let cleanupHostedUrlStatusListeners = () => {};
const httpsUrl = this.sanitizedUrl;
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
let fallbackStarted = false;
const startTorrentFallback = async (reason) => {
if (fallbackStarted) {
this.service.log(
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
);
return null;
}
fallbackStarted = true;
this.cleanupWatchdog();
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
if (activeVideoEl) {
try {
activeVideoEl.pause();
} catch (err) {
this.service.log(
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
err
);
}
try {
activeVideoEl.removeAttribute("src");
} catch (err) {
// ignore attribute removal errors
}
activeVideoEl.src = "";
activeVideoEl.srcObject = null;
try {
activeVideoEl.load();
} catch (err) {
this.service.log(
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
err
);
}
}
if (!this.magnetForPlayback) {
const message =
"Hosted playback failed and no magnet fallback is available.";
this.emit("status", { message });
this.emit("sourcechange", { source: null });
const error = new Error(message);
this.service.handleAnalyticsEvent("error", { error });
this.emit("error", { error, message });
this.result = { source: null, error };
return null;
}
this.emit("status", { message: "Switching to WebTorrent..." });
this.service.handleAnalyticsEvent("fallback", { reason });
this.emit("fallback", { reason });
if (typeof playViaWebTorrent !== "function") {
const error = new Error("No torrent playback handler provided.");
this.service.handleAnalyticsEvent("error", { error });
this.emit("error", { error, message: error.message });
this.result = { source: null, error };
return null;
}
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
});
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
this.emit("sourcechange", { source: "torrent" });
if (typeof autoplay === "function") {
autoplay();
}
this.result = { source: "torrent", torrentInstance };
return torrentInstance;
};
if (this.service.urlFirstEnabled && httpsUrl) {
this.emit("status", { message: "Checking hosted URL..." });
this.service.log(
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
);
let hostedStatusResolved = false;
const hostedStatusHandlers = [];
const addHostedStatusListener = (eventName, handler, options) => {
if (!activeVideoEl) {
return;
}
activeVideoEl.addEventListener(eventName, handler, options);
hostedStatusHandlers.push([eventName, handler, options]);
};
const markHostedUrlAsLive = () => {
if (hostedStatusResolved) {
return;
}
hostedStatusResolved = true;
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
cleanupHostedUrlStatusListeners();
};
const maybeMarkHostedUrl = () => {
if (
hostedStatusResolved ||
!activeVideoEl ||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
) {
return;
}
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
markHostedUrlAsLive();
}
};
cleanupHostedUrlStatusListeners = () => {
if (!hostedStatusHandlers.length || !activeVideoEl) {
hostedStatusHandlers.length = 0;
cleanupHostedUrlStatusListeners = () => {};
return;
}
for (const [eventName, handler, options] of hostedStatusHandlers) {
activeVideoEl.removeEventListener(eventName, handler, options);
}
hostedStatusHandlers.length = 0;
cleanupHostedUrlStatusListeners = () => {};
};
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
addHostedStatusListener("canplay", maybeMarkHostedUrl);
addHostedStatusListener(
"error",
() => {
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
},
{ once: true }
);
const probeResult =
typeof probeUrl === "function" ? await probeUrl(httpsUrl) : null;
const probeOutcome = probeResult?.outcome || "error";
const shouldAttemptHosted =
probeOutcome !== "bad" && probeOutcome !== "error";
this.service.log(
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
);
if (shouldAttemptHosted) {
let outcomeResolved = false;
let outcomeResolver = () => {};
let autoplayBlocked = false;
const playbackOutcomePromise = new Promise((resolve) => {
outcomeResolver = (value) => {
if (outcomeResolved) {
return;
}
outcomeResolved = true;
resolve(value);
};
});
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, {
stallMs,
onSuccess: () => outcomeResolver({ status: "success" }),
onFallback: (reason) => {
if (autoplayBlocked && reason === "stall") {
this.service.log(
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
);
this.emit("status", {
message: "Press play to start the hosted video.",
});
this.emit("autoplay-blocked", { reason });
attachWatchdogs({ stallMs: 0 });
return;
}
outcomeResolver({ status: "fallback", reason });
},
});
};
attachWatchdogs({ stallMs: 8000 });
const handleFatalPlaybackError = (err) => {
this.service.log(
"[playVideoWithFallback] Direct URL playback threw:",
err
);
outcomeResolver({ status: "fallback", reason: "play-error" });
};
try {
activeVideoEl.src = httpsUrl;
const playPromise = activeVideoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => {
if (err?.name === "NotAllowedError") {
autoplayBlocked = true;
this.service.log(
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
err
);
this.emit("status", {
message: "Press play to start the hosted video.",
});
this.cleanupWatchdog();
attachWatchdogs({ stallMs: 0 });
const restoreOnPlay = () => {
activeVideoEl.removeEventListener("play", restoreOnPlay);
this.cleanupWatchdog();
autoplayBlocked = false;
attachWatchdogs({ stallMs: 8000 });
};
activeVideoEl.addEventListener("play", restoreOnPlay, {
once: true,
});
return;
}
handleFatalPlaybackError(err);
});
}
} catch (err) {
handleFatalPlaybackError(err);
}
const playbackOutcome = await playbackOutcomePromise;
if (playbackOutcome?.status === "success") {
this.emit("poster-remove", { reason: "http-success" });
this.service.handleAnalyticsEvent("sourcechange", {
source: "url",
});
this.emit("sourcechange", { source: "url" });
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
this.result = { source: "url" };
return this.result;
}
const fallbackReason =
playbackOutcome?.reason || "watchdog-triggered";
const torrentInstance = await startTorrentFallback(fallbackReason);
this.result = torrentInstance
? { source: "torrent", torrentInstance }
: this.result;
return this.result;
}
this.service.log(
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
);
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
}
if (this.magnetForPlayback) {
const torrentInstance = await startTorrentFallback("magnet-primary");
this.result = torrentInstance
? { source: "torrent", torrentInstance }
: this.result;
return this.result;
}
const message = this.magnetProvided && !this.magnetForPlayback
? unsupportedBtihMessage
: "No playable source found.";
this.emit("status", { message });
this.service.handleAnalyticsEvent("sourcechange", { source: null });
this.emit("sourcechange", { source: null });
cleanupDebugListeners();
const error = new Error(message);
this.service.handleAnalyticsEvent("error", { error });
this.emit("error", { error, message });
this.result = { source: null, error };
return this.result;
} catch (error) {
this.service.log("Error in playVideoWithFallback:", error);
const message = error && error.message
? `Playback error: ${error.message}`
: "Playback error";
this.service.handleAnalyticsEvent("error", { error });
this.emit("error", { error, message });
this.result = { source: null, error };
return this.result;
} finally {
this.cleanupWatchdog();
this.finished = true;
if (this.result) {
this.emit("finished", this.result);
}
}
}
}
export default PlaybackService;
+821
View File
@@ -0,0 +1,821 @@
import {
loadR2Settings,
saveR2Settings,
clearR2Settings,
buildR2Key,
buildPublicUrl,
mergeBucketEntry,
sanitizeBaseDomain,
} from "../r2.js";
import {
sanitizeBucketName,
ensureBucket,
putCors,
attachCustomDomainAndWait,
setManagedDomain,
deriveShortSubdomain,
} from "../storage/r2-mgmt.js";
import {
makeR2Client,
multipartUpload,
ensureBucketCors,
} from "../storage/r2-s3.js";
import { truncateMiddle } from "../utils/formatters.js";
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
function createDefaultSettings() {
return {
accountId: "",
accessKeyId: "",
secretAccessKey: "",
apiToken: "",
zoneId: "",
baseDomain: "",
buckets: {},
};
}
class R2Service {
constructor() {
this.listeners = new Map();
this.cloudflareSettings = null;
this.cloudflareAdvancedVisible = false;
}
on(event, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const handlers = this.listeners.get(event);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(event);
}
};
}
emit(event, detail) {
const handlers = this.listeners.get(event);
if (!handlers) {
return;
}
for (const handler of handlers) {
try {
handler(detail);
} catch (err) {
console.error("[r2Service] Listener error for", event, err);
}
}
}
getSettings() {
return this.cloudflareSettings || createDefaultSettings();
}
setSettings(settings) {
this.cloudflareSettings = settings ? { ...settings } : null;
this.emit("settingsChanged", { settings: this.getSettings() });
}
getCloudflareAdvancedVisibility() {
return Boolean(this.cloudflareAdvancedVisible);
}
setCloudflareAdvancedVisibility(visible) {
const isVisible = Boolean(visible);
if (this.cloudflareAdvancedVisible === isVisible) {
return;
}
this.cloudflareAdvancedVisible = isVisible;
this.emit("advancedVisibilityChange", { visible: isVisible });
}
normalizeStatusVariant(variant) {
if (STATUS_VARIANTS.has(variant)) {
return variant;
}
return "info";
}
setCloudflareSettingsStatus(message = "", variant = "info") {
this.emit("settingsStatus", {
message: message || "",
variant: this.normalizeStatusVariant(variant),
});
}
setCloudflareUploadStatus(message = "", variant = "info") {
this.emit("uploadStatus", {
message: message || "",
variant: this.normalizeStatusVariant(variant),
});
}
setCloudflareUploading(isUploading) {
this.emit("uploadStateChange", { isUploading: Boolean(isUploading) });
}
updateCloudflareProgress(fraction) {
this.emit("uploadProgress", { fraction });
}
buildNip71MetadataForUpload(existingMetadata, { publicUrl = "", file = null } = {}) {
const base =
existingMetadata && typeof existingMetadata === "object"
? { ...existingMetadata }
: {};
const cloneImetaVariant = (variant) => {
if (!variant || typeof variant !== "object") {
return {
m: "",
dim: "",
url: "",
x: "",
image: [],
fallback: [],
service: [],
autoGenerated: true,
};
}
return {
...variant,
image: Array.isArray(variant.image) ? [...variant.image] : [],
fallback: Array.isArray(variant.fallback) ? [...variant.fallback] : [],
service: Array.isArray(variant.service) ? [...variant.service] : [],
};
};
const imetaList = Array.isArray(base.imeta)
? base.imeta.map((variant) => cloneImetaVariant(variant))
: [];
if (!imetaList.length) {
imetaList.push({
m: "",
dim: "",
url: "",
x: "",
image: [],
fallback: [],
service: [],
autoGenerated: true,
});
} else {
const primary = imetaList[0];
if (!Array.isArray(primary.image)) primary.image = [];
if (!Array.isArray(primary.fallback)) primary.fallback = [];
if (!Array.isArray(primary.service)) primary.service = [];
}
const primaryVariant = imetaList[0];
const normalizedUrl =
typeof publicUrl === "string" ? publicUrl.trim() : String(publicUrl || "").trim();
if (normalizedUrl && (!primaryVariant.url || !String(primaryVariant.url).trim())) {
primaryVariant.url = normalizedUrl;
}
const mimeType =
file && typeof file.type === "string" ? file.type.trim() : "";
if (mimeType && (!primaryVariant.m || !String(primaryVariant.m).trim())) {
primaryVariant.m = mimeType;
}
base.imeta = imetaList;
return base;
}
populateCloudflareSettingsInputs(settings) {
const data = {
accountId: settings?.accountId || "",
accessKeyId: settings?.accessKeyId || "",
secretAccessKey: settings?.secretAccessKey || "",
apiToken: settings?.apiToken || "",
zoneId: settings?.zoneId || "",
baseDomain: settings?.baseDomain || "",
};
const hasAdvancedValues = Boolean(
(data.apiToken && data.apiToken.length > 0) ||
(data.zoneId && data.zoneId.length > 0) ||
(data.baseDomain && data.baseDomain.length > 0)
);
if (hasAdvancedValues) {
this.setCloudflareAdvancedVisibility(true);
} else if (!this.cloudflareAdvancedVisible) {
this.setCloudflareAdvancedVisibility(false);
}
this.setCloudflareSettingsStatus("");
this.emit("settingsPopulated", { settings: data });
}
async loadSettings() {
try {
const settings = await loadR2Settings();
this.setSettings(settings);
this.populateCloudflareSettingsInputs(settings);
return settings;
} catch (err) {
console.error("Failed to load Cloudflare settings:", err);
this.setSettings(createDefaultSettings());
this.populateCloudflareSettingsInputs(this.getSettings());
this.setCloudflareSettingsStatus(
"Failed to load saved settings.",
"error"
);
throw err;
}
}
async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) {
const accountId = String(formValues.accountId || "").trim();
const accessKeyId = String(formValues.accessKeyId || "").trim();
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
const apiToken = String(formValues.apiToken || "").trim();
const zoneId = String(formValues.zoneId || "").trim();
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || "");
if (!accountId || !accessKeyId || !secretAccessKey) {
if (!quiet) {
this.setCloudflareSettingsStatus(
"Account ID, Access Key ID, and Secret are required.",
"error"
);
}
return false;
}
let buckets = { ...(this.getSettings().buckets || {}) };
const previousAccount = this.getSettings().accountId || "";
const previousBaseDomain = this.getSettings().baseDomain || "";
const previousZoneId = this.getSettings().zoneId || "";
if (
previousAccount !== accountId ||
previousBaseDomain !== baseDomain ||
previousZoneId !== zoneId
) {
buckets = {};
}
const updatedSettings = {
accountId,
accessKeyId,
secretAccessKey,
apiToken,
zoneId,
baseDomain,
buckets,
};
try {
const saved = await saveR2Settings(updatedSettings);
this.setSettings(saved);
this.populateCloudflareSettingsInputs(saved);
if (!quiet) {
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
}
return true;
} catch (err) {
console.error("Failed to save Cloudflare settings:", err);
if (!quiet) {
this.setCloudflareSettingsStatus(
"Failed to save settings. Check console for details.",
"error"
);
}
return false;
}
}
async saveSettings(formValues = {}, options = {}) {
return this.handleCloudflareSettingsSubmit(formValues, options);
}
async handleCloudflareClearSettings() {
try {
await clearR2Settings();
const refreshed = await loadR2Settings();
this.setSettings(refreshed);
this.populateCloudflareSettingsInputs(refreshed);
this.setCloudflareAdvancedVisibility(false);
this.setCloudflareSettingsStatus("Settings cleared.", "success");
return true;
} catch (err) {
console.error("Failed to clear Cloudflare settings:", err);
this.setCloudflareSettingsStatus("Failed to clear settings.", "error");
return false;
}
}
async clearSettings() {
return this.handleCloudflareClearSettings();
}
getCorsOrigins() {
const origins = new Set();
if (typeof window !== "undefined" && window.location) {
const origin = window.location.origin;
if (origin && origin !== "null") {
origins.add(origin);
}
if (origin && origin.startsWith("http://localhost")) {
origins.add(origin.replace("http://", "https://"));
}
}
return Array.from(origins);
}
deriveSubdomainForNpub(npub) {
try {
return deriveShortSubdomain(npub);
} catch (err) {
console.warn("Failed to derive short subdomain, falling back:", err);
}
const base = String(npub || "user")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
.replace(/^-+|[-]+$/g, "");
return base.slice(0, 32) || "user";
}
async ensureBucketConfigForNpub(npub) {
if (!npub || !this.cloudflareSettings) {
return null;
}
const accountId = (this.cloudflareSettings.accountId || "").trim();
const apiToken = (this.cloudflareSettings.apiToken || "").trim();
const zoneId = (this.cloudflareSettings.zoneId || "").trim();
const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim();
const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim();
const corsOrigins = this.getCorsOrigins();
const baseDomain = this.cloudflareSettings.baseDomain || "";
if (!accountId) {
throw new Error("Cloudflare account ID is missing.");
}
let entry = this.cloudflareSettings.buckets?.[npub] || null;
if (entry && entry.publicBaseUrl) {
if (apiToken) {
try {
await ensureBucket({
accountId,
bucket: entry.bucket,
token: apiToken,
});
await putCors({
accountId,
bucket: entry.bucket,
token: apiToken,
origins: corsOrigins,
});
} catch (err) {
console.warn("Failed to refresh bucket configuration:", err);
}
} else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
try {
const s3 = makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
});
await ensureBucketCors({
s3,
bucket: entry.bucket,
origins: corsOrigins,
});
} catch (err) {
console.warn("Failed to refresh bucket CORS via access keys:", err);
}
}
return {
entry,
usedManagedFallback: entry.domainType !== "custom",
customDomainStatus: entry.domainType === "custom" ? "active" : "skipped",
};
}
if (!apiToken) {
const bucketName = entry?.bucket || sanitizeBucketName(npub);
const manualCustomDomain = baseDomain
? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}`
: "";
let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain;
if (!publicBaseUrl) {
publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`;
}
if (!publicBaseUrl) {
throw new Error(
"No public bucket domain configured. Add an API token or configure the domain manually."
);
}
const manualEntry = {
bucket: bucketName,
publicBaseUrl,
domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom",
lastUpdated: Date.now(),
};
if (accessKeyId && secretAccessKey && corsOrigins.length > 0) {
try {
const s3 = makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
});
await ensureBucketCors({
s3,
bucket: bucketName,
origins: corsOrigins,
});
} catch (corsErr) {
console.warn(
"Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.",
corsErr
);
}
}
let savedEntry = entry;
if (
!entry ||
entry.bucket !== manualEntry.bucket ||
entry.publicBaseUrl !== manualEntry.publicBaseUrl ||
entry.domainType !== manualEntry.domainType
) {
const updatedSettings = await saveR2Settings(
mergeBucketEntry(this.getSettings(), npub, manualEntry)
);
this.setSettings(updatedSettings);
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
}
return {
entry: savedEntry,
usedManagedFallback: manualEntry.domainType !== "custom",
customDomainStatus:
manualEntry.domainType === "custom" ? "manual" : "managed",
};
}
const bucketName = entry?.bucket || sanitizeBucketName(npub);
await ensureBucket({ accountId, bucket: bucketName, token: apiToken });
try {
await putCors({
accountId,
bucket: bucketName,
token: apiToken,
origins: corsOrigins,
});
} catch (err) {
console.warn("Failed to apply R2 CORS rules:", err);
}
let publicBaseUrl = entry?.publicBaseUrl || "";
let domainType = entry?.domainType || "managed";
let usedManagedFallback = false;
let customDomainStatus = "skipped";
if (baseDomain && zoneId) {
const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`;
try {
const custom = await attachCustomDomainAndWait({
accountId,
bucket: bucketName,
token: apiToken,
zoneId,
domain,
pollInterval: 2500,
timeoutMs: 120000,
});
customDomainStatus = custom?.status || "unknown";
if (custom?.active && custom?.url) {
publicBaseUrl = custom.url;
domainType = "custom";
try {
await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
enabled: false,
});
} catch (disableErr) {
console.warn("Failed to disable managed domain:", disableErr);
}
} else {
usedManagedFallback = true;
}
} catch (err) {
if (/already exists/i.test(err.message || "")) {
publicBaseUrl = `https://${domain}`;
domainType = "custom";
customDomainStatus = "active";
try {
await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
enabled: false,
});
} catch (disableErr) {
console.warn("Failed to disable managed domain:", disableErr);
}
} else {
console.warn("Failed to attach custom domain, falling back:", err);
usedManagedFallback = true;
customDomainStatus = "error";
}
}
}
if (!publicBaseUrl) {
const managed = await setManagedDomain({
accountId,
bucket: bucketName,
token: apiToken,
enabled: true,
});
publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`;
domainType = "managed";
usedManagedFallback = true;
customDomainStatus =
customDomainStatus === "skipped" ? "managed" : customDomainStatus;
}
const mergedEntry = {
bucket: bucketName,
publicBaseUrl,
domainType,
lastUpdated: Date.now(),
};
const updatedSettings = await saveR2Settings(
mergeBucketEntry(this.getSettings(), npub, mergedEntry)
);
this.setSettings(updatedSettings);
return { entry: mergedEntry, usedManagedFallback, customDomainStatus };
}
async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) {
if (!this.cloudflareSettings) {
const detail = {
text: "Save your credentials to configure R2.",
title: "",
};
this.emit("bucketPreview", detail);
return detail;
}
if (!hasPubkey) {
const detail = { text: "Login to preview your R2 bucket.", title: "" };
this.emit("bucketPreview", detail);
return detail;
}
if (!npub) {
const detail = { text: "Unable to encode npub.", title: "" };
this.emit("bucketPreview", detail);
return detail;
}
const entry = this.cloudflareSettings.buckets?.[npub];
if (!entry || !entry.publicBaseUrl) {
const detail = {
text: "Bucket will be auto-created on your next upload.",
title: "",
};
this.emit("bucketPreview", detail);
return detail;
}
const sampleKey = buildR2Key(npub, { name: "sample.mp4" });
const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey);
const fullPreview = `${entry.bucket} • ${publicUrl}`;
let displayHostAndPath = truncateMiddle(publicUrl, 72);
try {
const parsed = new URL(publicUrl);
const cleanPath = parsed.pathname.replace(/^\//, "");
const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32);
displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`;
} catch (err) {
// ignore URL parse issues and fall back to the raw string
}
const truncatedBucket = truncateMiddle(entry.bucket, 28);
const detail = {
text: `${truncatedBucket} • ${displayHostAndPath}`,
title: fullPreview,
};
this.emit("bucketPreview", detail);
return detail;
}
async handleCloudflareUploadSubmit({
npub = "",
file = null,
metadata = {},
settingsInput = null,
publishVideoNote,
onReset,
} = {}) {
if (settingsInput) {
const saved = await this.handleCloudflareSettingsSubmit(settingsInput, {
quiet: true,
});
if (!saved) {
this.setCloudflareUploadStatus(
"Fix your R2 settings before uploading.",
"error"
);
return false;
}
}
if (!npub) {
this.setCloudflareUploadStatus("Unable to encode npub.", "error");
return false;
}
const title = String(metadata.title || "").trim();
if (!title) {
this.setCloudflareUploadStatus("Title is required.", "error");
return false;
}
if (!file) {
this.setCloudflareUploadStatus(
"Select a video or HLS file to upload.",
"error"
);
return false;
}
const description = String(metadata.description || "").trim();
const thumbnail = String(metadata.thumbnail || "").trim();
const magnet = String(metadata.magnet || "").trim();
const ws = String(metadata.ws || "").trim();
const xs = String(metadata.xs || "").trim();
const enableComments = metadata.enableComments !== false;
const accountId = (this.cloudflareSettings?.accountId || "").trim();
const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim();
const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim();
if (!accountId || !accessKeyId || !secretAccessKey) {
this.setCloudflareUploadStatus(
"Missing R2 credentials. Save them before uploading.",
"error"
);
return false;
}
this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info");
this.updateCloudflareProgress(0);
this.setCloudflareUploading(true);
let bucketResult = null;
try {
bucketResult = await this.ensureBucketConfigForNpub(npub);
} catch (err) {
console.error("Failed to prepare R2 bucket:", err);
this.setCloudflareUploadStatus(
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.",
"error"
);
this.setCloudflareUploading(false);
this.updateCloudflareProgress(Number.NaN);
return false;
}
const bucketEntry =
bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
if (!bucketEntry || !bucketEntry.publicBaseUrl) {
this.setCloudflareUploadStatus(
"Bucket is missing a public domain. Check your Cloudflare settings.",
"error"
);
this.setCloudflareUploading(false);
this.updateCloudflareProgress(Number.NaN);
return false;
}
let statusMessage = `Uploading to ${bucketEntry.bucket}…`;
if (bucketResult?.usedManagedFallback) {
const baseDomain = this.cloudflareSettings?.baseDomain || "";
if (baseDomain) {
const customStatus = bucketResult?.customDomainStatus
? ` (custom domain status: ${bucketResult.customDomainStatus})`
: "";
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`;
} else {
statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`;
}
}
this.setCloudflareUploadStatus(
statusMessage,
bucketResult?.usedManagedFallback ? "warning" : "info"
);
const key = buildR2Key(npub, file);
const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key);
try {
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
await multipartUpload({
s3,
bucket: bucketEntry.bucket,
key,
file,
contentType: file.type,
onProgress: (fraction) => {
this.updateCloudflareProgress(fraction);
},
});
let publishOutcome = true;
if (typeof publishVideoNote !== "function") {
console.warn(
"publishVideoNote handler missing; skipping publish step."
);
publishOutcome = false;
} else {
const payload = {
title,
url: publicUrl,
magnet,
thumbnail,
description,
ws,
xs,
enableComments,
};
const mergedNip71 = this.buildNip71MetadataForUpload(metadata?.nip71, {
publicUrl,
file,
});
if (mergedNip71 && Object.keys(mergedNip71).length) {
payload.nip71 = mergedNip71;
}
const published = await publishVideoNote(payload, {
onSuccess: () => {
if (typeof onReset === "function") {
onReset();
}
},
});
publishOutcome = Boolean(published);
if (publishOutcome) {
this.setCloudflareUploadStatus(
`Published ${publicUrl}`,
"success"
);
}
}
return publishOutcome;
} catch (err) {
console.error("Cloudflare upload failed:", err);
this.setCloudflareUploadStatus(
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
"error"
);
return false;
} finally {
this.setCloudflareUploading(false);
this.updateCloudflareProgress(Number.NaN);
await this.updateCloudflareBucketPreview({ hasPubkey: true, npub });
}
}
async uploadVideo(params = {}) {
return this.handleCloudflareUploadSubmit(params);
}
}
const r2Service = new R2Service();
export default r2Service;
+599
View File
@@ -0,0 +1,599 @@
import { isDevMode } from "../config.js";
import { pointerArrayToKey } from "../utils/pointer.js";
import {
ingestLocalViewEvent as defaultIngestLocalViewEvent,
} from "../viewCounter.js";
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
function sanitizeVideoMetadata(video) {
if (!video || typeof video !== "object") {
return null;
}
const createdAtCandidates = [
video.rootCreatedAt,
video.created_at,
video.createdAt,
video.publishedAt,
];
let createdAt = null;
for (const candidate of createdAtCandidates) {
if (Number.isFinite(candidate)) {
createdAt = Math.floor(candidate);
break;
}
}
return {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
created_at: createdAt,
url: typeof video.url === "string" ? video.url : "",
magnet: typeof video.magnet === "string" ? video.magnet : "",
infoHash: typeof video.infoHash === "string" ? video.infoHash : "",
legacyInfoHash:
typeof video.legacyInfoHash === "string" ? video.legacyInfoHash : "",
};
}
export default class WatchHistoryTelemetry {
constructor({
watchHistoryService = null,
watchHistoryController = null,
nostrClient = null,
log,
normalizeHexPubkey,
getActiveUserPubkey,
ingestLocalViewEvent = defaultIngestLocalViewEvent,
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
} = {}) {
this.watchHistoryService = watchHistoryService;
this.watchHistoryController = watchHistoryController;
this.nostrClient = nostrClient;
this.log = typeof log === "function" ? log : () => {};
this.normalizeHexPubkey =
typeof normalizeHexPubkey === "function"
? normalizeHexPubkey
: () => null;
this.getActiveUserPubkey =
typeof getActiveUserPubkey === "function" ? getActiveUserPubkey : () => null;
this.ingestLocalViewEvent =
typeof ingestLocalViewEvent === "function"
? ingestLocalViewEvent
: defaultIngestLocalViewEvent;
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
? Math.max(0, viewThresholdSeconds)
: DEFAULT_VIEW_THRESHOLD_SECONDS;
this.watchHistoryMetadataEnabled = null;
this.watchHistoryPreferenceUnsubscribe = null;
this.playbackTelemetryState = null;
this.loggedViewPointerKeys = new Set();
}
destroy() {
this.resetPlaybackLoggingState();
this._clearPreferenceSubscription();
this.watchHistoryMetadataEnabled = null;
}
async initPreferenceSync() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return null;
}
this.refreshPreferenceSettings();
if (
typeof this.watchHistoryService.subscribe === "function" &&
!this.watchHistoryPreferenceUnsubscribe
) {
try {
const unsubscribe = this.watchHistoryService.subscribe(
"metadata-preference",
(payload) => {
const previous = this.watchHistoryMetadataEnabled;
const enabled = payload?.enabled !== false;
this.watchHistoryMetadataEnabled = enabled;
if (previous === true && enabled === false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
error,
);
}
}
}
},
);
if (typeof unsubscribe === "function") {
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to subscribe to metadata preference changes:",
error,
);
}
}
}
return this.watchHistoryPreferenceUnsubscribe || null;
}
refreshPreferenceSettings() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return this.watchHistoryMetadataEnabled;
}
const previous = this.watchHistoryMetadataEnabled;
let enabled = true;
try {
if (typeof this.watchHistoryService.getSettings === "function") {
const settings = this.watchHistoryService.getSettings();
enabled = settings?.metadata?.storeLocally !== false;
} else if (
typeof this.watchHistoryService.shouldStoreMetadata === "function"
) {
enabled = this.watchHistoryService.shouldStoreMetadata() !== false;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to read metadata settings:",
error,
);
}
enabled = true;
}
this.watchHistoryMetadataEnabled = enabled;
if (enabled === false && previous !== false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to purge metadata cache while preference disabled:",
error,
);
}
}
}
return this.watchHistoryMetadataEnabled;
}
isMetadataPreferenceEnabled() {
return this.watchHistoryMetadataEnabled !== false;
}
persistMetadataForVideo(video, pointerInfo) {
if (
!this.watchHistoryMetadataEnabled ||
!pointerInfo ||
!pointerInfo.key ||
typeof this.watchHistoryService?.setLocalMetadata !== "function"
) {
return;
}
if (!video || typeof video !== "object") {
return;
}
const metadata = {
video: sanitizeVideoMetadata(video),
};
try {
this.watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to persist local metadata for pointer:",
pointerInfo.key,
error,
);
}
}
}
dropMetadata(pointerKey) {
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
if (typeof this.watchHistoryService?.removeLocalMetadata !== "function") {
return;
}
try {
this.watchHistoryService.removeLocalMetadata(pointerKey);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to remove cached metadata for pointer:",
pointerKey,
error,
);
}
}
}
async handleRemoval(payload = {}) {
if (!this.watchHistoryController) {
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
}
flush(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryController) {
return Promise.resolve();
}
try {
return this.watchHistoryController.flush(reason, context);
} catch (error) {
return Promise.reject(error);
}
}
cancelPlaybackLogging({
reason = "session-end",
context = "cancelPendingViewLogging",
} = {}) {
const state = this.playbackTelemetryState;
if (!state) {
return;
}
if (state.viewTimerId) {
this._getTimerHost().clearTimeout(state.viewTimerId);
}
if (Array.isArray(state.handlers) && state.videoEl) {
for (const { eventName, handler } of state.handlers) {
try {
state.videoEl.removeEventListener(eventName, handler);
} catch (error) {
if (isDevMode) {
console.warn(
`[watchHistoryTelemetry] Failed to detach ${eventName} listener:`,
error,
);
}
}
}
}
this.playbackTelemetryState = null;
this.flush(reason, context).catch((error) => {
const message = normalizeString(error?.message) || String(error ?? "unknown");
this.log(
`[watchHistoryTelemetry] Watch history flush failed: ${message}`,
);
});
}
resetPlaybackLoggingState() {
this.cancelPlaybackLogging();
if (this.loggedViewPointerKeys.size > 0) {
this.loggedViewPointerKeys.clear();
}
}
preparePlaybackLogging({
videoElement,
pointer,
pointerKey: explicitPointerKey,
video,
} = {}) {
this.cancelPlaybackLogging();
if (!videoElement || typeof videoElement.addEventListener !== "function") {
return;
}
const pointerKey = explicitPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
return;
}
const viewerIdentityKey = this.getActiveViewIdentityKey();
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
return;
}
const state = {
videoEl: videoElement,
pointer,
pointerKey,
viewerIdentityKey,
handlers: [],
viewTimerId: null,
viewFired: false,
videoMetadata: sanitizeVideoMetadata(video),
};
const clearTimer = (timerKey) => {
const property = `${timerKey}TimerId`;
if (state[property]) {
this._getTimerHost().clearTimeout(state[property]);
state[property] = null;
}
};
const finalizeView = () => {
if (state.viewFired) {
return;
}
state.viewFired = true;
clearTimer("view");
this.cancelPlaybackLogging();
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
state;
(async () => {
let viewResult;
try {
const canUseWatchHistoryService =
typeof this.watchHistoryService?.publishView === "function";
const resolveWatchActor = () => {
const normalizedUser = this.normalizeHexPubkey(
this.getActiveUserPubkey(),
);
if (normalizedUser) {
return normalizedUser;
}
const normalizedClient = this.normalizeHexPubkey(
this.nostrClient?.pubkey,
);
if (normalizedClient) {
return normalizedClient;
}
const normalizedSession = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (normalizedSession) {
return normalizedSession;
}
if (normalizeString(this.nostrClient?.pubkey)) {
return normalizeString(this.nostrClient.pubkey).toLowerCase();
}
if (normalizeString(this.nostrClient?.sessionActor?.pubkey)) {
return normalizeString(
this.nostrClient.sessionActor.pubkey,
).toLowerCase();
}
return "";
};
const activeWatchActor = resolveWatchActor();
const watchMetadata = {};
if (activeWatchActor) {
watchMetadata.actor = activeWatchActor;
}
if (state.videoMetadata) {
watchMetadata.video = state.videoMetadata;
}
const metadataPayload = Object.keys(watchMetadata).length
? watchMetadata
: undefined;
if (canUseWatchHistoryService) {
viewResult = await this.watchHistoryService.publishView(
thresholdPointer,
undefined,
metadataPayload,
);
} else if (typeof this.nostrClient?.recordVideoView === "function") {
viewResult = await this.nostrClient.recordVideoView(
thresholdPointer,
);
} else {
viewResult = { ok: false, error: "view-logging-unavailable" };
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Exception while recording video view:",
error,
);
}
}
const viewOk = !!viewResult?.ok;
if (viewOk) {
const eventIdentityKey =
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
state.viewerIdentityKey ||
this.getActiveViewIdentityKey();
const keyToPersist = this.buildViewCooldownKey(
thresholdPointerKey,
eventIdentityKey,
);
if (keyToPersist) {
this.loggedViewPointerKeys.add(keyToPersist);
}
if (viewResult?.event && this.ingestLocalViewEvent) {
try {
this.ingestLocalViewEvent({
event: viewResult.event,
pointer: thresholdPointer,
});
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to ingest local view event:",
error,
);
}
}
}
} else if (isDevMode && viewResult) {
console.warn(
"[watchHistoryTelemetry] View event rejected by relays:",
viewResult,
);
}
})().catch((error) => {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Unexpected error while recording video view:",
error,
);
}
});
};
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
const firedKey = `${timerKey}Fired`;
const idKey = `${timerKey}TimerId`;
if (state[firedKey] || state[idKey]) {
return;
}
const currentSeconds = Number.isFinite(videoElement.currentTime)
? videoElement.currentTime
: 0;
const remainingMs = Math.max(
0,
Math.ceil((thresholdSeconds - currentSeconds) * 1000),
);
if (remainingMs <= 0) {
callback();
return;
}
state[idKey] = this._getTimerHost().setTimeout(callback, remainingMs);
};
const registerHandler = (eventName, handler) => {
videoElement.addEventListener(eventName, handler);
state.handlers.push({ eventName, handler });
};
registerHandler("timeupdate", () => {
if (!state.viewFired && videoElement.currentTime >= this.viewThresholdSeconds) {
finalizeView();
}
});
const cancelOnPause = () => {
if (!state.viewFired) {
clearTimer("view");
}
};
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
registerHandler(event, cancelOnPause),
);
const resumeIfNeeded = () => {
if (state.viewFired) {
return;
}
scheduleTimer("view", this.viewThresholdSeconds, finalizeView);
};
["play", "playing"].forEach((event) =>
registerHandler(event, resumeIfNeeded),
);
this.playbackTelemetryState = state;
if (!videoElement.paused && videoElement.currentTime > 0) {
resumeIfNeeded();
}
}
getActiveViewIdentityKey() {
const normalizedUser = this.normalizeHexPubkey(this.getActiveUserPubkey());
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
deriveViewIdentityKeyFromEvent(event) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
buildViewCooldownKey(pointerKey, identityKey) {
const normalizedPointerKey = normalizeString(pointerKey);
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity = normalizeString(identityKey).toLowerCase();
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
_clearPreferenceSubscription() {
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
try {
this.watchHistoryPreferenceUnsubscribe();
} catch (error) {
console.warn(
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
error,
);
}
}
this.watchHistoryPreferenceUnsubscribe = null;
}
_getTimerHost() {
if (typeof window !== "undefined" && window) {
return window;
}
return globalThis;
}
}
+15 -7
View File
@@ -1,7 +1,7 @@
// sidebar.js
import { setHashView } from "./index.js";
export function setupSidebarNavigation() {
export function setupSidebarNavigation({ closeSidebar } = {}) {
const sidebarLinks = document.querySelectorAll('#sidebar a[href^="#view="]');
sidebarLinks.forEach((link) => {
link.addEventListener("click", (e) => {
@@ -16,13 +16,21 @@ export function setupSidebarNavigation() {
setHashView(viewName); // This changes the hash and loads the view.
// --- NEW: if on mobile, close the sidebar automatically. ---
if (window.innerWidth < 768) {
const sidebar = document.getElementById("sidebar");
const app = document.getElementById("app");
if (sidebar && app) {
sidebar.classList.remove("sidebar-open");
app.classList.remove("sidebar-open");
const isMobile =
typeof window.matchMedia === "function"
? window.matchMedia("(max-width: 767px)").matches
: window.innerWidth < 768;
if (isMobile) {
if (typeof closeSidebar === "function") {
closeSidebar();
return;
}
const sidebar = document.getElementById("sidebar");
if (sidebar) {
sidebar.classList.remove("sidebar-open");
}
document.body.classList.remove("sidebar-open");
}
});
});
+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>
`;
}
+221
View File
@@ -0,0 +1,221 @@
const state = {
pubkey: null,
currentUserNpub: null,
currentVideo: null,
modals: Object.create(null),
videosMap: null,
videoSubscription: null,
};
const globalSubscribers = new Set();
const keySubscribers = new Map();
const modalSubscribers = new Map();
function createSnapshot() {
return {
pubkey: state.pubkey,
currentUserNpub: state.currentUserNpub,
currentVideo: state.currentVideo,
modals: { ...state.modals },
videosMap: state.videosMap,
videoSubscription: state.videoSubscription,
};
}
function notifyKey(key, value, previousValue) {
const subscribers = keySubscribers.get(key);
if (subscribers) {
for (const callback of subscribers) {
try {
callback(value, previousValue, createSnapshot());
} catch (error) {
console.warn(`[appState] subscriber for key "${key}" threw:`, error);
}
}
}
if (globalSubscribers.size) {
const snapshot = createSnapshot();
for (const callback of globalSubscribers) {
try {
callback(snapshot, { key, value, previousValue });
} catch (error) {
console.warn("[appState] global subscriber threw:", error);
}
}
}
}
function subscribeToKey(key, callback) {
if (typeof callback !== "function") {
return () => {};
}
if (!keySubscribers.has(key)) {
keySubscribers.set(key, new Set());
}
const subscribers = keySubscribers.get(key);
subscribers.add(callback);
return () => {
subscribers.delete(callback);
if (subscribers.size === 0) {
keySubscribers.delete(key);
}
};
}
export function subscribeToAppState(callback) {
if (typeof callback !== "function") {
return () => {};
}
globalSubscribers.add(callback);
return () => {
globalSubscribers.delete(callback);
};
}
export function subscribeToAppStateKey(key, callback) {
return subscribeToKey(key, callback);
}
function updateKey(key, value) {
const previousValue = state[key];
if (previousValue === value) {
return value;
}
state[key] = value;
notifyKey(key, value, previousValue);
return value;
}
export function getAppState() {
return createSnapshot();
}
export function getPubkey() {
return state.pubkey;
}
export function setPubkey(value) {
return updateKey("pubkey", typeof value === "string" ? value : value ?? null);
}
export function getCurrentUserNpub() {
return state.currentUserNpub;
}
export function setCurrentUserNpub(value) {
return updateKey(
"currentUserNpub",
typeof value === "string" && value ? value : value ?? null
);
}
export function getCurrentVideo() {
return state.currentVideo;
}
export function setCurrentVideo(value) {
return updateKey("currentVideo", value ?? null);
}
export function getVideosMap() {
return state.videosMap;
}
export function setVideosMap(value) {
if (value instanceof Map) {
return updateKey("videosMap", value);
}
return updateKey("videosMap", value ?? null);
}
export function getVideoSubscription() {
return state.videoSubscription;
}
export function setVideoSubscription(value) {
return updateKey("videoSubscription", value ?? null);
}
export function getModalState(name) {
if (typeof name !== "string" || !name) {
return false;
}
return Boolean(state.modals[name]);
}
function notifyModal(name, value, previousValue) {
const subscribers = modalSubscribers.get(name);
if (!subscribers || subscribers.size === 0) {
return;
}
for (const callback of subscribers) {
try {
callback(value, previousValue, createSnapshot());
} catch (error) {
console.warn(`[appState] modal subscriber for "${name}" threw:`, error);
}
}
}
export function setModalState(name, isOpen) {
if (typeof name !== "string" || !name) {
return false;
}
const normalized = Boolean(isOpen);
const previous = Boolean(state.modals[name]);
if (previous === normalized) {
return normalized;
}
const previousModals = { ...state.modals };
state.modals[name] = normalized;
notifyModal(name, normalized, previous);
notifyKey("modals", { ...state.modals }, previousModals);
return normalized;
}
export function subscribeToModalState(name, callback) {
if (typeof name !== "string" || !name || typeof callback !== "function") {
return () => {};
}
if (!modalSubscribers.has(name)) {
modalSubscribers.set(name, new Set());
}
const subscribers = modalSubscribers.get(name);
subscribers.add(callback);
return () => {
subscribers.delete(callback);
if (subscribers.size === 0) {
modalSubscribers.delete(name);
}
};
}
export function resetAppState() {
const previous = createSnapshot();
state.pubkey = null;
state.currentUserNpub = null;
state.currentVideo = null;
state.modals = Object.create(null);
state.videosMap = null;
state.videoSubscription = null;
notifyKey("pubkey", state.pubkey, previous.pubkey);
notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub);
notifyKey("currentVideo", state.currentVideo, previous.currentVideo);
notifyKey("modals", { ...state.modals }, previous.modals);
notifyKey("videosMap", state.videosMap, previous.videosMap);
notifyKey(
"videoSubscription",
state.videoSubscription,
previous.videoSubscription
);
}
+734
View File
@@ -0,0 +1,734 @@
import {
readUrlHealthFromStorage,
removeUrlHealthFromStorage,
writeUrlHealthToStorage,
} from "../utils/storage.js";
const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1";
const PROFILE_CACHE_VERSION = 1;
const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1";
const SAVED_PROFILES_STORAGE_VERSION = 1;
const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes
const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes
export const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds
const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed;
}
let savedProfiles = [];
let activeProfilePubkey = null;
const profileCache = new Map();
const urlHealthCache = new Map();
const urlHealthInFlight = new Map();
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
if (previousProfiles === nextProfiles) {
return false;
}
if (!Array.isArray(previousProfiles) || !Array.isArray(nextProfiles)) {
return true;
}
if (previousProfiles.length !== nextProfiles.length) {
return true;
}
for (let index = 0; index < previousProfiles.length; index += 1) {
const prev = previousProfiles[index];
const next = nextProfiles[index];
if (prev === next) {
continue;
}
if (!prev || !next || typeof prev !== "object" || typeof next !== "object") {
return true;
}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
if (prev[key] !== next[key]) {
return true;
}
}
}
return false;
}
function safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
if (decoded && decoded.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (error) {
return null;
}
return null;
}
function normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
}
export function getSavedProfiles() {
return savedProfiles;
}
export function getProfileCacheMap() {
return profileCache;
}
export function getActiveProfilePubkey() {
return activeProfilePubkey;
}
export function setActiveProfilePubkey(pubkey, { persist = true } = {}) {
const normalized = normalizeHexPubkey(pubkey);
const nextValue =
normalized || (typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : null);
if (activeProfilePubkey === nextValue) {
if (persist) {
persistSavedProfiles({ persistActive: true });
}
return activeProfilePubkey;
}
activeProfilePubkey = nextValue;
if (persist) {
persistSavedProfiles({ persistActive: true });
}
return activeProfilePubkey;
}
export function mutateSavedProfiles(mutator, { persist = true, persistActive = true } = {}) {
if (typeof mutator !== "function") {
return { changed: false, profiles: getSavedProfiles() };
}
const draft = savedProfiles.slice();
const result = mutator(draft);
const nextProfiles = Array.isArray(result) ? result : draft;
const changed = hasSavedProfilesChanged(savedProfiles, nextProfiles);
if (changed) {
savedProfiles = nextProfiles;
if (persist) {
persistSavedProfiles({ persistActive });
}
} else if (persist && persistActive === false) {
persistSavedProfiles({ persistActive: false });
}
return { changed, profiles: getSavedProfiles() };
}
export function setSavedProfiles(nextProfiles, options) {
return mutateSavedProfiles(
() => (Array.isArray(nextProfiles) ? nextProfiles.slice() : []),
options
);
}
export function readSavedProfilesPayloadFromStorage() {
if (typeof localStorage === "undefined") {
return null;
}
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch (error) {
console.warn(
"[cache.readSavedProfilesPayloadFromStorage] Failed to parse payload:",
error
);
return null;
}
}
function writeSavedProfilesPayloadToStorage(payload) {
if (typeof localStorage === "undefined") {
return;
}
try {
localStorage.setItem(SAVED_PROFILES_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
const isQuotaError =
error &&
(error.name === "QuotaExceededError" ||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
error.code === 22 ||
error.code === 1014);
if (isQuotaError) {
console.warn(
"[cache.writeSavedProfilesPayloadToStorage] Storage quota exceeded; keeping in-memory copy only.",
error
);
} else {
console.warn(
"[cache.writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:",
error
);
}
}
}
export function persistSavedProfiles({ persistActive = true } = {}) {
if (typeof localStorage === "undefined") {
return;
}
if (!savedProfiles.length && !activeProfilePubkey) {
try {
localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY);
} catch (error) {
console.warn(
"[cache.persistSavedProfiles] Failed to remove empty payload:",
error
);
}
return;
}
let activePubkeyToPersist = activeProfilePubkey || null;
if (!persistActive) {
const storedPayload = readSavedProfilesPayloadFromStorage();
if (storedPayload && typeof storedPayload === "object") {
const candidate =
typeof storedPayload.activePubkey === "string"
? storedPayload.activePubkey
: typeof storedPayload.activePubKey === "string"
? storedPayload.activePubKey
: null;
const normalizedStored = normalizeHexPubkey(candidate);
if (normalizedStored) {
activePubkeyToPersist = normalizedStored;
} else if (candidate === null) {
activePubkeyToPersist = null;
}
}
}
const payload = {
version: SAVED_PROFILES_STORAGE_VERSION,
entries: savedProfiles.map((entry) => ({
pubkey: entry.pubkey,
npub:
typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null,
name: typeof entry.name === "string" ? entry.name : "",
picture: typeof entry.picture === "string" ? entry.picture : "",
authType: entry.authType === "nsec" ? "nsec" : "nip07",
})),
activePubkey: activePubkeyToPersist,
};
writeSavedProfilesPayloadToStorage(payload);
}
export function loadSavedProfilesFromStorage() {
savedProfiles = [];
activeProfilePubkey = null;
if (typeof localStorage === "undefined") {
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
}
const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY);
if (!raw) {
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
}
let needsRewrite = false;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const seenPubkeys = new Set();
for (const candidate of entries) {
if (!candidate || typeof candidate !== "object") {
continue;
}
const normalizedPubkey = normalizeHexPubkey(candidate.pubkey);
if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) {
needsRewrite = true;
continue;
}
seenPubkeys.add(normalizedPubkey);
const storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07";
const npub =
typeof candidate.npub === "string" && candidate.npub.trim()
? candidate.npub.trim()
: null;
const entry = {
pubkey: normalizedPubkey,
npub,
name: typeof candidate.name === "string" ? candidate.name : "",
picture: typeof candidate.picture === "string" ? candidate.picture : "",
authType: storedAuthType,
};
if (
typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub
) {
needsRewrite = true;
}
if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) {
needsRewrite = true;
}
savedProfiles.push(entry);
}
const activeCandidate =
typeof parsed.activePubkey === "string"
? parsed.activePubkey
: typeof parsed.activePubKey === "string"
? parsed.activePubKey
: null;
const normalizedActive = normalizeHexPubkey(activeCandidate);
if (normalizedActive && seenPubkeys.has(normalizedActive)) {
activeProfilePubkey = normalizedActive;
} else if (activeCandidate) {
needsRewrite = true;
}
} else if (raw) {
needsRewrite = true;
}
} catch (error) {
console.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error);
needsRewrite = true;
}
if (!activeProfilePubkey && savedProfiles.length) {
activeProfilePubkey = savedProfiles[0].pubkey;
needsRewrite = true;
}
if (needsRewrite) {
persistSavedProfiles();
}
return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey };
}
export function syncSavedProfileFromCache(pubkey, { persist = false } = {}) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return false;
}
const cacheEntry = profileCache.get(normalized);
if (!cacheEntry || typeof cacheEntry !== "object") {
return false;
}
let updated = false;
mutateSavedProfiles((profiles) => {
const next = profiles.slice();
const index = next.findIndex((entry) => entry && entry.pubkey === normalized);
if (index < 0) {
return profiles;
}
const existing = next[index] || {};
const profile = cacheEntry.profile || {};
const nextEntry = {
...existing,
name: profile.name || existing.name || "",
picture: profile.picture || existing.picture || "",
};
if (
existing.name === nextEntry.name &&
existing.picture === nextEntry.picture
) {
return profiles;
}
next[index] = nextEntry;
updated = true;
return next;
}, { persist, persistActive: false });
return updated;
}
export function loadProfileCacheFromStorage() {
if (typeof localStorage === "undefined") {
return;
}
const now = Date.now();
const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY);
if (!raw) {
return;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return;
}
if (parsed.version !== PROFILE_CACHE_VERSION) {
return;
}
const entries = parsed.entries;
if (!entries || typeof entries !== "object") {
return;
}
for (const [pubkey, entry] of Object.entries(entries)) {
if (!pubkey || !entry || typeof entry !== "object") {
continue;
}
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
continue;
}
const profile = entry.profile;
if (!profile || typeof profile !== "object") {
continue;
}
const normalized = {
name: profile.name || profile.display_name || "Unknown",
picture: profile.picture || "assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileString(profile.banner || profile.header);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
profileCache.set(pubkey, {
profile: normalized,
timestamp,
});
}
} catch (error) {
console.warn("[cache.loadProfileCacheFromStorage] Failed to parse payload:", error);
}
}
export function persistProfileCacheToStorage() {
if (typeof localStorage === "undefined") {
return;
}
const now = Date.now();
const entries = {};
for (const [pubkey, entry] of profileCache.entries()) {
if (!entry || typeof entry !== "object") {
continue;
}
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) {
profileCache.delete(pubkey);
continue;
}
entries[pubkey] = {
profile: entry.profile,
timestamp,
};
}
const payload = {
version: PROFILE_CACHE_VERSION,
savedAt: now,
entries,
};
if (Object.keys(entries).length === 0) {
try {
localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY);
} catch (error) {
console.warn(
"[cache.persistProfileCacheToStorage] Failed to clear storage:",
error
);
}
return;
}
try {
localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("[cache.persistProfileCacheToStorage] Failed to persist cache:", error);
}
}
export function getProfileCacheEntry(pubkey) {
const normalized = normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
}
const entry = profileCache.get(normalized);
if (!entry) {
return null;
}
const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) {
profileCache.delete(normalized);
persistProfileCacheToStorage();
return null;
}
return entry;
}
export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
const normalizedPubkey = normalizeHexPubkey(pubkey);
if (!normalizedPubkey || !profile) {
return null;
}
const normalized = {
name: profile.name || profile.display_name || "Unknown",
picture: profile.picture || "assets/svg/default-profile.svg",
};
const about = sanitizeProfileString(profile.about || profile.aboutMe);
if (about) {
normalized.about = about;
}
const website = sanitizeProfileString(profile.website || profile.url);
if (website) {
normalized.website = website;
}
const banner = sanitizeProfileString(profile.banner || profile.header);
if (banner) {
normalized.banner = banner;
}
const lud16 = sanitizeProfileString(profile.lud16);
if (lud16) {
normalized.lud16 = lud16;
}
const lud06 = sanitizeProfileString(profile.lud06);
if (lud06) {
normalized.lud06 = lud06;
}
const lightningCandidates = [
sanitizeProfileString(profile.lightningAddress),
lud16,
lud06,
].filter(Boolean);
if (lightningCandidates.length) {
normalized.lightningAddress = lightningCandidates[0];
}
const entry = {
profile: normalized,
timestamp: Date.now(),
};
profileCache.set(normalizedPubkey, entry);
if (persist) {
persistProfileCacheToStorage();
}
return entry;
}
function buildUrlProbeKey(url, options = {}) {
const trimmed = typeof url === "string" ? url : "";
const mode = options?.confirmPlayable ? "playable" : "basic";
return `${trimmed}::${mode}`;
}
function isUrlHealthEntryFresh(entry, url) {
if (!entry || typeof entry !== "object") {
return false;
}
const now = Date.now();
if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) {
return false;
}
if (url && entry.url && entry.url !== url) {
return false;
}
return true;
}
export function getCachedUrlHealth(eventId, url) {
if (!eventId) {
return null;
}
const entry = urlHealthCache.get(eventId);
if (isUrlHealthEntryFresh(entry, url)) {
return entry;
}
if (entry) {
urlHealthCache.delete(eventId);
}
const stored = readUrlHealthFromStorage(eventId);
if (!isUrlHealthEntryFresh(stored, url)) {
if (stored) {
removeUrlHealthFromStorage(eventId);
}
return null;
}
urlHealthCache.set(eventId, stored);
return stored;
}
export function storeUrlHealth(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) {
if (!eventId) {
return null;
}
const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS;
const now = Date.now();
const entry = {
status: result?.status || "checking",
message: result?.message || "Checking hosted URL…",
url: url || result?.url || "",
expiresAt: now + ttl,
lastCheckedAt: now,
};
urlHealthCache.set(eventId, entry);
writeUrlHealthToStorage(eventId, entry);
return entry;
}
export function setInFlightUrlProbe(eventId, url, promise, options = {}) {
if (!eventId || !promise) {
return;
}
const key = buildUrlProbeKey(url, options);
urlHealthInFlight.set(eventId, { promise, key });
promise.finally(() => {
const current = urlHealthInFlight.get(eventId);
if (current && current.promise === promise) {
urlHealthInFlight.delete(eventId);
}
});
}
export function getInFlightUrlProbe(eventId, url, options = {}) {
if (!eventId) {
return null;
}
const entry = urlHealthInFlight.get(eventId);
if (!entry) {
return null;
}
const key = buildUrlProbeKey(url, options);
if (entry.key && entry.key !== key) {
return null;
}
return entry.promise;
}
export const urlHealthConstants = {
URL_HEALTH_TTL_MS,
URL_HEALTH_TIMEOUT_RETRY_MS,
URL_PROBE_TIMEOUT_RETRY_MS,
};
+106 -10
View File
@@ -1,14 +1,97 @@
// js/storage/r2-s3.js
import {
S3Client,
CreateBucketCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
HeadBucketCommand,
PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
const AWS_SDK_SPECIFIER =
"https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
const disableNetworkImports = Boolean(
globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__
);
let awsSdk = null;
let awsSdkLoadError = null;
let awsSdkLoadPromise = null;
function assignAwsSdk(module) {
if (!module) {
return;
}
awsSdk = {
S3Client: module.S3Client,
CreateBucketCommand: module.CreateBucketCommand,
CreateMultipartUploadCommand: module.CreateMultipartUploadCommand,
UploadPartCommand: module.UploadPartCommand,
CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand,
AbortMultipartUploadCommand: module.AbortMultipartUploadCommand,
HeadBucketCommand: module.HeadBucketCommand,
PutBucketCorsCommand: module.PutBucketCorsCommand,
};
}
async function loadAwsSdkModule() {
if (awsSdk || awsSdkLoadError) {
return awsSdk;
}
if (!awsSdkLoadPromise) {
if (disableNetworkImports) {
awsSdkLoadError = new Error(
"Cloudflare R2 SDK network import has been disabled."
);
return null;
}
awsSdkLoadPromise = import(AWS_SDK_SPECIFIER)
.then((module) => {
assignAwsSdk(module);
return awsSdk;
})
.catch((error) => {
awsSdkLoadError = error || new Error("Failed to load R2 SDK module");
throw awsSdkLoadError;
});
}
try {
return await awsSdkLoadPromise;
} catch (error) {
awsSdkLoadError = error;
throw error;
}
}
function requireAwsSdk() {
if (awsSdk) {
return awsSdk;
}
if (awsSdkLoadError) {
throw awsSdkLoadError;
}
throw new Error(
"Cloudflare R2 SDK has not finished loading. Call ensureR2SdkLoaded() first."
);
}
export function ensureR2SdkLoaded() {
return loadAwsSdkModule();
}
export function getR2SdkLoadError() {
return awsSdkLoadError;
}
export function isR2SdkAvailable() {
return Boolean(awsSdk);
}
if (!disableNetworkImports) {
// Begin loading immediately in environments that support network imports.
loadAwsSdkModule().catch(() => {
// Errors are captured for later inspection by getR2SdkLoadError().
});
}
function computeCacheControl(key) {
if (!key) {
@@ -36,6 +119,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
throw new Error("Missing Cloudflare R2 credentials");
}
const { S3Client } = requireAwsSdk();
return new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
@@ -45,6 +130,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
}
async function ensureBucketExists({ s3, bucket }) {
const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk();
try {
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
return;
@@ -80,6 +167,8 @@ export async function ensureBucketCors({ s3, bucket, origins }) {
throw new Error("Bucket name is required to configure CORS");
}
const { PutBucketCorsCommand } = requireAwsSdk();
const allowedOrigins = (origins || []).filter(Boolean);
if (allowedOrigins.length === 0) {
return;
@@ -131,6 +220,13 @@ export async function multipartUpload({
throw new Error("File is required");
}
const {
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
} = requireAwsSdk();
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
+509 -536
View File
File diff suppressed because it is too large. Load diff
+885
View File
@@ -0,0 +1,885 @@
import { extractMagnetHints, normalizeAndAugmentMagnet } from "../../magnet.js";
import { Nip71FormManager } from "./nip71FormManager.js";
export class EditModal {
constructor({
removeTrackingScripts,
setGlobalModalState,
sanitizers = {},
escapeHtml,
showError,
getMode,
eventTarget,
container,
} = {}) {
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.escapeHtml = typeof escapeHtml === "function" ? escapeHtml : (value) => `${value ?? ""}`;
this.showError = typeof showError === "function" ? showError : () => {};
this.getMode = typeof getMode === "function" ? getMode : () => "live";
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.sanitizers = {
text:
typeof sanitizers.text === "function"
? sanitizers.text
: (value) => (typeof value === "string" ? value.trim() : ""),
url:
typeof sanitizers.url === "function"
? sanitizers.url
: (value) => (typeof value === "string" ? value.trim() : ""),
magnet:
typeof sanitizers.magnet === "function"
? sanitizers.magnet
: (value) => (typeof value === "string" ? value.trim() : ""),
checkbox:
typeof sanitizers.checkbox === "function"
? sanitizers.checkbox
: (value) => !!value,
};
this.root = null;
this.overlay = null;
this.form = null;
this.closeButton = null;
this.cancelButton = null;
this.submitButton = null;
this.fieldButtons = [];
this.fields = {};
this.visibility = {
container: null,
buttons: [],
};
this.activeVideo = null;
this.isVisible = false;
this.eventsBound = false;
this.loadPromise = null;
this.pendingSubmit = false;
this.pendingSubmitVideo = null;
this.nip71FormManager = new Nip71FormManager();
this.nip71SectionKey = "edit";
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
}
addEventListener(type, listener, options) {
this.eventTarget.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
this.eventTarget.removeEventListener(type, listener, options);
}
emit(type, detail) {
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
}
getRoot() {
return this.root;
}
async load({ container } = {}) {
if (this.root) {
return this.root;
}
if (this.loadPromise) {
return this.loadPromise;
}
this.loadPromise = (async () => {
const targetContainer =
container || this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
let modal = targetContainer.querySelector("#editVideoModal");
if (!modal) {
const response = await fetch("components/edit-video-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
modal = wrapper.querySelector("#editVideoModal");
}
if (!modal) {
throw new Error("Edit video modal markup missing after load.");
}
this.container = targetContainer;
this.root = modal;
this.cacheElements(modal);
if (!this.eventsBound) {
this.bindEvents();
this.eventsBound = true;
}
this.reset();
return this.root;
})();
try {
return await this.loadPromise;
} finally {
this.loadPromise = null;
}
}
cacheElements(context) {
this.overlay = context.querySelector("#editVideoModalOverlay") || null;
this.form = context.querySelector("#editVideoForm") || null;
this.closeButton = context.querySelector("#closeEditVideoModal") || null;
this.cancelButton = context.querySelector("#cancelEditVideo") || null;
this.submitButton = context.querySelector("#submitEditVideo") || null;
this.updateSubmitButtonState();
this.fieldButtons = Array.from(context.querySelectorAll("[data-edit-target]"));
this.fields = {
title: context.querySelector("#editVideoTitle") || null,
url: context.querySelector("#editVideoUrl") || null,
magnet: context.querySelector("#editVideoMagnet") || null,
ws: context.querySelector("#editVideoWs") || null,
xs: context.querySelector("#editVideoXs") || null,
thumbnail: context.querySelector("#editVideoThumbnail") || null,
description: context.querySelector("#editVideoDescription") || null,
isPrivate: context.querySelector("#editVideoIsPrivate") || null,
enableComments: context.querySelector("#editEnableComments") || null,
};
this.visibility = {
container: context.querySelector("[data-visibility-toggle]") || null,
buttons: Array.from(
context.querySelectorAll("[data-visibility-option]") || []
),
helper: context.querySelector("[data-visibility-helper]") || null,
helperDefault: "",
};
if (this.visibility.helper) {
this.visibility.helperDefault = this.visibility.helper.textContent || "";
}
const nip71Context = this.form || context;
this.nip71FormManager.registerSection(this.nip71SectionKey, nip71Context);
}
bindEvents() {
if (this.form) {
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.submit();
});
}
const cancelHandler = (event) => {
event?.preventDefault?.();
this.close({ emitCancel: true });
};
if (this.overlay) {
this.overlay.addEventListener("click", cancelHandler);
}
if (this.closeButton) {
this.closeButton.addEventListener("click", cancelHandler);
}
if (this.cancelButton) {
this.cancelButton.addEventListener("click", cancelHandler);
}
if (Array.isArray(this.fieldButtons)) {
this.fieldButtons.forEach((button) => {
if (!button || button.dataset.editListenerAttached === "true") {
return;
}
button.addEventListener("click", (event) => {
this.handleEditFieldToggle(event);
});
button.dataset.editListenerAttached = "true";
});
}
if (Array.isArray(this.visibility?.buttons)) {
this.visibility.buttons.forEach((button) => {
button.addEventListener("click", (event) => {
const option = event?.currentTarget?.dataset?.visibilityOption;
if (!option) {
return;
}
this.setVisibility(option);
});
});
}
if (this.fields.isPrivate) {
this.fields.isPrivate.addEventListener("change", () => {
this.handleIsPrivateChange({ emit: true });
});
}
if (this.fields.enableComments) {
this.fields.enableComments.addEventListener("change", (event) => {
const target = event.currentTarget;
const value = this.sanitizers.checkbox(target?.checked);
const detail = {
field: "enableComments",
value,
videoId: this.escapeHtml(this.activeVideo?.id || ""),
};
this.emit("video:edit-visibility-change", detail);
});
}
this.nip71FormManager.bindSection(this.nip71SectionKey);
}
reset() {
if (!this.root) {
this.activeVideo = null;
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
this.setSubmitState({ pending: false });
return;
}
const inputs = Object.values(this.fields);
inputs.forEach((input) => {
if (!input) {
return;
}
if (input.type === "checkbox") {
const defaultAttr = input.dataset?.defaultChecked;
const defaultChecked =
defaultAttr === "true"
? true
: defaultAttr === "false"
? false
: input.defaultChecked;
input.checked = defaultChecked;
input.disabled = false;
input.removeAttribute("disabled");
delete input.dataset.isEditing;
} else {
input.value = "";
input.readOnly = false;
input.removeAttribute("readonly");
input.classList.remove("locked-input");
delete input.dataset.isEditing;
}
delete input.dataset.originalValue;
});
if (Array.isArray(this.fieldButtons)) {
this.fieldButtons.forEach((button) => {
button.classList.add("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
});
}
this.handleIsPrivateChange({ emit: false });
this.nip71FormManager.resetSection(this.nip71SectionKey);
this.originalNip71Metadata = null;
this.originalNip71MetadataJson = null;
this.activeVideo = null;
this.setSubmitState({ pending: false });
}
async open(video) {
await this.load();
if (!video) {
throw new Error("No video provided for editing.");
}
this.reset();
const magnetSource = video.magnet || video.rawMagnet || "";
const magnetHints = extractMagnetHints(magnetSource);
const effectiveWs = video.ws || magnetHints.ws || "";
const effectiveXs = video.xs || magnetHints.xs || "";
const enableCommentsValue =
typeof video.enableComments === "boolean" ? video.enableComments : true;
const isPrivateValue = video.isPrivate === true;
const editContext = {
...video,
ws: effectiveWs,
xs: effectiveXs,
enableComments: enableCommentsValue,
isPrivate: isPrivateValue,
};
this.applyVideoToForm(editContext);
this.nip71FormManager.resetSection(this.nip71SectionKey);
if (video.nip71 && typeof video.nip71 === "object") {
this.nip71FormManager.hydrateSection(this.nip71SectionKey, video.nip71);
}
const initialNip71 = this.nip71FormManager.collectSection(this.nip71SectionKey);
this.originalNip71Metadata = this.cloneNip71Metadata(initialNip71);
this.originalNip71MetadataJson = JSON.stringify(this.originalNip71Metadata);
this.activeVideo = editContext;
if (this.root) {
this.root.classList.remove("hidden");
this.isVisible = true;
this.setGlobalModalState("editVideo", true);
}
return true;
}
applyVideoToForm(editContext) {
if (!editContext) {
return;
}
const fieldMap = {
title: editContext.title || "",
url: editContext.url || "",
magnet: editContext.magnet || "",
ws: editContext.ws || "",
xs: editContext.xs || "",
thumbnail: editContext.thumbnail || "",
description: editContext.description || "",
isPrivate: editContext.isPrivate,
enableComments: editContext.enableComments,
};
Object.entries(fieldMap).forEach(([key, rawValue]) => {
const input = this.fields[key];
const targetId = this.fieldIdForKey(key);
const button = this.fieldButtons.find(
(item) => item?.dataset?.editTarget === targetId
);
if (!input) {
if (button) {
button.classList.add("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
}
return;
}
if (targetId) {
input.id = targetId;
}
const isCheckbox = input.type === "checkbox";
if (isCheckbox) {
const hasValue = rawValue !== undefined;
const boolValue = rawValue === true;
input.checked = boolValue;
input.disabled = hasValue;
if (hasValue) {
input.setAttribute("disabled", "disabled");
} else {
input.removeAttribute("disabled");
}
input.dataset.originalValue = boolValue ? "true" : "false";
input.dataset.isEditing = hasValue ? "false" : "true";
if (button) {
if (hasValue) {
button.classList.remove("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
} else {
button.classList.add("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
}
}
return;
}
const value = typeof rawValue === "string" ? rawValue : "";
const hasValue = value.trim().length > 0;
input.value = value;
input.dataset.originalValue = value;
input.dataset.isEditing = hasValue ? "false" : "true";
if (hasValue) {
input.readOnly = true;
input.setAttribute("readonly", "readonly");
input.classList.add("locked-input");
} else {
input.readOnly = false;
input.removeAttribute("readonly");
input.classList.remove("locked-input");
}
if (button) {
if (hasValue) {
button.classList.remove("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
} else {
button.classList.add("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
}
}
});
this.handleIsPrivateChange({ emit: false });
}
fieldIdForKey(key) {
switch (key) {
case "title":
return "editVideoTitle";
case "url":
return "editVideoUrl";
case "magnet":
return "editVideoMagnet";
case "ws":
return "editVideoWs";
case "xs":
return "editVideoXs";
case "thumbnail":
return "editVideoThumbnail";
case "description":
return "editVideoDescription";
case "isPrivate":
return "editVideoIsPrivate";
case "enableComments":
return "editEnableComments";
default:
return null;
}
}
close({ emitCancel = false } = {}) {
if (this.root) {
this.root.classList.add("hidden");
}
this.isVisible = false;
this.setGlobalModalState("editVideo", false);
const cancelledVideo = this.activeVideo;
this.reset();
if (emitCancel) {
this.emit("video:edit-cancel", {
videoId: this.escapeHtml(cancelledVideo?.id || ""),
pubkey: this.escapeHtml(cancelledVideo?.pubkey || ""),
});
}
}
handleEditFieldToggle(event) {
const button = event?.currentTarget;
if (!button) {
return;
}
const targetId = button.dataset?.editTarget;
if (!targetId) {
return;
}
const input = this.root?.querySelector(`#${targetId}`);
if (!input) {
return;
}
const isCheckbox = input.type === "checkbox";
const isLocked = isCheckbox
? input.disabled === true
: input.readOnly === true;
if (isLocked) {
if (isCheckbox) {
input.disabled = false;
input.removeAttribute("disabled");
input.dataset.isEditing = "true";
} else {
input.disabled = false;
input.readOnly = false;
input.removeAttribute("readonly");
input.classList.remove("locked-input");
input.dataset.isEditing = "true";
}
button.dataset.mode = "editing";
button.textContent = "Restore original";
if (!isCheckbox && typeof input.focus === "function") {
input.focus();
if (typeof input.setSelectionRange === "function") {
const length = input.value.length;
try {
input.setSelectionRange(length, length);
} catch (error) {
// Ignore selection errors (e.g. unsupported input types)
}
}
}
if (input === this.fields.isPrivate) {
this.updateVisibilityToggleUI();
}
return;
}
const originalValue = input.dataset?.originalValue || "";
if (isCheckbox) {
input.checked = originalValue === "true";
input.disabled = true;
input.setAttribute("disabled", "disabled");
input.dataset.isEditing = "false";
button.dataset.mode = "locked";
button.textContent = "Edit field";
if (input === this.fields.isPrivate) {
this.updateVisibilityToggleUI();
}
return;
}
input.value = originalValue;
if (originalValue) {
input.readOnly = true;
input.setAttribute("readonly", "readonly");
input.classList.add("locked-input");
input.dataset.isEditing = "false";
button.dataset.mode = "locked";
button.textContent = "Edit field";
} else {
input.readOnly = false;
input.removeAttribute("readonly");
input.classList.remove("locked-input");
input.dataset.isEditing = "true";
button.classList.add("hidden");
button.dataset.mode = "locked";
button.textContent = "Edit field";
}
}
setVisibility(option, { emit = true } = {}) {
const checkbox = this.fields.isPrivate;
if (!checkbox) {
return;
}
const normalized = typeof option === "string" ? option.toLowerCase() : "";
if (normalized !== "public" && normalized !== "private") {
return;
}
if (checkbox.disabled) {
this.updateVisibilityToggleUI();
return;
}
const wantPrivate = normalized === "private";
if (checkbox.checked === wantPrivate) {
this.updateVisibilityToggleUI();
return;
}
checkbox.checked = wantPrivate;
this.handleIsPrivateChange({ emit });
}
updateVisibilityToggleUI() {
const checkbox = this.fields.isPrivate;
const buttons = Array.isArray(this.visibility?.buttons)
? this.visibility.buttons
: [];
if (!checkbox) {
return;
}
const isPrivate = checkbox.checked === true;
const disabled = checkbox.disabled === true;
buttons.forEach((button) => {
if (!button) {
return;
}
const option = button.dataset?.visibilityOption || "";
const normalized = option.toLowerCase();
const isActive =
(normalized === "private" && isPrivate) ||
(normalized === "public" && !isPrivate);
button.dataset.active = isActive ? "true" : "false";
button.setAttribute("aria-pressed", isActive ? "true" : "false");
if (disabled) {
button.classList.add("is-disabled");
button.setAttribute("aria-disabled", "true");
button.tabIndex = -1;
} else {
button.classList.remove("is-disabled");
button.removeAttribute("aria-disabled");
button.tabIndex = 0;
}
});
if (this.visibility?.container) {
this.visibility.container.dataset.state = isPrivate ? "private" : "public";
if (disabled) {
this.visibility.container.dataset.disabled = "true";
} else {
delete this.visibility.container.dataset.disabled;
}
}
const helper = this.visibility?.helper;
if (helper) {
const privateCopy = this.visibility.helperDefault || helper.textContent;
const publicCopy =
"Public notes appear in feeds for everyone who can view your channel.";
helper.textContent = isPrivate ? privateCopy : publicCopy;
}
}
handleIsPrivateChange({ emit = true } = {}) {
const checkbox = this.fields.isPrivate;
if (!checkbox) {
return;
}
this.updateVisibilityToggleUI();
if (!emit) {
return;
}
const value = this.sanitizers.checkbox(checkbox.checked);
const detail = {
field: "isPrivate",
value,
videoId: this.escapeHtml(this.activeVideo?.id || ""),
};
this.emit("video:edit-visibility-change", detail);
}
cloneNip71Metadata(metadata) {
if (metadata == null) {
return null;
}
try {
return JSON.parse(JSON.stringify(metadata));
} catch (error) {
return metadata;
}
}
submit() {
if (this.pendingSubmit) {
return;
}
if (!this.activeVideo || !this.root) {
this.showError("No video selected for editing.");
return;
}
this.setSubmitState({ pending: true, video: this.activeVideo });
const fieldValue = (key) => {
const input = this.fields[key];
if (!input || typeof input.value !== "string") {
return "";
}
if (key === "url" || key === "ws" || key === "xs") {
return this.sanitizers.url(input.value);
}
if (key === "magnet") {
return this.sanitizers.magnet(input.value);
}
return this.sanitizers.text(input.value);
};
const original = this.pendingSubmitVideo || this.activeVideo;
const titleInput = this.fields.title;
const urlInput = this.fields.url;
const magnetInput = this.fields.magnet;
const wsInput = this.fields.ws;
const xsInput = this.fields.xs;
const thumbnailInput = this.fields.thumbnail;
const descriptionInput = this.fields.description;
const commentsInput = this.fields.enableComments;
const privateInput = this.fields.isPrivate;
const newTitle = fieldValue("title");
const newUrl = fieldValue("url");
const newMagnet = fieldValue("magnet");
const newWs = fieldValue("ws");
const newXs = fieldValue("xs");
const newThumbnail = fieldValue("thumbnail");
const newDescription = fieldValue("description");
const isEditing = (input) => {
if (!input) {
return true;
}
if (input.dataset?.isEditing === "true") {
return true;
}
if (input.type === "checkbox") {
return input.disabled === false;
}
return input.readOnly === false;
};
const titleWasEdited = isEditing(titleInput);
const urlWasEdited = isEditing(urlInput);
const magnetWasEdited = isEditing(magnetInput);
const finalTitle = titleWasEdited ? newTitle : original.title || "";
const finalUrl = urlWasEdited ? newUrl : original.url || "";
const shouldUseOriginalWs = wsInput ? wsInput.readOnly !== false : true;
const shouldUseOriginalXs = xsInput ? xsInput.readOnly !== false : true;
let finalWs = shouldUseOriginalWs ? original.ws || "" : newWs;
let finalXs = shouldUseOriginalXs ? original.xs || "" : newXs;
let finalMagnet = magnetWasEdited ? newMagnet : original.magnet || "";
const finalThumbnail = isEditing(thumbnailInput)
? newThumbnail
: original.thumbnail || "";
const finalDescription = isEditing(descriptionInput)
? newDescription
: original.description || "";
const originalEnableComments =
typeof original.enableComments === "boolean" ? original.enableComments : true;
const originalIsPrivate = original.isPrivate === true;
const currentNip71 = this.cloneNip71Metadata(
this.nip71FormManager.collectSection(this.nip71SectionKey)
);
const hasImetaSource = Array.isArray(currentNip71?.imeta)
? currentNip71.imeta.some((variant) => {
if (!variant || typeof variant !== "object") {
return false;
}
const hasUrl = typeof variant.url === "string" && variant.url.trim();
const hasMagnet = typeof variant.x === "string" && variant.x.trim();
return Boolean(hasUrl || hasMagnet);
})
: false;
let finalEnableComments = originalEnableComments;
if (commentsInput) {
if (commentsInput.disabled) {
finalEnableComments = commentsInput.dataset.originalValue === "true";
} else {
finalEnableComments = this.sanitizers.checkbox(commentsInput.checked);
}
}
let finalIsPrivate = originalIsPrivate;
if (privateInput) {
if (privateInput.disabled) {
finalIsPrivate = privateInput.dataset.originalValue === "true";
} else {
finalIsPrivate = this.sanitizers.checkbox(privateInput.checked);
}
}
if (!finalTitle || (!finalUrl && !finalMagnet && !hasImetaSource)) {
this.showError(
"Title and at least one of URL, Magnet, or a NIP-71 media variant is required."
);
return;
}
if (finalUrl && !/^https:\/\//i.test(finalUrl)) {
this.showError("Hosted video URLs must use HTTPS.");
return;
}
if (finalMagnet) {
const normalizedMagnet = normalizeAndAugmentMagnet(finalMagnet, {
ws: finalWs,
xs: finalXs,
});
finalMagnet = normalizedMagnet;
const hints = extractMagnetHints(normalizedMagnet);
finalWs = hints.ws;
finalXs = hints.xs;
} else {
finalWs = "";
finalXs = "";
}
const updatedData = {
version: original.version || 2,
title: finalTitle,
magnet: finalMagnet,
url: finalUrl,
thumbnail: finalThumbnail,
description: finalDescription,
mode: this.getMode(),
ws: finalWs,
xs: finalXs,
wsEdited: !shouldUseOriginalWs,
xsEdited: !shouldUseOriginalXs,
urlEdited: urlWasEdited,
magnetEdited: magnetWasEdited,
enableComments: finalEnableComments,
isPrivate: finalIsPrivate,
};
const currentNip71Json = JSON.stringify(currentNip71);
const nip71Edited = currentNip71Json !== this.originalNip71MetadataJson;
updatedData.nip71 = currentNip71;
updatedData.nip71Edited = nip71Edited;
const originalEvent = {
id: this.sanitizers.text(original.id || ""),
pubkey: this.sanitizers.text(original.pubkey || ""),
videoRootId: this.sanitizers.text(original.videoRootId || ""),
};
this.emit("video:edit-submit", {
originalEvent,
updatedData,
video: { ...original },
});
}
setSubmitState({ pending = false, video } = {}) {
const nextPending = Boolean(pending);
if (nextPending) {
this.pendingSubmitVideo = video || this.activeVideo || this.pendingSubmitVideo;
} else {
this.pendingSubmitVideo = null;
}
this.pendingSubmit = nextPending;
this.updateSubmitButtonState();
}
updateSubmitButtonState() {
if (!this.submitButton) {
return;
}
if (this.pendingSubmit) {
this.submitButton.disabled = true;
this.submitButton.setAttribute("disabled", "disabled");
} else {
this.submitButton.disabled = false;
this.submitButton.removeAttribute("disabled");
}
}
}
File diff suppressed because it is too large. Load diff
+765
View File
@@ -0,0 +1,765 @@
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
import { Nip71FormManager } from "./nip71FormManager.js";
export class UploadModal {
constructor({
authService,
r2Service,
publishVideoNote,
removeTrackingScripts,
setGlobalModalState,
showError,
showSuccess,
getCurrentPubkey,
safeEncodeNpub,
eventTarget,
container,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
this.publishVideoNote = typeof publishVideoNote === "function" ? publishVideoNote : null;
this.removeTrackingScripts =
typeof removeTrackingScripts === "function" ? removeTrackingScripts : () => {};
this.setGlobalModalState =
typeof setGlobalModalState === "function" ? setGlobalModalState : () => {};
this.showError = typeof showError === "function" ? showError : () => {};
this.showSuccess = typeof showSuccess === "function" ? showSuccess : () => {};
this.getCurrentPubkey = typeof getCurrentPubkey === "function" ? getCurrentPubkey : null;
this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => "";
this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.root = null;
this.activeMode = "custom";
this.isVisible = false;
this.cloudflareSettings = this.r2Service?.getSettings?.() || null;
this.cloudflareAdvancedVisible =
this.r2Service?.getCloudflareAdvancedVisibility?.() || false;
this.r2Unsubscribes = [];
this.uploadModeButtons = [];
this.customSection = null;
this.cloudflareSection = null;
this.customForm = null;
this.customFormInputs = {};
this.cloudflareSettingsForm = null;
this.cloudflareClearSettingsButton = null;
this.cloudflareSettingsStatus = null;
this.cloudflareBucketPreview = null;
this.cloudflareUploadForm = null;
this.cloudflareFileInput = null;
this.cloudflareUploadButton = null;
this.cloudflareUploadStatus = null;
this.cloudflareProgressBar = null;
this.cloudflareProgressFill = null;
this.cloudflareTitleInput = null;
this.cloudflareDescriptionInput = null;
this.cloudflareThumbnailInput = null;
this.cloudflareMagnetInput = null;
this.cloudflareWsInput = null;
this.cloudflareXsInput = null;
this.cloudflareEnableCommentsInput = null;
this.cloudflareAdvancedToggle = null;
this.cloudflareAdvancedToggleLabel = null;
this.cloudflareAdvancedToggleIcon = null;
this.cloudflareAdvancedFields = null;
this.r2AccountIdInput = null;
this.r2AccessKeyIdInput = null;
this.r2SecretAccessKeyInput = null;
this.r2ApiTokenInput = null;
this.r2ZoneIdInput = null;
this.r2BaseDomainInput = null;
this.nip71FormManager = new Nip71FormManager();
}
addEventListener(type, listener, options) {
if (!this.eventTarget) {
return;
}
this.eventTarget.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
if (!this.eventTarget) {
return;
}
this.eventTarget.removeEventListener(type, listener, options);
}
emit(type, detail) {
if (!this.eventTarget) {
return;
}
this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
}
getRoot() {
return this.root;
}
async load({ container } = {}) {
if (this.root) {
return this.root;
}
const targetContainer =
container || this.container || document.getElementById("modalContainer");
if (!targetContainer) {
throw new Error("Modal container element not found!");
}
const response = await fetch("components/upload-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
targetContainer.appendChild(wrapper);
this.container = targetContainer;
this.root = wrapper.querySelector("#uploadModal");
if (!this.root) {
throw new Error("Upload modal markup missing after load.");
}
this.cacheElements(wrapper);
this.bindEvents();
this.registerR2Subscriptions();
this.renderCloudflareAdvancedVisibility(
this.r2Service?.getCloudflareAdvancedVisibility?.()
);
try {
await this.loadR2Settings();
} catch (error) {
// Errors already surfaced via the service listeners.
}
await this.refreshCloudflareBucketPreview();
this.setMode(this.activeMode);
this.updateCloudflareProgress(Number.NaN);
return this.root;
}
cacheElements(context) {
this.uploadModeButtons = Array.from(
context.querySelectorAll(".upload-mode-toggle[data-upload-mode]")
);
this.customSection = context.querySelector("#customUploadSection") || null;
this.cloudflareSection = context.querySelector("#cloudflareUploadSection") || null;
this.customForm = context.querySelector("#uploadForm") || null;
this.customFormInputs = {
title: context.querySelector("#uploadTitle") || null,
url: context.querySelector("#uploadUrl") || null,
magnet: context.querySelector("#uploadMagnet") || null,
ws: context.querySelector("#uploadWs") || null,
xs: context.querySelector("#uploadXs") || null,
thumbnail: context.querySelector("#uploadThumbnail") || null,
description: context.querySelector("#uploadDescription") || null,
enableComments: context.querySelector("#uploadEnableComments") || null,
isPrivate: context.querySelector("#uploadIsPrivate") || null,
};
this.closeButton = context.querySelector("#closeUploadModal") || null;
this.cloudflareSettingsForm = context.querySelector("#cloudflareSettingsForm") || null;
this.cloudflareClearSettingsButton =
context.querySelector("#cloudflareClearSettings") || null;
this.cloudflareSettingsStatus =
context.querySelector("#cloudflareSettingsStatus") || null;
this.cloudflareBucketPreview =
context.querySelector("#cloudflareBucketPreview") || null;
this.cloudflareUploadForm = context.querySelector("#cloudflareUploadForm") || null;
this.cloudflareFileInput = context.querySelector("#cloudflareFile") || null;
this.cloudflareUploadButton = context.querySelector("#cloudflareUploadButton") || null;
this.cloudflareUploadStatus =
context.querySelector("#cloudflareUploadStatus") || null;
this.cloudflareProgressBar = context.querySelector("#cloudflareProgressBar") || null;
this.cloudflareProgressFill = context.querySelector("#cloudflareProgressFill") || null;
this.cloudflareTitleInput = context.querySelector("#cloudflareTitle") || null;
this.cloudflareDescriptionInput =
context.querySelector("#cloudflareDescription") || null;
this.cloudflareThumbnailInput =
context.querySelector("#cloudflareThumbnail") || null;
this.cloudflareMagnetInput = context.querySelector("#cloudflareMagnet") || null;
this.cloudflareWsInput = context.querySelector("#cloudflareWs") || null;
this.cloudflareXsInput = context.querySelector("#cloudflareXs") || null;
this.cloudflareEnableCommentsInput =
context.querySelector("#cloudflareEnableComments") || null;
this.cloudflareAdvancedToggle =
context.querySelector("#cloudflareAdvancedToggle") || null;
this.cloudflareAdvancedToggleLabel =
context.querySelector("#cloudflareAdvancedToggleLabel") || null;
this.cloudflareAdvancedToggleIcon =
context.querySelector("#cloudflareAdvancedToggleIcon") || null;
this.cloudflareAdvancedFields =
context.querySelector("#cloudflareAdvancedFields") || null;
this.r2AccountIdInput = context.querySelector("#r2AccountId") || null;
this.r2AccessKeyIdInput = context.querySelector("#r2AccessKeyId") || null;
this.r2SecretAccessKeyInput = context.querySelector("#r2SecretAccessKey") || null;
this.r2ApiTokenInput = context.querySelector("#r2ApiToken") || null;
this.r2ZoneIdInput = context.querySelector("#r2ZoneId") || null;
this.r2BaseDomainInput = context.querySelector("#r2BaseDomain") || null;
this.nip71FormManager.registerSection("custom", this.customSection);
this.nip71FormManager.registerSection("cloudflare", this.cloudflareSection);
}
buildAutoGeneratedImetaVariant(file) {
if (!file) {
return null;
}
const mimeType = typeof file.type === "string" ? file.type.trim() : "";
if (!mimeType) {
return null;
}
return {
m: mimeType,
dim: "",
url: "",
x: "",
image: [],
fallback: [],
service: [],
autoGenerated: true,
};
}
bindEvents() {
if (this.closeButton) {
this.closeButton.addEventListener("click", () => {
this.close();
});
}
if (this.customForm) {
this.customForm.addEventListener("submit", (event) => {
event.preventDefault();
this.handleCustomSubmit();
});
}
if (Array.isArray(this.uploadModeButtons)) {
this.uploadModeButtons.forEach((button) => {
if (!button) return;
button.addEventListener("click", () => {
const mode = button.dataset.uploadMode || "custom";
this.setMode(mode);
});
});
}
if (this.cloudflareSettingsForm) {
this.cloudflareSettingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
await this.handleCloudflareSettingsSubmit();
});
}
if (this.cloudflareClearSettingsButton) {
this.cloudflareClearSettingsButton.addEventListener("click", async () => {
await this.handleCloudflareClearSettings();
});
}
if (this.cloudflareUploadForm) {
this.cloudflareUploadForm.addEventListener("submit", async (event) => {
event.preventDefault();
await this.handleCloudflareUploadSubmit();
});
}
if (this.cloudflareAdvancedToggle) {
this.cloudflareAdvancedToggle.addEventListener("click", () => {
if (this.r2Service?.setCloudflareAdvancedVisibility &&
this.r2Service?.getCloudflareAdvancedVisibility) {
const nextState = !this.r2Service.getCloudflareAdvancedVisibility();
this.r2Service.setCloudflareAdvancedVisibility(nextState);
} else {
this.renderCloudflareAdvancedVisibility(!this.cloudflareAdvancedVisible);
}
});
}
this.nip71FormManager.bindSection("custom");
this.nip71FormManager.bindSection("cloudflare");
}
registerR2Subscriptions() {
if (!this.r2Service?.on) {
return;
}
if (Array.isArray(this.r2Unsubscribes)) {
this.r2Unsubscribes.forEach((unsubscribe) => {
try {
if (typeof unsubscribe === "function") {
unsubscribe();
}
} catch (error) {
console.warn("[UploadModal] Failed to remove R2 listener", error);
}
});
}
this.r2Unsubscribes = [];
const register = (unsubscribe) => {
if (typeof unsubscribe === "function") {
this.r2Unsubscribes.push(unsubscribe);
}
};
register(
this.r2Service.on("advancedVisibilityChange", ({ visible } = {}) => {
this.renderCloudflareAdvancedVisibility(visible);
})
);
register(
this.r2Service.on("settingsStatus", ({ message, variant } = {}) => {
this.applyCloudflareStatus(this.cloudflareSettingsStatus, message, variant);
})
);
register(
this.r2Service.on("uploadStatus", ({ message, variant } = {}) => {
this.applyCloudflareStatus(this.cloudflareUploadStatus, message, variant);
})
);
register(
this.r2Service.on("uploadStateChange", ({ isUploading } = {}) => {
this.renderCloudflareUploadingState(isUploading);
})
);
register(
this.r2Service.on("uploadProgress", ({ fraction } = {}) => {
this.updateCloudflareProgress(fraction);
})
);
register(
this.r2Service.on("settingsPopulated", ({ settings } = {}) => {
this.fillCloudflareSettingsInputs(settings);
})
);
register(
this.r2Service.on("settingsChanged", ({ settings } = {}) => {
this.cloudflareSettings = settings || this.cloudflareSettings;
})
);
register(
this.r2Service.on("bucketPreview", (detail = {}) => {
this.renderCloudflareBucketPreview(detail);
})
);
}
open() {
if (!this.root) {
return;
}
if (!this.root.classList.contains("hidden")) {
this.isVisible = true;
return;
}
this.root.classList.remove("hidden");
this.isVisible = true;
this.setGlobalModalState("upload", true);
this.emit("upload:open", { mode: this.activeMode });
}
close() {
if (!this.root) {
return;
}
if (this.root.classList.contains("hidden")) {
this.isVisible = false;
return;
}
this.root.classList.add("hidden");
this.isVisible = false;
this.setGlobalModalState("upload", false);
this.emit("upload:close", { mode: this.activeMode });
}
setMode(mode) {
const normalized = mode === "cloudflare" ? "cloudflare" : "custom";
this.activeMode = normalized;
if (this.customSection) {
if (normalized === "custom") {
this.customSection.classList.remove("hidden");
} else {
this.customSection.classList.add("hidden");
}
}
if (this.cloudflareSection) {
if (normalized === "cloudflare") {
this.cloudflareSection.classList.remove("hidden");
} else {
this.cloudflareSection.classList.add("hidden");
}
}
if (Array.isArray(this.uploadModeButtons)) {
this.uploadModeButtons.forEach((button) => {
if (!button?.dataset) {
return;
}
const isActive = button.dataset.uploadMode === normalized;
button.classList.toggle("bg-blue-500", isActive);
button.classList.toggle("text-white", isActive);
button.classList.toggle("shadow", isActive);
button.classList.toggle("text-gray-300", !isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
}
if (normalized === "cloudflare") {
this.refreshCloudflareBucketPreview();
}
}
handleCustomSubmit() {
const payload = {
title: this.customFormInputs.title?.value?.trim() || "",
url: this.customFormInputs.url?.value?.trim() || "",
magnet: this.customFormInputs.magnet?.value?.trim() || "",
ws: this.customFormInputs.ws?.value?.trim() || "",
xs: this.customFormInputs.xs?.value?.trim() || "",
thumbnail: this.customFormInputs.thumbnail?.value?.trim() || "",
description: this.customFormInputs.description?.value?.trim() || "",
enableComments: this.customFormInputs.enableComments
? !!this.customFormInputs.enableComments.checked
: true,
};
if (this.customFormInputs.isPrivate) {
payload.isPrivate = !!this.customFormInputs.isPrivate.checked;
}
const nip71Metadata = this.nip71FormManager.collectSection("custom");
if (nip71Metadata) {
payload.nip71 = nip71Metadata;
}
this.emit("upload:submit", { payload });
}
collectCloudflareSettingsFormValues() {
return {
accountId: this.r2AccountIdInput?.value?.trim() || "",
accessKeyId: this.r2AccessKeyIdInput?.value?.trim() || "",
secretAccessKey: this.r2SecretAccessKeyInput?.value?.trim() || "",
apiToken: this.r2ApiTokenInput?.value?.trim() || "",
zoneId: this.r2ZoneIdInput?.value?.trim() || "",
baseDomain: this.r2BaseDomainInput?.value || "",
};
}
applyCloudflareStatus(element, message = "", variant = "info") {
if (!element) {
return;
}
element.textContent = message || "";
element.classList.remove(
"text-green-400",
"text-red-400",
"text-yellow-400",
"text-gray-400"
);
if (!message) {
element.classList.add("text-gray-400");
return;
}
let className = "text-gray-400";
if (variant === "success") {
className = "text-green-400";
} else if (variant === "error") {
className = "text-red-400";
} else if (variant === "warning") {
className = "text-yellow-400";
}
element.classList.add(className);
}
renderCloudflareAdvancedVisibility(visible) {
const isVisible = Boolean(visible);
this.cloudflareAdvancedVisible = isVisible;
if (this.cloudflareAdvancedFields) {
if (isVisible) {
this.cloudflareAdvancedFields.classList.remove("hidden");
} else {
this.cloudflareAdvancedFields.classList.add("hidden");
}
}
if (this.cloudflareAdvancedToggle) {
this.cloudflareAdvancedToggle.setAttribute(
"aria-expanded",
isVisible ? "true" : "false"
);
}
if (this.cloudflareAdvancedToggleLabel) {
this.cloudflareAdvancedToggleLabel.textContent = isVisible
? "Hide advanced options"
: "Show advanced options";
}
if (this.cloudflareAdvancedToggleIcon) {
this.cloudflareAdvancedToggleIcon.classList.toggle("rotate-90", isVisible);
}
}
renderCloudflareUploadingState(isUploading) {
if (this.cloudflareUploadButton) {
this.cloudflareUploadButton.disabled = Boolean(isUploading);
this.cloudflareUploadButton.textContent = isUploading
? "Uploading…"
: "Upload to R2 & publish";
}
if (this.cloudflareFileInput) {
this.cloudflareFileInput.disabled = Boolean(isUploading);
}
if (this.cloudflareEnableCommentsInput) {
this.cloudflareEnableCommentsInput.disabled = Boolean(isUploading);
}
}
fillCloudflareSettingsInputs(settings) {
const data = settings || {};
if (this.r2AccountIdInput) {
this.r2AccountIdInput.value = data.accountId || "";
}
if (this.r2AccessKeyIdInput) {
this.r2AccessKeyIdInput.value = data.accessKeyId || "";
}
if (this.r2SecretAccessKeyInput) {
this.r2SecretAccessKeyInput.value = data.secretAccessKey || "";
}
if (this.r2ApiTokenInput) {
this.r2ApiTokenInput.value = data.apiToken || "";
}
if (this.r2ZoneIdInput) {
this.r2ZoneIdInput.value = data.zoneId || "";
}
if (this.r2BaseDomainInput) {
this.r2BaseDomainInput.value = data.baseDomain || "";
}
}
renderCloudflareBucketPreview({ text = "", title = "" } = {}) {
if (!this.cloudflareBucketPreview) {
return;
}
this.cloudflareBucketPreview.textContent = text || "";
if (title) {
this.cloudflareBucketPreview.setAttribute("title", title);
} else {
this.cloudflareBucketPreview.removeAttribute("title");
}
}
async refreshCloudflareBucketPreview() {
if (!this.r2Service?.updateCloudflareBucketPreview) {
return;
}
const pubkey =
(this.getCurrentPubkey && this.getCurrentPubkey()) ||
(this.authService?.getActivePubkey?.() ?? null);
const hasPubkey = Boolean(pubkey);
const npub = hasPubkey ? this.safeEncodeNpub(pubkey) : "";
await this.r2Service.updateCloudflareBucketPreview({ hasPubkey, npub });
}
updateCloudflareProgress(fraction) {
if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) {
this.emit("upload:r2-progress", { fraction: null });
return;
}
if (!Number.isFinite(fraction) || fraction < 0) {
this.cloudflareProgressBar.classList.add("hidden");
this.cloudflareProgressBar.setAttribute("aria-hidden", "true");
this.cloudflareProgressFill.style.width = "0%";
this.cloudflareProgressFill.setAttribute("aria-valuenow", "0");
this.emit("upload:r2-progress", { fraction: null });
return;
}
const clamped = Math.max(0, Math.min(1, fraction));
const percent = Math.round(clamped * 100);
this.cloudflareProgressBar.classList.remove("hidden");
this.cloudflareProgressBar.setAttribute("aria-hidden", "false");
this.cloudflareProgressFill.style.width = `${percent}%`;
this.cloudflareProgressFill.setAttribute("aria-valuenow", `${percent}`);
this.emit("upload:r2-progress", { fraction: clamped });
}
resetCloudflareUploadForm() {
if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = "";
if (this.cloudflareDescriptionInput) this.cloudflareDescriptionInput.value = "";
if (this.cloudflareThumbnailInput) this.cloudflareThumbnailInput.value = "";
if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = "";
if (this.cloudflareWsInput) this.cloudflareWsInput.value = "";
if (this.cloudflareXsInput) this.cloudflareXsInput.value = "";
if (this.cloudflareEnableCommentsInput)
this.cloudflareEnableCommentsInput.checked = true;
if (this.cloudflareFileInput) this.cloudflareFileInput.value = "";
this.nip71FormManager.resetSection("cloudflare");
this.updateCloudflareProgress(Number.NaN);
}
resetCustomForm() {
if (this.customFormInputs.title) this.customFormInputs.title.value = "";
if (this.customFormInputs.url) this.customFormInputs.url.value = "";
if (this.customFormInputs.magnet) this.customFormInputs.magnet.value = "";
if (this.customFormInputs.ws) this.customFormInputs.ws.value = "";
if (this.customFormInputs.xs) this.customFormInputs.xs.value = "";
if (this.customFormInputs.thumbnail) this.customFormInputs.thumbnail.value = "";
if (this.customFormInputs.description)
this.customFormInputs.description.value = "";
if (this.customFormInputs.enableComments)
this.customFormInputs.enableComments.checked = true;
if (this.customFormInputs.isPrivate)
this.customFormInputs.isPrivate.checked = false;
this.nip71FormManager.resetSection("custom");
}
async loadR2Settings() {
if (!this.r2Service?.loadSettings) {
return null;
}
const settings = await this.r2Service.loadSettings();
this.cloudflareSettings = settings;
return settings;
}
async handleCloudflareSettingsSubmit() {
if (!this.r2Service?.saveSettings) {
return;
}
try {
const formValues = this.collectCloudflareSettingsFormValues();
const saved = await this.r2Service.saveSettings(formValues);
if (saved) {
await this.refreshCloudflareBucketPreview();
}
} catch (error) {
console.error("[UploadModal] Failed to save Cloudflare settings", error);
}
}
async handleCloudflareClearSettings() {
if (!this.r2Service?.clearSettings) {
return;
}
try {
const cleared = await this.r2Service.clearSettings();
if (cleared) {
await this.refreshCloudflareBucketPreview();
}
} catch (error) {
console.error("[UploadModal] Failed to clear Cloudflare settings", error);
}
}
async handleCloudflareUploadSubmit() {
if (!this.r2Service?.uploadVideo) {
return;
}
const pubkey =
(this.getCurrentPubkey && this.getCurrentPubkey()) ||
(this.authService?.getActivePubkey?.() ?? null);
if (!pubkey) {
this.showError("Please login to post a video.");
return;
}
const npub = this.safeEncodeNpub(pubkey) || "";
const file = this.cloudflareFileInput?.files?.[0] || null;
const metadata = {
title: this.cloudflareTitleInput?.value?.trim() || "",
description: this.cloudflareDescriptionInput?.value?.trim() || "",
thumbnail: this.cloudflareThumbnailInput?.value?.trim() || "",
magnet: this.cloudflareMagnetInput?.value?.trim() || "",
ws: this.cloudflareWsInput?.value?.trim() || "",
xs: this.cloudflareXsInput?.value?.trim() || "",
enableComments: this.cloudflareEnableCommentsInput
? !!this.cloudflareEnableCommentsInput.checked
: true,
};
const nip71Metadata = this.nip71FormManager.collectSection("cloudflare");
if (nip71Metadata) {
const imetaList = Array.isArray(nip71Metadata.imeta)
? [...nip71Metadata.imeta]
: [];
const autoImeta = this.buildAutoGeneratedImetaVariant(file);
if (autoImeta && !imetaList.some((variant) => variant?.autoGenerated)) {
imetaList.push(autoImeta);
}
metadata.nip71 = {
...nip71Metadata,
imeta: imetaList,
};
}
try {
await this.r2Service.uploadVideo({
npub,
file,
metadata,
settingsInput: this.collectCloudflareSettingsFormValues(),
publishVideoNote: (payload, options) =>
this.publishVideoNote ? this.publishVideoNote(payload, options) : null,
onReset: () => this.resetCloudflareUploadForm(),
});
} catch (error) {
console.error("[UploadModal] Cloudflare upload failed", error);
}
}
destroy() {
if (Array.isArray(this.r2Unsubscribes)) {
this.r2Unsubscribes.forEach((unsubscribe) => {
try {
if (typeof unsubscribe === "function") {
unsubscribe();
}
} catch (error) {
console.warn("[UploadModal] Failed to cleanup R2 listener", error);
}
});
}
this.r2Unsubscribes = [];
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+782
View File
@@ -0,0 +1,782 @@
// NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features.
const REPEATER_KEYS = ["imeta", "text-track", "segment", "t", "p", "r"];
export class Nip71FormManager {
constructor({ defaultFocusSelector } = {}) {
this.defaultFocusSelector =
typeof defaultFocusSelector === "string"
? defaultFocusSelector
: "[data-nip71-field], [data-nip71-nested-field]";
this.sections = new Map();
}
registerSection(key, root) {
if (!key) {
return null;
}
if (!root) {
this.sections.delete(key);
return null;
}
const store = this.cacheSection(root);
this.sections.set(key, store);
return store;
}
bindSection(key) {
const store = this.getSection(key);
if (!store?.root) {
return;
}
if (store.handlers?.click) {
try {
store.root.removeEventListener("click", store.handlers.click);
} catch (error) {
console.warn("[Nip71FormManager] Failed to detach previous handler", error);
}
}
const handleClick = (event) => {
const addTrigger = event.target?.closest?.("[data-nip71-add]");
if (addTrigger && store.root.contains(addTrigger)) {
event.preventDefault();
const targetKey = addTrigger.dataset?.nip71Add || "";
const entry = this.addRepeaterEntry(key, targetKey);
if (entry) {
this.focusFirstField(entry, store.focusSelector);
}
return;
}
const nestedAddTrigger = event.target?.closest?.("[data-nip71-nested-add]");
if (nestedAddTrigger && store.root.contains(nestedAddTrigger)) {
event.preventDefault();
const nestedKey = nestedAddTrigger.dataset?.nip71NestedAdd || "";
const container = nestedAddTrigger.closest(
`[data-nip71-nested="${nestedKey}"]`
);
const entry = this.addNestedEntry(container, nestedKey);
if (entry) {
this.focusFirstField(
entry,
`[data-nip71-nested-field="${nestedKey}"]`
);
}
return;
}
const removeTrigger = event.target?.closest?.("[data-nip71-remove]");
if (removeTrigger && store.root.contains(removeTrigger)) {
event.preventDefault();
const targetKey = removeTrigger.dataset?.nip71Remove || "";
if (!targetKey) {
return;
}
if (targetKey === "nested") {
const nestedEntry = removeTrigger.closest("[data-nip71-nested-entry]");
this.removeNestedEntry(nestedEntry);
return;
}
this.removeRepeaterEntry(key, targetKey, removeTrigger);
}
};
store.root.addEventListener("click", handleClick);
store.handlers.click = handleClick;
}
resetSection(key) {
const store = this.getSection(key);
if (!store) {
return;
}
if (Array.isArray(store.kindInputs)) {
store.kindInputs.forEach((input) => {
if (!input) {
return;
}
input.checked = Boolean(input.defaultChecked);
});
}
if (store.summaryInput) {
store.summaryInput.value = "";
}
if (store.publishedAtInput) {
store.publishedAtInput.value = "";
}
if (store.altInput) {
store.altInput.value = "";
}
if (store.durationInput) {
store.durationInput.value = "";
}
if (store.contentWarningInput) {
store.contentWarningInput.value = "";
}
if (store.repeaters) {
Object.entries(store.repeaters).forEach(([repeaterKey, repeater]) => {
if (!repeater?.list) {
return;
}
const entries = Array.from(
repeater.list.querySelectorAll(
`[data-nip71-entry="${repeaterKey}"]`
)
);
entries.forEach((entry) => {
if (entry.dataset?.nip71Primary === "true") {
this.resetEntry(entry);
} else {
entry.remove();
}
});
});
}
}
hydrateSection(key, metadata) {
const store = this.getSection(key);
if (!store) {
return;
}
this.resetSection(key);
if (!metadata || typeof metadata !== "object") {
return;
}
if (Array.isArray(store.kindInputs)) {
const targetValue = metadata.kind != null ? `${metadata.kind}`.trim() : "";
if (targetValue) {
store.kindInputs.forEach((input) => {
if (!input) {
return;
}
input.checked = `${input.value}` === targetValue;
});
}
}
if (store.summaryInput) {
store.summaryInput.value = this.toInputValue(metadata.summary);
}
if (store.publishedAtInput) {
const value =
metadata.publishedAt ?? metadata.published_at ?? metadata["published-at"];
store.publishedAtInput.value = this.toInputValue(value);
}
if (store.altInput) {
store.altInput.value = this.toInputValue(metadata.alt);
}
if (store.durationInput) {
const duration = this.normalizeNumber(metadata.duration);
store.durationInput.value = duration != null ? `${duration}` : "";
}
if (store.contentWarningInput) {
const value =
metadata.contentWarning ??
metadata["content-warning"] ??
metadata.content_warning;
store.contentWarningInput.value = this.toInputValue(value);
}
this.hydrateRepeater(key, "imeta", metadata.imeta, (entry, value) => {
if (!value || typeof value !== "object") {
return;
}
this.setFieldValue(entry, "m", value.m);
this.setFieldValue(entry, "dim", value.dim);
this.setFieldValue(entry, "url", value.url);
this.setFieldValue(entry, "x", value.x);
this.hydrateNested(entry, "image", value.image);
this.hydrateNested(entry, "fallback", value.fallback);
this.hydrateNested(entry, "service", value.service);
});
this.hydrateRepeater(
key,
"text-track",
metadata.textTracks ?? metadata["text-track"] ?? metadata.text_track,
(entry, value) => {
if (!value || typeof value !== "object") {
return;
}
this.setFieldValue(entry, "url", value.url);
this.setFieldValue(entry, "type", value.type);
this.setFieldValue(entry, "language", value.language);
}
);
this.hydrateRepeater(key, "segment", metadata.segments, (entry, value) => {
if (!value || typeof value !== "object") {
return;
}
this.setFieldValue(entry, "start", value.start);
this.setFieldValue(entry, "end", value.end);
this.setFieldValue(entry, "title", value.title);
this.setFieldValue(entry, "thumbnail", value.thumbnail);
});
const sanitizedHashtags = this.sanitizeHashtags(
metadata.hashtags ?? metadata.t,
{ dedupe: true }
);
this.hydrateRepeater(key, "t", sanitizedHashtags, (entry, value) => {
this.setFieldValue(entry, "value", this.renderHashtagValue(value));
});
this.hydrateRepeater(
key,
"p",
metadata.participants ?? metadata.p,
(entry, value) => {
if (!value || typeof value !== "object") {
return;
}
this.setFieldValue(entry, "pubkey", value.pubkey ?? value["pubkey"]);
this.setFieldValue(entry, "relay", value.relay ?? value["relay"]);
}
);
this.hydrateRepeater(
key,
"r",
metadata.references ?? metadata.r,
(entry, value) => {
this.setFieldValue(entry, "url", value);
}
);
}
collectSection(key) {
const store = this.getSection(key);
if (!store) {
return null;
}
const kindInput = Array.isArray(store.kindInputs)
? store.kindInputs.find((input) => input?.checked)
: null;
let kind = null;
if (kindInput?.value != null) {
const parsed = Number(kindInput.value);
kind = Number.isFinite(parsed) ? parsed : String(kindInput.value).trim();
}
const summary = this.getTrimmedValue(store.summaryInput);
const publishedAt = this.getTrimmedValue(store.publishedAtInput);
const alt = this.getTrimmedValue(store.altInput);
let duration = null;
if (store.durationInput) {
const rawValue = store.durationInput.value;
if (rawValue !== "" && rawValue != null) {
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
duration = parsed;
}
}
}
const contentWarning = this.getTrimmedValue(store.contentWarningInput);
const imeta = this.collectRepeaterValues(key, "imeta", (entry) => {
const variant = {
m: this.getFieldValue(entry, "m"),
dim: this.getFieldValue(entry, "dim"),
url: this.getFieldValue(entry, "url"),
x: this.getFieldValue(entry, "x"),
image: this.collectNestedValues(entry, "image"),
fallback: this.collectNestedValues(entry, "fallback"),
service: this.collectNestedValues(entry, "service"),
};
const hasContent =
variant.m ||
variant.dim ||
variant.url ||
variant.x ||
variant.image.length > 0 ||
variant.fallback.length > 0 ||
variant.service.length > 0;
return hasContent ? variant : null;
});
const textTracks = this.collectRepeaterValues(
key,
"text-track",
(entry) => {
const track = {
url: this.getFieldValue(entry, "url"),
type: this.getFieldValue(entry, "type"),
language: this.getFieldValue(entry, "language"),
};
const hasContent = track.url || track.type || track.language;
return hasContent ? track : null;
}
);
const segments = this.collectRepeaterValues(key, "segment", (entry) => {
const segment = {
start: this.getFieldValue(entry, "start"),
end: this.getFieldValue(entry, "end"),
title: this.getFieldValue(entry, "title"),
thumbnail: this.getFieldValue(entry, "thumbnail"),
};
const hasContent =
segment.start || segment.end || segment.title || segment.thumbnail;
return hasContent ? segment : null;
});
const hashtags = this.sanitizeHashtags(
this.collectRepeaterValues(key, "t", (entry) => {
const value = this.getFieldValue(entry, "value");
return value || null;
}),
{ dedupe: true }
);
const participants = this.collectRepeaterValues(key, "p", (entry) => {
const participant = {
pubkey: this.getFieldValue(entry, "pubkey"),
relay: this.getFieldValue(entry, "relay"),
};
const hasContent = participant.pubkey || participant.relay;
return hasContent ? participant : null;
});
const references = this.collectRepeaterValues(key, "r", (entry) => {
const url = this.getFieldValue(entry, "url");
return url || null;
});
return {
kind,
summary,
publishedAt,
alt,
duration,
contentWarning,
imeta,
textTracks,
segments,
hashtags,
participants,
references,
};
}
collectRepeaterValues(sectionKey, repeaterKey, mapFn) {
const store = this.getSection(sectionKey);
const repeater = store?.repeaters?.[repeaterKey];
if (!repeater?.list) {
return [];
}
const entries = Array.from(
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
);
const results = [];
entries.forEach((entry) => {
const value = typeof mapFn === "function" ? mapFn(entry) : null;
if (value == null) {
return;
}
if (typeof value === "string") {
if (value.trim()) {
results.push(value.trim());
}
return;
}
results.push(value);
});
return results;
}
sanitizeHashtags(values, { dedupe = false } = {}) {
if (!Array.isArray(values)) {
return [];
}
const results = [];
const seen = new Set();
values.forEach((value) => {
const sanitized = this.sanitizeHashtagValue(value);
if (!sanitized) {
return;
}
const dedupeKey = sanitized.toLowerCase();
if (dedupe) {
if (seen.has(dedupeKey)) {
return;
}
seen.add(dedupeKey);
}
results.push(sanitized);
});
return results;
}
sanitizeHashtagValue(value) {
if (typeof value === "number") {
value = Number.isFinite(value) ? `${value}` : "";
}
if (typeof value !== "string") {
value = `${value ?? ""}`;
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const withoutHash = trimmed.replace(/^#+/, "").trim();
if (!withoutHash) {
return "";
}
return withoutHash.toLowerCase();
}
renderHashtagValue(value) {
const sanitized = this.sanitizeHashtagValue(value);
return sanitized ? `#${sanitized}` : "";
}
collectNestedValues(entry, nestedKey) {
if (!entry || !nestedKey) {
return [];
}
const container = entry.querySelector(
`[data-nip71-nested="${nestedKey}"]`
);
if (!container) {
return [];
}
const fields = Array.from(
container.querySelectorAll(`[data-nip71-nested-field="${nestedKey}"]`)
);
return fields
.map((field) => this.getTrimmedValue(field))
.filter((value) => Boolean(value));
}
addRepeaterEntry(sectionKey, repeaterKey) {
if (!repeaterKey) {
return null;
}
const store = this.getSection(sectionKey);
if (!store?.repeaters || !store.repeaters[repeaterKey]) {
return null;
}
const { list, template } = store.repeaters[repeaterKey];
if (!list || !template) {
return null;
}
const fragment = template.content
? template.content.cloneNode(true)
: template.cloneNode(true);
const entry =
fragment.querySelector?.(`[data-nip71-entry="${repeaterKey}"]`) ||
fragment.firstElementChild ||
null;
list.appendChild(fragment);
return entry;
}
removeRepeaterEntry(sectionKey, repeaterKey, trigger) {
if (!repeaterKey || !trigger) {
return;
}
const store = this.getSection(sectionKey);
if (!store?.repeaters?.[repeaterKey]?.list) {
return;
}
const entry = trigger.closest(`[data-nip71-entry="${repeaterKey}"]`);
if (!entry) {
return;
}
if (entry.dataset?.nip71Primary === "true") {
this.resetEntry(entry);
return;
}
entry.remove();
}
addNestedEntry(container, nestedKey) {
if (!container || !nestedKey) {
return null;
}
const list = container.querySelector(
`[data-nip71-nested-list="${nestedKey}"]`
);
const template = container.querySelector(
`[data-nip71-nested-template="${nestedKey}"]`
);
if (!list || !template) {
return null;
}
const fragment = template.content
? template.content.cloneNode(true)
: template.cloneNode(true);
const entry =
fragment.querySelector?.(`[data-nip71-nested-entry="${nestedKey}"]`) ||
fragment.firstElementChild ||
null;
list.appendChild(fragment);
return entry;
}
removeNestedEntry(entry) {
if (!entry) {
return;
}
entry.remove();
}
resetEntry(entry) {
if (!entry) {
return;
}
const fields = entry.querySelectorAll("[data-nip71-field]");
fields.forEach((field) => {
if (field) {
field.value = "";
}
});
const nestedContainers = entry.querySelectorAll("[data-nip71-nested]");
nestedContainers.forEach((container) => {
if (!container?.dataset?.nip71Nested) {
return;
}
const list = container.querySelector(
`[data-nip71-nested-list="${container.dataset.nip71Nested}"]`
);
if (list) {
list.innerHTML = "";
}
});
}
hydrateRepeater(sectionKey, repeaterKey, rawValues, applyValue) {
const values = Array.isArray(rawValues) ? rawValues : [];
if (!values.length) {
return;
}
const store = this.getSection(sectionKey);
const repeater = store?.repeaters?.[repeaterKey];
if (!repeater?.list) {
return;
}
let entries = Array.from(
repeater.list.querySelectorAll(`[data-nip71-entry="${repeaterKey}"]`)
);
values.forEach((value, index) => {
let entry = entries[index];
if (!entry) {
entry = this.addRepeaterEntry(sectionKey, repeaterKey);
if (entry) {
entries.push(entry);
}
}
if (!entry) {
return;
}
if (typeof applyValue === "function") {
applyValue(entry, value, index);
}
});
}
hydrateNested(entry, nestedKey, rawValues) {
if (!entry) {
return;
}
const values = Array.isArray(rawValues)
? rawValues.filter((value) => this.toInputValue(value))
: [];
if (!values.length) {
return;
}
const container = entry.querySelector(`[data-nip71-nested="${nestedKey}"]`);
if (!container) {
return;
}
values.forEach((value) => {
const nestedEntry = this.addNestedEntry(container, nestedKey);
if (!nestedEntry) {
return;
}
const field = nestedEntry.querySelector(
`[data-nip71-nested-field="${nestedKey}"]`
);
if (field) {
field.value = this.toInputValue(value);
}
});
}
setFieldValue(entry, field, value) {
if (!entry || !field) {
return;
}
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
if (!element) {
return;
}
element.value = this.toInputValue(value);
}
focusFirstField(container, selector) {
if (!container) {
return;
}
const targetSelector = selector || this.defaultFocusSelector;
const target = container.querySelector(targetSelector);
if (target?.focus) {
target.focus();
}
}
getFieldValue(entry, field) {
if (!entry || !field) {
return "";
}
const element = entry.querySelector(`[data-nip71-field="${field}"]`);
return this.getTrimmedValue(element);
}
getTrimmedValue(element) {
if (!element) {
return "";
}
const { value } = element;
if (typeof value === "number") {
return Number.isFinite(value) ? String(value).trim() : "";
}
if (typeof value === "string") {
return value.trim();
}
return String(value ?? "").trim();
}
toInputValue(value) {
if (value == null) {
return "";
}
if (typeof value === "number") {
return Number.isFinite(value) ? `${value}` : "";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return `${value ?? ""}`;
}
normalizeNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
cacheSection(section) {
const repeaters = {};
REPEATER_KEYS.forEach((key) => {
repeaters[key] = this.cacheRepeater(section, key);
});
return {
root: section,
focusSelector: this.defaultFocusSelector,
kindInputs: Array.from(section.querySelectorAll('[data-nip71-input="kind"]')),
publishedAtInput:
section.querySelector('[data-nip71-input="published_at"]') || null,
altInput: section.querySelector('[data-nip71-input="alt"]') || null,
durationInput:
section.querySelector('[data-nip71-input="duration"]') || null,
contentWarningInput:
section.querySelector('[data-nip71-input="content-warning"]') || null,
summaryInput: section.querySelector('[data-nip71-input="summary"]') || null,
repeaters,
handlers: {},
};
}
cacheRepeater(section, key) {
if (!section || !key) {
return null;
}
const repeaterRoot = section.querySelector(
`[data-nip71-repeater="${key}"]`
);
if (!repeaterRoot) {
return null;
}
return {
root: repeaterRoot,
list: repeaterRoot.querySelector(`[data-nip71-list="${key}"]`) || null,
template:
repeaterRoot.querySelector(`[data-nip71-template="${key}"]`) || null,
};
}
getSection(key) {
if (!key) {
return null;
}
return this.sections.get(key) || null;
}
}
+598
View File
@@ -0,0 +1,598 @@
export default class MoreMenuController {
constructor(options = {}) {
const {
document: doc = typeof document !== "undefined" ? document : null,
accessControl = null,
userBlocks = null,
subscriptions = null,
clipboard = typeof navigator !== "undefined" ? navigator.clipboard : null,
isDevMode = false,
callbacks = {},
} = options;
this.document = doc;
this.accessControl = accessControl;
this.userBlocks = userBlocks;
this.subscriptions = subscriptions;
this.clipboard = clipboard;
this.isDevMode = Boolean(isDevMode);
this.callbacks = {
getCurrentVideo: callbacks.getCurrentVideo || (() => null),
getCurrentUserNpub: callbacks.getCurrentUserNpub || (() => null),
getCurrentUserPubkey: callbacks.getCurrentUserPubkey || (() => null),
canCurrentUserManageBlacklist:
callbacks.canCurrentUserManageBlacklist || (() => false),
openCreatorChannel: callbacks.openCreatorChannel || (() => {}),
goToProfile: callbacks.goToProfile || (() => {}),
showError: callbacks.showError || (() => {}),
showSuccess: callbacks.showSuccess || (() => {}),
safeEncodeNpub: callbacks.safeEncodeNpub || (() => ""),
safeDecodeNpub: callbacks.safeDecodeNpub || (() => ""),
buildShareUrlFromEventId:
callbacks.buildShareUrlFromEventId || (() => ""),
handleRemoveHistoryAction:
callbacks.handleRemoveHistoryAction || (() => Promise.resolve()),
handleRepostAction:
callbacks.handleRepostAction || (() => Promise.resolve()),
handleMirrorAction:
callbacks.handleMirrorAction || (() => Promise.resolve()),
handleEnsurePresenceAction:
callbacks.handleEnsurePresenceAction || (() => Promise.resolve()),
loadVideos: callbacks.loadVideos || (() => Promise.resolve()),
onUserBlocksUpdated: callbacks.onUserBlocksUpdated || (() => {}),
};
this.moreMenuGlobalHandlerBound = false;
this.boundDocumentClick = null;
this.boundDocumentKeydown = null;
this.videoListView = null;
this.videoModal = null;
this.boundVideoListShareListener = null;
this.boundVideoListContextListener = null;
this.boundVideoListBlacklistHandler = null;
}
setVideoModal(videoModal) {
this.videoModal = videoModal || null;
}
attachVideoListView(view) {
if (view === this.videoListView) {
return;
}
this.detachVideoListView();
if (!view) {
return;
}
this.videoListView = view;
this.boundVideoListBlacklistHandler = ({ video, dataset }) => {
const detail = {
...(dataset || {}),
author: dataset?.author || video?.pubkey || "",
};
this.handleMoreMenuAction("blacklist-author", detail);
};
if (typeof view.setBlacklistHandler === "function") {
view.setBlacklistHandler(this.boundVideoListBlacklistHandler);
}
this.boundVideoListShareListener = (event) => {
const detail = event?.detail || {};
const dataset = {
...(detail.dataset || {}),
eventId:
detail.eventId ||
detail.dataset?.eventId ||
detail.video?.id ||
"",
context: detail.dataset?.context || "card",
};
const action = detail.action || "copy-link";
this.handleMoreMenuAction(action, dataset);
};
if (typeof view.addEventListener === "function") {
view.addEventListener("video:share", this.boundVideoListShareListener);
}
this.boundVideoListContextListener = (event) => {
const detail = event?.detail || {};
const dataset = {
...(detail.dataset || {}),
eventId: detail.dataset?.eventId || detail.video?.id || "",
};
this.handleMoreMenuAction(detail.action, dataset);
};
if (typeof view.addEventListener === "function") {
view.addEventListener(
"video:context-action",
this.boundVideoListContextListener,
);
}
}
detachVideoListView() {
if (!this.videoListView) {
return;
}
if (
this.boundVideoListShareListener &&
typeof this.videoListView.removeEventListener === "function"
) {
this.videoListView.removeEventListener(
"video:share",
this.boundVideoListShareListener,
);
}
if (
this.boundVideoListContextListener &&
typeof this.videoListView.removeEventListener === "function"
) {
this.videoListView.removeEventListener(
"video:context-action",
this.boundVideoListContextListener,
);
}
if (typeof this.videoListView.setBlacklistHandler === "function") {
this.videoListView.setBlacklistHandler(null);
}
this.boundVideoListShareListener = null;
this.boundVideoListContextListener = null;
this.boundVideoListBlacklistHandler = null;
this.videoListView = null;
}
destroy() {
this.detachVideoListView();
this.setVideoModal(null);
if (this.document && this.boundDocumentClick) {
this.document.removeEventListener("click", this.boundDocumentClick);
}
if (this.document && this.boundDocumentKeydown) {
this.document.removeEventListener("keydown", this.boundDocumentKeydown);
}
this.boundDocumentClick = null;
this.boundDocumentKeydown = null;
this.moreMenuGlobalHandlerBound = false;
}
ensureGlobalMoreMenuHandlers() {
if (this.moreMenuGlobalHandlerBound) {
return;
}
if (!this.document) {
return;
}
this.moreMenuGlobalHandlerBound = true;
this.boundDocumentClick = (event) => {
const target = event?.target;
if (!target || typeof target.closest !== "function") {
this.closeAllMoreMenus();
return;
}
if (
target.closest("[data-more-menu-wrapper]") ||
target.closest("[data-more-menu]")
) {
return;
}
this.closeAllMoreMenus();
};
this.boundDocumentKeydown = (event) => {
if (event?.key === "Escape") {
this.closeAllMoreMenus();
}
};
this.document.addEventListener("click", this.boundDocumentClick);
this.document.addEventListener("keydown", this.boundDocumentKeydown);
}
closeAllMoreMenus() {
if (
this.videoListView &&
typeof this.videoListView.closeAllMenus === "function"
) {
this.videoListView.closeAllMenus();
}
if (!this.document || typeof this.document.querySelectorAll !== "function") {
return;
}
const HTMLElementCtor =
this.document?.defaultView?.HTMLElement ||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
const menus = this.document.querySelectorAll("[data-more-menu]");
menus.forEach((menu) => {
if (!HTMLElementCtor || menu instanceof HTMLElementCtor) {
menu.classList.add("hidden");
}
});
const buttons = this.document.querySelectorAll("[data-more-dropdown]");
buttons.forEach((button) => {
if (!HTMLElementCtor || button instanceof HTMLElementCtor) {
button.setAttribute("aria-expanded", "false");
}
});
}
attachMoreMenuHandlers(container) {
if (!container || typeof container.querySelectorAll !== "function") {
return;
}
if (!this.document) {
return;
}
const buttons = container.querySelectorAll("[data-more-dropdown]");
if (!buttons.length) {
return;
}
this.ensureGlobalMoreMenuHandlers();
const HTMLElementCtor =
this.document?.defaultView?.HTMLElement ||
(typeof HTMLElement !== "undefined" ? HTMLElement : null);
buttons.forEach((button) => {
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
return;
}
if (button.dataset.moreMenuToggleBound === "true") {
return;
}
button.dataset.moreMenuToggleBound = "true";
button.setAttribute("aria-expanded", "false");
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const key = button.getAttribute("data-more-dropdown") || "";
const dropdown = this.document.getElementById(`moreDropdown-${key}`);
if (HTMLElementCtor && !(dropdown instanceof HTMLElementCtor)) {
return;
}
const willOpen = dropdown.classList.contains("hidden");
this.closeAllMoreMenus();
if (willOpen) {
dropdown.classList.remove("hidden");
button.setAttribute("aria-expanded", "true");
}
});
});
const actionButtons = container.querySelectorAll(
"[data-more-menu] button[data-action]",
);
actionButtons.forEach((button) => {
if (HTMLElementCtor && !(button instanceof HTMLElementCtor)) {
return;
}
if (button.dataset.moreMenuActionBound === "true") {
return;
}
button.dataset.moreMenuActionBound = "true";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const { action } = button.dataset;
this.handleMoreMenuAction(action, button.dataset);
this.closeAllMoreMenus();
});
});
}
syncModalMoreMenuData() {
if (!this.videoModal || typeof this.videoModal.syncMoreMenuData !== "function") {
return;
}
const currentVideo = this.callbacks.getCurrentVideo();
const canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist();
this.videoModal.syncMoreMenuData({
currentVideo,
canManageBlacklist,
});
}
async handleMoreMenuAction(action, dataset = {}) {
const normalized = typeof action === "string" ? action.trim() : "";
const context = dataset.context || "";
const currentVideo = this.callbacks.getCurrentVideo();
switch (normalized) {
case "open-channel": {
if (context === "modal") {
this.callbacks.openCreatorChannel();
break;
}
const author =
dataset.author || (currentVideo ? currentVideo.pubkey : "");
if (author) {
this.callbacks.goToProfile(author);
} else {
this.callbacks.showError("No creator info available.");
}
break;
}
case "copy-link": {
const eventId =
dataset.eventId || (context === "modal" ? currentVideo?.id : "");
if (!eventId) {
this.callbacks.showError("Could not generate link.");
break;
}
const shareUrl = this.callbacks.buildShareUrlFromEventId(eventId);
if (!shareUrl) {
this.callbacks.showError("Could not generate link.");
break;
}
if (!this.clipboard || typeof this.clipboard.writeText !== "function") {
this.callbacks.showError("Failed to copy the link.");
break;
}
this.clipboard
.writeText(shareUrl)
.then(() =>
this.callbacks.showSuccess("Video link copied to clipboard!"),
)
.catch(() => this.callbacks.showError("Failed to copy the link."));
break;
}
case "remove-history": {
await this.callbacks.handleRemoveHistoryAction(dataset);
break;
}
case "repost-event": {
await this.callbacks.handleRepostAction(dataset);
break;
}
case "mirror-video": {
await this.callbacks.handleMirrorAction(dataset);
break;
}
case "ensure-presence": {
await this.callbacks.handleEnsurePresenceAction(dataset);
break;
}
case "copy-npub": {
const explicitNpub =
typeof dataset.npub === "string" && dataset.npub.trim()
? dataset.npub.trim()
: "";
const authorCandidate = dataset.author || "";
const fallbackNpub = this.callbacks.safeEncodeNpub(authorCandidate);
const valueToCopy = explicitNpub || fallbackNpub;
if (!valueToCopy) {
this.callbacks.showError("No npub available to copy.");
break;
}
if (!this.clipboard || typeof this.clipboard.writeText !== "function") {
this.callbacks.showError("Failed to copy the npub.");
break;
}
try {
await this.clipboard.writeText(valueToCopy);
this.callbacks.showSuccess("Channel npub copied to clipboard!");
} catch (error) {
console.error("Failed to copy npub:", error);
this.callbacks.showError("Failed to copy the npub.");
}
break;
}
case "blacklist-author": {
const actorNpub = this.callbacks.getCurrentUserNpub();
if (!actorNpub) {
this.callbacks.showError(
"Please login as a moderator to manage the blacklist.",
);
break;
}
try {
await this.accessControl?.ensureReady?.();
} catch (error) {
console.warn(
"Failed to refresh moderation state before blacklisting:",
error,
);
}
if (!this.accessControl?.canEditAdminLists?.(actorNpub)) {
this.callbacks.showError("Only moderators can manage the blacklist.");
break;
}
let author = dataset.author || "";
if (!author && context === "modal" && currentVideo?.pubkey) {
author = currentVideo.pubkey;
}
if (!author && currentVideo?.pubkey) {
author = currentVideo.pubkey;
}
const explicitNpub =
typeof dataset.npub === "string" && dataset.npub.trim()
? dataset.npub.trim()
: "";
const targetNpub = explicitNpub || this.callbacks.safeEncodeNpub(author);
if (!targetNpub) {
this.callbacks.showError("Unable to determine the creator npub.");
break;
}
try {
const result = await this.accessControl.addToBlacklist(
actorNpub,
targetNpub,
);
if (result?.ok) {
this.callbacks.showSuccess("Creator added to the blacklist.");
try {
await this.subscriptions?.refreshActiveFeed?.({
reason: "admin-blacklist-update",
});
} catch (error) {
if (this.isDevMode) {
console.warn(
"[Subscriptions] Failed to refresh after blacklist update:",
error,
);
}
}
} else {
const code = result?.error || "unknown";
switch (code) {
case "self":
this.callbacks.showError("You cannot blacklist yourself.");
break;
case "immutable":
this.callbacks.showError(
"Moderators cannot blacklist the super admin or fellow moderators.",
);
break;
case "invalid npub":
this.callbacks.showError("Unable to blacklist this creator.");
break;
case "forbidden":
this.callbacks.showError("Only moderators can manage the blacklist.");
break;
default:
this.callbacks.showError(
"Failed to update the blacklist. Please try again.",
);
break;
}
}
} catch (error) {
console.error("Failed to add creator to blacklist:", error);
this.callbacks.showError(
"Failed to update the blacklist. Please try again.",
);
}
break;
}
case "block-author": {
const currentUserPubkey = this.callbacks.getCurrentUserPubkey();
if (!currentUserPubkey) {
this.callbacks.showError("Please login to manage your block list.");
break;
}
const authorCandidate =
dataset.author || (currentVideo && currentVideo.pubkey) || "";
const trimmed =
typeof authorCandidate === "string" ? authorCandidate.trim() : "";
if (!trimmed) {
this.callbacks.showError("Unable to determine the creator to block.");
break;
}
let normalizedHex = "";
if (trimmed.startsWith("npub1")) {
normalizedHex = this.callbacks.safeDecodeNpub(trimmed) || "";
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
normalizedHex = trimmed.toLowerCase();
}
if (!normalizedHex) {
this.callbacks.showError("Unable to determine the creator to block.");
break;
}
if (normalizedHex === currentUserPubkey) {
this.callbacks.showError("You cannot block yourself.");
break;
}
try {
await this.userBlocks?.ensureLoaded?.(currentUserPubkey);
if (this.userBlocks?.isBlocked?.(normalizedHex)) {
this.callbacks.showSuccess("You already blocked this creator.");
} else {
await this.userBlocks?.addBlock?.(normalizedHex, currentUserPubkey);
this.callbacks.showSuccess(
"Creator blocked. You won't see their videos anymore.",
);
}
try {
this.callbacks.onUserBlocksUpdated();
} catch (error) {
console.warn(
"[MoreMenu] Failed to refresh blocked list after update:",
error,
);
}
await this.callbacks.loadVideos();
try {
await this.subscriptions?.refreshActiveFeed?.({
reason: "user-block-update",
});
} catch (error) {
if (this.isDevMode) {
console.warn(
"[Subscriptions] Failed to refresh after user block update:",
error,
);
}
}
} catch (error) {
console.error("Failed to update personal block list:", error);
const message =
error?.code === "nip04-missing"
? "Your Nostr extension must support NIP-04 to manage private lists."
: "Failed to update your block list. Please try again.";
this.callbacks.showError(message);
}
break;
}
case "report": {
this.callbacks.showSuccess("Reporting coming soon.");
break;
}
default:
break;
}
}
}
File diff suppressed because it is too large. Load diff
+996
View File
@@ -0,0 +1,996 @@
import { VideoCard } from "../components/VideoCard.js";
import {
subscribeToVideoViewCount,
unsubscribeFromVideoViewCount,
} from "../../viewCounter.js";
const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__";
export class VideoListView {
constructor(options = {}) {
const {
document: doc = typeof document !== "undefined" ? document : null,
container = null,
mediaLoader = null,
badgeHelpers = {},
formatters = {},
helpers = {},
assets = {},
state = {},
utils = {},
renderers = {},
} = options;
this.document = doc;
this.window = this.document?.defaultView ||
(typeof window !== "undefined" ? window : null);
this.container = null;
this.mediaLoader = mediaLoader;
this.badgeHelpers = {
attachHealthBadges: badgeHelpers.attachHealthBadges || (() => {}),
attachUrlHealthBadges:
badgeHelpers.attachUrlHealthBadges || (() => {}),
};
this.formatters = {
formatTimeAgo: formatters.formatTimeAgo || ((timestamp) => timestamp),
formatViewCountLabel:
formatters.formatViewCountLabel || ((total) => `${total}`),
};
this.helpers = {
escapeHtml: helpers.escapeHtml || ((value) => value),
isMagnetSupported: helpers.isMagnetSupported || (() => false),
toLocaleString: helpers.toLocaleString || ((value) => value),
};
this.assets = {
fallbackThumbnailSrc: assets.fallbackThumbnailSrc || "",
unsupportedBtihMessage: assets.unsupportedBtihMessage || "",
};
this.state = {
loadedThumbnails:
state.loadedThumbnails instanceof Map ? state.loadedThumbnails : new Map(),
videosMap: state.videosMap instanceof Map ? state.videosMap : new Map(),
urlHealthByVideoId:
state.urlHealthByVideoId instanceof Map
? state.urlHealthByVideoId
: new Map(),
streamHealthByVideoId:
state.streamHealthByVideoId instanceof Map
? state.streamHealthByVideoId
: new Map(),
feedMetadata:
state.feedMetadata && typeof state.feedMetadata === "object"
? { ...state.feedMetadata }
: null,
};
this.renderers = {
getLoadingMarkup:
typeof renderers.getLoadingMarkup === "function"
? renderers.getLoadingMarkup
: () => "",
};
this.utils = {
dedupeVideos:
typeof utils.dedupeVideos === "function"
? utils.dedupeVideos
: (videos) => (Array.isArray(videos) ? [...videos] : []),
getAllEvents:
typeof utils.getAllEvents === "function" ? utils.getAllEvents : () => [],
hasOlderVersion:
typeof utils.hasOlderVersion === "function"
? utils.hasOlderVersion
: () => false,
derivePointerInfo:
typeof utils.derivePointerInfo === "function"
? utils.derivePointerInfo
: () => null,
persistWatchHistoryMetadata:
typeof utils.persistWatchHistoryMetadata === "function"
? utils.persistWatchHistoryMetadata
: () => {},
getShareUrlBase:
typeof utils.getShareUrlBase === "function"
? utils.getShareUrlBase
: () => "",
buildShareUrlFromNevent:
typeof utils.buildShareUrlFromNevent === "function"
? utils.buildShareUrlFromNevent
: () => "",
buildShareUrlFromEventId:
typeof utils.buildShareUrlFromEventId === "function"
? utils.buildShareUrlFromEventId
: () => "",
getKnownVideoPostedAt:
typeof utils.getKnownVideoPostedAt === "function"
? utils.getKnownVideoPostedAt
: () => null,
resolveVideoPostedAt:
typeof utils.resolveVideoPostedAt === "function"
? utils.resolveVideoPostedAt
: null,
canManageBlacklist:
typeof utils.canManageBlacklist === "function"
? utils.canManageBlacklist
: () => false,
canEditVideo:
typeof utils.canEditVideo === "function" ? utils.canEditVideo : () => false,
canDeleteVideo:
typeof utils.canDeleteVideo === "function"
? utils.canDeleteVideo
: (video) => this.utils.canEditVideo(video),
batchFetchProfiles:
typeof utils.batchFetchProfiles === "function"
? utils.batchFetchProfiles
: () => {},
bindThumbnailFallbacks:
typeof utils.bindThumbnailFallbacks === "function"
? utils.bindThumbnailFallbacks
: () => {},
handleUrlHealthBadge:
typeof utils.handleUrlHealthBadge === "function"
? utils.handleUrlHealthBadge
: () => {},
refreshDiscussionCounts:
typeof utils.refreshDiscussionCounts === "function"
? utils.refreshDiscussionCounts
: () => {},
ensureGlobalMoreMenuHandlers:
typeof utils.ensureGlobalMoreMenuHandlers === "function"
? utils.ensureGlobalMoreMenuHandlers
: () => {},
closeAllMenus:
typeof utils.closeAllMenus === "function" ? utils.closeAllMenus : () => {},
};
this.renderedVideoIds = new Set();
this.videoCardInstances = [];
this.viewCountSubscriptions = new Map();
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
this.handlers = {
playback: null,
edit: null,
revert: null,
delete: null,
blacklist: null,
};
this.emitter = typeof EventTarget !== "undefined" ? new EventTarget() : null;
this._boundClickHandler = this.handleContainerClick.bind(this);
if (container) {
this.setContainer(container);
}
}
addEventListener(type, listener, options) {
if (!this.emitter) return;
this.emitter.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
if (!this.emitter) return;
this.emitter.removeEventListener(type, listener, options);
}
dispatchEvent(event) {
if (!this.emitter) {
return false;
}
return this.emitter.dispatchEvent(event);
}
setContainer(container) {
if (container === this.container) {
return;
}
if (this.container) {
this.container.removeEventListener("click", this._boundClickHandler);
}
this.container = container || null;
if (this.container) {
this.container.addEventListener("click", this._boundClickHandler);
}
}
mount(container) {
this.setContainer(container);
return this.container;
}
unmount() {
this.setContainer(null);
}
showLoading(message = "") {
if (!this.container) {
return;
}
const markup = this.renderers.getLoadingMarkup(message);
if (typeof markup === "string") {
this.container.innerHTML = markup;
}
}
destroy() {
this.unmount();
this.teardownAllViewCountSubscriptions();
this.renderedVideoIds.clear();
this.videoCardInstances = [];
this.currentVideos = [];
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = null;
}
setPlaybackHandler(handler) {
this.handlers.playback = typeof handler === "function" ? handler : null;
}
setEditHandler(handler) {
this.handlers.edit = typeof handler === "function" ? handler : null;
}
setRevertHandler(handler) {
this.handlers.revert = typeof handler === "function" ? handler : null;
}
setDeleteHandler(handler) {
this.handlers.delete = typeof handler === "function" ? handler : null;
}
setBlacklistHandler(handler) {
this.handlers.blacklist = typeof handler === "function" ? handler : null;
}
render(videos, metadata = null) {
if (!this.container) {
return [];
}
if (this._lastRenderedVideoListElement !== this.container) {
this.lastRenderedVideoSignature = null;
this._lastRenderedVideoListElement = this.container;
}
const normalizedMetadata =
metadata && typeof metadata === "object" ? { ...metadata } : null;
this.state.feedMetadata = normalizedMetadata;
const source = Array.isArray(videos) ? videos : [];
const dedupedVideos = this.utils.dedupeVideos(source);
const displayVideos = dedupedVideos.filter(
(video) => this.utils.canEditVideo(video) || !video?.isPrivate
);
this.syncViewCountSubscriptions(displayVideos);
this.cleanupThumbnailCache(displayVideos);
this.cleanupHealthCache(displayVideos);
if (!displayVideos.length) {
this.renderedVideoIds.clear();
this.videoCardInstances = [];
if (this.lastRenderedVideoSignature === EMPTY_VIDEO_LIST_SIGNATURE) {
return displayVideos;
}
this.lastRenderedVideoSignature = EMPTY_VIDEO_LIST_SIGNATURE;
this.container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one!
</p>`;
return displayVideos;
}
displayVideos.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
this.currentVideos = displayVideos.slice();
const signaturePayload = displayVideos.map((video) => ({
id: typeof video?.id === "string" ? video.id : "",
createdAt: Number.isFinite(video?.created_at)
? video.created_at
: Number(video?.created_at) || 0,
deleted: Boolean(video?.deleted),
isPrivate: Boolean(video?.isPrivate),
thumbnail: typeof video?.thumbnail === "string" ? video.thumbnail : "",
url: typeof video?.url === "string" ? video.url : "",
magnet: typeof video?.magnet === "string" ? video.magnet : "",
enableComments: video?.enableComments === false ? false : true,
}));
const signature = JSON.stringify(signaturePayload);
if (signature === this.lastRenderedVideoSignature) {
return displayVideos;
}
this.lastRenderedVideoSignature = signature;
const previouslyRenderedIds = new Set(this.renderedVideoIds);
this.renderedVideoIds.clear();
const allEvents = this.utils.getAllEvents();
const fragment = this.document?.createDocumentFragment?.();
if (!fragment) {
return displayVideos;
}
const authorSet = new Set();
const shareBase = this.computeShareBase();
const canManageBlacklist = this.utils.canManageBlacklist();
this.videoCardInstances = [];
displayVideos.forEach((video, index) => {
if (!video || !video.id || !video.title) {
return;
}
authorSet.add(video.pubkey);
const shareUrl = this.buildShareUrl(video, shareBase);
const canEdit = this.utils.canEditVideo(video);
const canDelete = this.utils.canDeleteVideo(video);
const highlightClass =
canEdit && video.isPrivate ? "video-card--owner-private" : "";
const isNewlyRendered = !previouslyRenderedIds.has(video.id);
const animationClass = isNewlyRendered ? "video-card--enter" : "";
const knownPostedAt = this.utils.getKnownVideoPostedAt(video);
const normalizedPostedAt = Number.isFinite(knownPostedAt)
? Math.floor(knownPostedAt)
: null;
const fallbackTimestamp =
normalizedPostedAt !== null
? normalizedPostedAt
: Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: null;
const timeAgo =
fallbackTimestamp !== null
? this.formatters.formatTimeAgo(fallbackTimestamp)
: "";
let hasOlder = false;
if (canEdit && video.videoRootId) {
hasOlder = this.utils.hasOlderVersion(video, allEvents);
}
const pointerInfo = this.utils.derivePointerInfo(video);
if (pointerInfo) {
this.utils.persistWatchHistoryMetadata(video, pointerInfo);
}
const videoCard = new VideoCard({
document: this.document,
video,
index,
shareUrl,
pointerInfo,
timeAgo,
postedAt: normalizedPostedAt,
highlightClass,
animationClass,
capabilities: {
canEdit,
canDelete,
canRevert: hasOlder,
canManageBlacklist,
},
helpers: {
escapeHtml: this.helpers.escapeHtml,
isMagnetSupported: this.helpers.isMagnetSupported,
toLocaleString: this.helpers.toLocaleString,
},
assets: {
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
unsupportedBtihMessage: this.assets.unsupportedBtihMessage,
},
state: {
loadedThumbnails: this.state.loadedThumbnails,
urlHealthByVideoId: this.state.urlHealthByVideoId,
streamHealthByVideoId: this.state.streamHealthByVideoId,
},
ensureGlobalMoreMenuHandlers: () =>
this.utils.ensureGlobalMoreMenuHandlers(),
onRequestCloseAllMenus: () => this.closeAllMenus(),
formatters: {
formatTimeAgo: this.formatters.formatTimeAgo,
},
});
videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => {
const trigger = domEvent?.currentTarget || domEvent?.target;
const detail = this.extractPlaybackDetail(trigger, cardVideo || video);
this.emitSelected(detail);
};
videoCard.onEdit = ({ video: editVideo, index: editIndex }) => {
if (this.handlers.edit) {
this.handlers.edit({ video: editVideo, index: editIndex });
}
};
videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => {
if (this.handlers.revert) {
this.handlers.revert({ video: revertVideo, index: revertIndex });
}
};
videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => {
if (this.handlers.delete) {
this.handlers.delete({ video: deleteVideo, index: deleteIndex });
}
};
videoCard.onMoreAction = ({ dataset }) => {
const action = dataset?.action || "";
if (action === "blacklist-author") {
if (this.handlers.blacklist) {
this.handlers.blacklist({ video, dataset });
}
return;
}
if (action === "copy-link") {
const shareDetail = this.buildShareDetail(video, dataset);
this.emitShare(shareDetail);
return;
}
this.emitContextAction(action, { video, dataset });
};
videoCard.onAuthorNavigate = ({ pubkey }) => {
this.emitContextAction("open-channel", {
video,
dataset: { author: pubkey || video.pubkey || "", context: "card" },
});
};
const cardEl = videoCard.getRoot();
if (!cardEl) {
return;
}
if (pointerInfo) {
this.registerVideoViewCountElement(cardEl, pointerInfo);
}
if (video && video.id) {
this.state.videosMap.set(video.id, video);
}
fragment.appendChild(cardEl);
this.renderedVideoIds.add(video.id);
this.videoCardInstances.push(videoCard);
const shouldResolvePostedAt =
normalizedPostedAt === null &&
typeof this.utils.resolveVideoPostedAt === "function";
if (shouldResolvePostedAt) {
Promise.resolve(this.utils.resolveVideoPostedAt(video))
.then((resolvedPostedAt) => {
if (!Number.isFinite(resolvedPostedAt)) {
return;
}
if (!this.videoCardInstances.includes(videoCard)) {
return;
}
if (videoCard.video?.id !== video.id) {
return;
}
videoCard.updatePostedAt(Math.floor(resolvedPostedAt));
})
.catch((error) => {
if (this.window?.console?.warn) {
this.window.console.warn(
"[VideoListView] Failed to resolve posted timestamp:",
error
);
}
});
}
});
this.container.innerHTML = "";
this.container.appendChild(fragment);
this.badgeHelpers.attachHealthBadges(this.container);
this.badgeHelpers.attachUrlHealthBadges(this.container, ({ badgeEl, url, eventId }) => {
this.utils.handleUrlHealthBadge({
video: this.state.videosMap.get(eventId) || { id: eventId },
url,
badgeEl,
});
});
this.utils.bindThumbnailFallbacks(this.container);
if (this.mediaLoader && typeof this.mediaLoader.observe === "function") {
const lazyEls = this.container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => this.mediaLoader.observe(el));
}
if (authorSet.size) {
this.utils.batchFetchProfiles(authorSet);
}
this.utils.refreshDiscussionCounts(displayVideos, {
container: this.container,
});
this.pruneDetachedViewCountElements();
return displayVideos;
}
cacheUrlHealth(videoId, entry) {
if (!videoId || !(this.state.urlHealthByVideoId instanceof Map)) {
return;
}
if (!entry || typeof entry !== "object") {
this.state.urlHealthByVideoId.delete(videoId);
return;
}
const normalized = {
status:
typeof entry.status === "string" && entry.status ? entry.status : "checking",
message:
typeof entry.message === "string" && entry.message
? entry.message
: "Checking hosted URL…",
};
if (Number.isFinite(entry.lastCheckedAt)) {
normalized.lastCheckedAt = Math.floor(entry.lastCheckedAt);
}
this.state.urlHealthByVideoId.set(videoId, normalized);
}
cacheStreamHealth(videoId, entry) {
if (!videoId || !(this.state.streamHealthByVideoId instanceof Map)) {
return;
}
if (!entry || typeof entry !== "object") {
this.state.streamHealthByVideoId.delete(videoId);
return;
}
const normalized = {
state:
typeof entry.state === "string" && entry.state ? entry.state : "unknown",
};
if (Number.isFinite(entry.peers)) {
normalized.peers = Math.max(0, Number(entry.peers));
}
if (typeof entry.reason === "string" && entry.reason) {
normalized.reason = entry.reason;
}
if (Number.isFinite(entry.checkedAt)) {
normalized.checkedAt = Math.floor(entry.checkedAt);
}
if (typeof entry.text === "string" && entry.text) {
normalized.text = entry.text;
}
if (typeof entry.tooltip === "string" && entry.tooltip) {
normalized.tooltip = entry.tooltip;
}
if (entry.role === "alert" || entry.role === "status") {
normalized.role = entry.role;
}
if (entry.ariaLive === "assertive" || entry.ariaLive === "polite") {
normalized.ariaLive = entry.ariaLive;
}
this.state.streamHealthByVideoId.set(videoId, normalized);
}
cleanupHealthCache(videos) {
const activeIds = new Set();
if (Array.isArray(videos)) {
videos.forEach((video) => {
if (video && typeof video.id === "string" && video.id) {
activeIds.add(video.id);
}
});
}
if (this.state.urlHealthByVideoId instanceof Map) {
Array.from(this.state.urlHealthByVideoId.keys()).forEach((videoId) => {
if (!activeIds.has(videoId)) {
this.state.urlHealthByVideoId.delete(videoId);
}
});
}
if (this.state.streamHealthByVideoId instanceof Map) {
Array.from(this.state.streamHealthByVideoId.keys()).forEach((videoId) => {
if (!activeIds.has(videoId)) {
this.state.streamHealthByVideoId.delete(videoId);
}
});
}
}
updateVideo(videoId, patch) {
if (!videoId) {
return null;
}
const index = this.currentVideos.findIndex((video) => video?.id === videoId);
if (index === -1) {
return null;
}
const current = this.currentVideos[index] || {};
const next =
typeof patch === "function" ? patch({ ...current }) : { ...current, ...patch };
this.currentVideos[index] = next;
this.state.videosMap.set(videoId, next);
this.render(this.currentVideos.slice(), this.state.feedMetadata);
return next;
}
computeShareBase() {
const explicit = this.utils.getShareUrlBase();
if (explicit) {
return explicit;
}
const win = this.window;
if (win?.location) {
const origin = win.location.origin || "";
const pathname = win.location.pathname || "";
if (origin || pathname) {
return `${origin}${pathname}`;
}
if (win.location.href) {
return win.location.href.split(/[?#]/)[0];
}
}
return "";
}
buildShareUrl(video, shareBase) {
if (!video?.id) {
return "#";
}
const win = this.window;
try {
const nevent = win?.NostrTools?.nip19?.neventEncode({ id: video.id });
if (nevent) {
const explicit = this.utils.buildShareUrlFromNevent(nevent);
if (explicit) {
return explicit;
}
if (shareBase) {
return `${shareBase}?v=${encodeURIComponent(nevent)}`;
}
}
} catch (error) {
// ignore and fall back below
}
if (shareBase) {
return `${shareBase}?v=${encodeURIComponent(video.id)}`;
}
return "#";
}
syncViewCountSubscriptions(videos) {
const activePointerKeys = new Set();
videos.forEach((video) => {
const pointerInfo = this.utils.derivePointerInfo(video);
if (pointerInfo?.key) {
activePointerKeys.add(pointerInfo.key);
}
});
const keysToRemove = [];
for (const [key, entry] of this.viewCountSubscriptions.entries()) {
if (!activePointerKeys.has(key)) {
keysToRemove.push(key);
continue;
}
if (entry && entry.elements instanceof Set) {
entry.elements.clear();
}
}
keysToRemove.forEach((key) => {
const entry = this.viewCountSubscriptions.get(key);
if (entry && entry.pointer && entry.token) {
try {
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
} catch (error) {
console.warn(
`[viewCount] Failed to unsubscribe from stale pointer ${key}:`,
error
);
}
}
this.viewCountSubscriptions.delete(key);
});
}
cleanupThumbnailCache(videos) {
const cache = this.state.loadedThumbnails;
if (!cache || !cache.size) {
return;
}
const activeIds = new Set();
videos.forEach((video) => {
if (video && typeof video.id === "string" && video.id) {
activeIds.add(video.id);
}
});
Array.from(cache.keys()).forEach((videoId) => {
if (!activeIds.has(videoId)) {
cache.delete(videoId);
}
});
}
extractPlaybackDetail(trigger, video) {
const element = trigger instanceof HTMLElement ? trigger : null;
const target = element?.closest("[data-play-url],[data-play-magnet]") || element;
const rawUrlValue =
(target?.dataset && typeof target.dataset.playUrl === "string"
? target.dataset.playUrl
: null) ?? target?.getAttribute?.("data-play-url") ?? "";
const rawMagnetValue =
(target?.dataset && typeof target.dataset.playMagnet === "string"
? target.dataset.playMagnet
: null) ?? target?.getAttribute?.("data-play-magnet") ?? "";
let url = "";
if (rawUrlValue) {
try {
url = decodeURIComponent(rawUrlValue);
} catch (error) {
url = rawUrlValue;
}
}
const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : "";
const videoId =
target?.dataset?.videoId || target?.getAttribute?.("data-video-id") || video?.id || "";
return { videoId, url, magnet, video };
}
emitSelected(detail) {
const enriched = {
...detail,
video: detail?.video || this.state.videosMap.get(detail?.videoId) || null,
};
if (this.handlers.playback) {
this.handlers.playback(enriched);
}
this.emit("video:selected", enriched);
}
buildShareDetail(video, dataset = {}) {
const eventId = dataset.eventId || video?.id || "";
const shareUrl = this.utils.buildShareUrlFromEventId(eventId);
return {
action: "copy-link",
video,
eventId,
shareUrl,
dataset,
};
}
emitShare(detail) {
this.emit("video:share", detail);
}
emitContextAction(action, payload = {}) {
if (!action) {
return;
}
this.emit("video:context-action", { action, ...payload });
}
emit(type, detail) {
if (this.emitter) {
this.emitter.dispatchEvent(new CustomEvent(type, { detail }));
}
if (this.container) {
this.container.dispatchEvent(
new CustomEvent(type, { detail, bubbles: true, composed: true })
);
}
}
ensureViewCountSubscription(pointerInfo) {
if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) {
return null;
}
const existing = this.viewCountSubscriptions.get(pointerInfo.key);
if (existing) {
return existing;
}
const entry = {
pointer: pointerInfo.pointer,
key: pointerInfo.key,
token: null,
elements: new Set(),
lastTotal: null,
lastStatus: "idle",
lastText: "– views",
};
try {
const token = subscribeToVideoViewCount(pointerInfo.pointer, ({ total, status }) => {
let text;
if (Number.isFinite(total)) {
const numeric = Number(total);
entry.lastTotal = numeric;
text = this.formatters.formatViewCountLabel(numeric);
} else if (status === "hydrating") {
text = "Loading views…";
} else {
text = "– views";
}
entry.lastStatus = status;
entry.lastText = text;
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
continue;
}
el.textContent = text;
}
});
entry.token = token;
this.viewCountSubscriptions.set(pointerInfo.key, entry);
return entry;
} catch (error) {
console.warn("[viewCount] Failed to subscribe to view counter:", error);
return null;
}
}
registerVideoViewCountElement(cardEl, pointerInfo) {
if (!cardEl || !pointerInfo) {
return;
}
const viewCountEl = cardEl.querySelector("[data-view-count]");
if (!viewCountEl) {
return;
}
if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) {
viewCountEl.textContent = "– views";
}
const entry = this.ensureViewCountSubscription(pointerInfo);
if (!entry) {
return;
}
entry.elements.add(viewCountEl);
if (typeof pointerInfo.key === "string") {
viewCountEl.dataset.viewPointer = pointerInfo.key;
}
viewCountEl.textContent = entry.lastText;
}
pruneDetachedViewCountElements() {
for (const entry of this.viewCountSubscriptions.values()) {
if (!entry || !(entry.elements instanceof Set)) {
continue;
}
for (const el of Array.from(entry.elements)) {
if (!el || !el.isConnected) {
entry.elements.delete(el);
}
}
}
}
teardownAllViewCountSubscriptions() {
const keys = Array.from(this.viewCountSubscriptions.keys());
keys.forEach((key) => {
const entry = this.viewCountSubscriptions.get(key);
if (entry && entry.token && entry.pointer) {
try {
unsubscribeFromVideoViewCount(entry.pointer, entry.token);
} catch (error) {
console.warn(
`[viewCount] Failed to unsubscribe from pointer ${key}:`,
error
);
}
}
this.viewCountSubscriptions.delete(key);
});
}
closeAllMenus() {
if (Array.isArray(this.videoCardInstances) && this.videoCardInstances.length) {
this.videoCardInstances.forEach((card) => {
if (!card) {
return;
}
if (typeof card.closeMoreMenu === "function") {
card.closeMoreMenu();
}
if (typeof card.closeSettingsMenu === "function") {
card.closeSettingsMenu();
}
});
}
if (!this.document) {
return;
}
const menus = this.document.querySelectorAll("[data-more-menu]");
menus.forEach((menu) => {
if (menu instanceof HTMLElement) {
menu.classList.add("hidden");
}
});
const buttons = this.document.querySelectorAll("[data-more-dropdown]");
buttons.forEach((btn) => {
if (btn instanceof HTMLElement) {
btn.setAttribute("aria-expanded", "false");
}
});
}
handleContainerClick(event) {
if (!event || !this.container) {
return;
}
const target = event.target instanceof HTMLElement ? event.target : null;
if (!target) {
return;
}
const trigger = target.closest("[data-play-magnet],[data-play-url]");
if (!trigger || !this.container.contains(trigger)) {
return;
}
const isPrimaryClick =
typeof event.button !== "number" || event.button === 0;
if (!isPrimaryClick || event.ctrlKey || event.metaKey) {
return;
}
event.preventDefault();
const detail = this.extractPlaybackDetail(trigger, null);
this.emitSelected(detail);
}
}
+367
View File
@@ -0,0 +1,367 @@
import { isDevMode } from "../config.js";
import {
normalizePointerInput,
pointerKey as derivePointerKey,
} from "../nostr.js";
const noop = () => {};
function resolveCardElement(trigger) {
if (!(trigger instanceof HTMLElement)) {
return null;
}
const candidate = trigger.closest(".video-card");
return candidate instanceof HTMLElement ? candidate : null;
}
function safeInvoke(callback, payload) {
if (typeof callback !== "function") {
return;
}
try {
callback(payload);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryController] onStateChange handler failed:",
error,
);
}
}
}
export default class WatchHistoryController {
constructor({
watchHistoryService,
nostrClient,
showError,
showSuccess,
dropWatchHistoryMetadata,
getActivePubkey,
} = {}) {
this.watchHistoryService = watchHistoryService || null;
this.nostrClient = nostrClient || null;
this.showError = typeof showError === "function" ? showError : noop;
this.showSuccess = typeof showSuccess === "function" ? showSuccess : noop;
this.dropWatchHistoryMetadata =
typeof dropWatchHistoryMetadata === "function"
? dropWatchHistoryMetadata
: noop;
this.getActivePubkey =
typeof getActivePubkey === "function" ? getActivePubkey : () => "";
}
buildPointerFromDataset(dataset = {}) {
if (!dataset || typeof dataset !== "object") {
return null;
}
const typeValue =
typeof dataset.pointerType === "string" ? dataset.pointerType : "";
const normalizedType = typeValue === "a" ? "a" : typeValue === "e" ? "e" : "";
const value =
typeof dataset.pointerValue === "string" && dataset.pointerValue.trim()
? dataset.pointerValue.trim()
: "";
if (!normalizedType || !value) {
return null;
}
const pointer = { type: normalizedType, value };
if (typeof dataset.pointerRelay === "string" && dataset.pointerRelay.trim()) {
pointer.relay = dataset.pointerRelay.trim();
}
if (typeof dataset.pointerWatchedAt === "string" && dataset.pointerWatchedAt) {
const parsed = Number.parseInt(dataset.pointerWatchedAt, 10);
if (Number.isFinite(parsed)) {
pointer.watchedAt = parsed;
}
}
if (dataset.pointerSession === "true") {
pointer.session = true;
}
return pointer;
}
resolvePointerKey(pointer, explicitKey) {
if (typeof explicitKey === "string" && explicitKey.trim()) {
return explicitKey.trim();
}
const normalized = normalizePointerInput(pointer);
if (!normalized) {
return "";
}
try {
return derivePointerKey(normalized) || "";
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryController] Failed to derive pointer key:",
error,
);
}
return "";
}
}
applyCardState(card, { status, removeCard } = {}) {
if (!(card instanceof HTMLElement)) {
return;
}
if (status === "pending") {
card.dataset.historyRemovalPending = "true";
card.classList.add("opacity-60");
card.classList.add("pointer-events-none");
return;
}
delete card.dataset.historyRemovalPending;
card.classList.remove("opacity-60", "pointer-events-none");
if (status === "removed" && removeCard) {
card.remove();
}
}
resolveActor(actor) {
if (typeof actor === "string" && actor.trim()) {
return actor.trim();
}
const candidate = this.getActivePubkey();
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
const sessionPubkey = this.nostrClient?.sessionActor?.pubkey;
if (typeof sessionPubkey === "string" && sessionPubkey.trim()) {
return sessionPubkey.trim();
}
return "";
}
async removeEntry(options = {}) {
if (!this.watchHistoryService?.isEnabled?.()) {
this.showError("Watch history sync is not available right now.");
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
const {
dataset = {},
pointer: pointerInput = null,
pointerKey: explicitPointerKey = "",
reason: providedReason,
trigger = null,
removeCard = false,
actor,
items,
onStateChange,
} = options;
const pointer = pointerInput || this.buildPointerFromDataset(dataset);
const pointerKey = this.resolvePointerKey(pointer, explicitPointerKey || dataset.pointerKey);
if (!pointerKey) {
this.showError("Unable to determine which history entry to remove.");
return null;
}
const reason =
typeof providedReason === "string" && providedReason.trim()
? providedReason.trim()
: typeof dataset.reason === "string" && dataset.reason.trim()
? dataset.reason.trim()
: "remove-item";
const card = resolveCardElement(trigger);
this.applyCardState(card, { status: "pending" });
safeInvoke(onStateChange, { status: "pending", card, removeCard });
try {
const result = await this.handleWatchHistoryRemoval({
actor,
items,
reason,
removed: {
pointer,
pointerKey,
},
});
this.applyCardState(card, { status: removeCard ? "removed" : "idle", removeCard });
safeInvoke(onStateChange, {
status: removeCard ? "removed" : "idle",
card,
removeCard,
result,
});
return result;
} catch (error) {
this.applyCardState(card, { status: "idle" });
safeInvoke(onStateChange, { status: "error", card, error, removeCard });
if (!error?.handled) {
this.showError("Failed to remove from history. Please try again.");
}
throw error;
}
}
async handleWatchHistoryRemoval(payload = {}) {
if (!this.watchHistoryService?.isEnabled?.()) {
const error = new Error("watch-history-disabled");
error.handled = true;
this.showError("Watch history sync is not available right now.");
throw error;
}
const reason =
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: "remove-item";
const actorCandidate = this.resolveActor(payload.actor);
const removedPointerRaw =
payload?.removed?.pointer || payload?.removed?.raw || payload?.removed || null;
const removedPointerNormalized = normalizePointerInput(removedPointerRaw);
let removedPointerKey =
typeof payload?.removed?.pointerKey === "string"
? payload.removed.pointerKey
: "";
if (!removedPointerKey && removedPointerNormalized) {
removedPointerKey = derivePointerKey(removedPointerNormalized) || "";
}
if (removedPointerKey) {
this.dropWatchHistoryMetadata(removedPointerKey);
}
const normalizeEntry = (entry) => {
if (!entry) {
return null;
}
const pointer = normalizePointerInput(entry.pointer || entry);
if (!pointer) {
return null;
}
if (Number.isFinite(entry.watchedAt)) {
pointer.watchedAt = Math.floor(entry.watchedAt);
} else if (Number.isFinite(entry.pointer?.watchedAt)) {
pointer.watchedAt = Math.floor(entry.pointer.watchedAt);
}
if (entry.pointer?.session === true || entry.session === true) {
pointer.session = true;
}
return pointer;
};
let normalizedItems = null;
if (Array.isArray(payload.items) && payload.items.length) {
normalizedItems = payload.items.map(normalizeEntry).filter(Boolean);
}
if (!normalizedItems) {
try {
const latest = await this.watchHistoryService.loadLatest(
actorCandidate,
{ allowStale: false },
);
normalizedItems = Array.isArray(latest)
? latest.map(normalizeEntry).filter(Boolean)
: [];
} catch (error) {
this.showError("Failed to load watch history. Please try again.");
if (error && typeof error === "object") {
error.handled = true;
}
throw error;
}
}
if (removedPointerKey) {
normalizedItems = normalizedItems.filter((entry) => {
try {
return derivePointerKey(entry) !== removedPointerKey;
} catch (error) {
return true;
}
});
}
this.showSuccess("Removing from history…");
try {
const snapshotResult = await this.watchHistoryService.snapshot(normalizedItems, {
actor: actorCandidate || undefined,
reason,
});
try {
await this.nostrClient?.updateWatchHistoryList?.(normalizedItems, {
actorPubkey: actorCandidate || undefined,
replace: true,
source: reason,
});
} catch (updateError) {
if (isDevMode) {
console.warn(
"[watchHistoryController] Failed to update local watch history list:",
updateError,
);
}
}
this.showSuccess(
"Removed from encrypted history. Relay sync may take a moment.",
);
return { handledToasts: true, snapshot: snapshotResult };
} catch (error) {
let message = "Failed to remove from history. Please try again.";
if (error?.result?.retryable) {
message =
"Removal will retry once encrypted history is accepted by your relays.";
}
this.showError(message);
if (error && typeof error === "object") {
error.handled = true;
}
throw error;
}
}
flush(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryService?.isEnabled?.()) {
return Promise.resolve();
}
try {
const result = this.watchHistoryService.snapshot(undefined, { reason });
return Promise.resolve(result).catch((error) => {
if (isDevMode) {
console.warn(`[${context}] Watch history flush failed:`, error);
}
throw error;
});
} catch (error) {
if (isDevMode) {
console.warn(`[${context}] Failed to queue watch history flush:`, error);
}
return Promise.reject(error);
}
}
}
+988
View File
@@ -0,0 +1,988 @@
import { isDevMode } from "../config.js";
import {
calculateZapShares,
fetchLightningMetadata,
formatMinRequirement,
getCachedLightningEntry,
getCachedMetadataByUrl,
getCachedPlatformLightningAddress,
isMetadataEntryFresh,
normalizeLightningAddressKey,
rememberLightningMetadata,
setCachedPlatformLightningAddress,
validateInvoiceAmount,
} from "../payments/zapSharedState.js";
import {
resolveLightningAddress,
fetchPayServiceData,
requestInvoice,
} from "../payments/lnurl.js";
import { getPlatformLightningAddress } from "../payments/platformAddress.js";
import {
ensureWallet as ensureWalletDefault,
sendPayment as sendPaymentDefault,
} from "../payments/nwcClient.js";
const DEFAULT_SPLIT_SUMMARY = "Enter an amount to view the split.";
export default class ZapController {
constructor({
videoModal,
getCurrentVideo,
nwcSettings,
getActiveNwcSettings,
isUserLoggedIn,
hasActiveWalletConnection,
splitAndZap,
payments = {},
callbacks = {},
requestWalletPane,
} = {}) {
this.videoModal = videoModal || null;
this.getCurrentVideo =
typeof getCurrentVideo === "function" ? getCurrentVideo : () => null;
this.nwcSettings =
nwcSettings &&
typeof nwcSettings === "object" &&
typeof nwcSettings.getActiveNwcSettings === "function"
? nwcSettings
: null;
if (this.nwcSettings) {
this.getActiveNwcSettings = () =>
this.nwcSettings.getActiveNwcSettings();
} else if (typeof getActiveNwcSettings === "function") {
this.getActiveNwcSettings = getActiveNwcSettings;
} else {
this.getActiveNwcSettings = () => ({});
}
this.isUserLoggedIn =
typeof isUserLoggedIn === "function" ? isUserLoggedIn : () => false;
if (
this.nwcSettings &&
typeof this.nwcSettings.hasActiveWalletConnection === "function"
) {
this.hasActiveWalletConnection = () =>
this.nwcSettings.hasActiveWalletConnection();
} else if (typeof hasActiveWalletConnection === "function") {
this.hasActiveWalletConnection = hasActiveWalletConnection;
} else {
this.hasActiveWalletConnection = () => false;
}
this.splitAndZap =
typeof splitAndZap === "function" ? splitAndZap : () => Promise.resolve();
this.payments = payments || {};
this.callbacks = {
onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null,
onError: typeof callbacks.onError === "function" ? callbacks.onError : null,
};
this.requestWalletPane =
typeof requestWalletPane === "function" ? requestWalletPane : null;
this.modalZapInFlight = false;
this.modalZapRetryState = null;
this.modalZapAmountValue = 0;
this.modalZapCommentValue = "";
}
/** Public API **/
setVisibility(visible) {
const lightningVisible = !!visible;
const shouldShow = lightningVisible && this.isUserLoggedIn();
const hasWallet = this.hasActiveWalletConnection();
if (this.videoModal) {
this.videoModal.setZapVisibility(shouldShow);
this.videoModal.setWalletPromptVisible(shouldShow && !hasWallet);
}
}
resetState({ preserveAmount = false } = {}) {
if (!preserveAmount) {
this.modalZapAmountValue = 0;
}
this.modalZapCommentValue = "";
this.modalZapInFlight = false;
this.resetRetryState();
if (this.videoModal) {
this.videoModal.setZapCompleted(false);
this.videoModal.resetZapForm({
amount: preserveAmount ? this.modalZapAmountValue || "" : "",
comment: "",
});
this.videoModal.setZapPending(false);
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
}
}
open() {
this.resetFeedback();
this.applyDefaultAmount();
this.preloadLightningMetadata().catch((error) => {
if (isDevMode) {
console.warn("[zap] Preload metadata error:", error);
}
});
}
close() {
this.resetFeedback();
}
setAmount(amount) {
const rawAmount = Number(amount);
const fallback = this.videoModal?.getZapAmountValue?.() || 0;
const value = Number.isFinite(rawAmount) ? rawAmount : fallback;
this.modalZapAmountValue = Math.max(0, Math.round(value || 0));
this.resetFeedback();
this.updateSplitSummary();
}
setComment(comment) {
if (typeof comment === "string") {
this.modalZapCommentValue = comment.trim();
return;
}
const fallback = this.videoModal?.getZapCommentValue?.();
this.modalZapCommentValue = typeof fallback === "string" ? fallback : "";
}
async sendZap({ amount, comment } = {}) {
if (this.modalZapInFlight) {
return;
}
this.videoModal?.setZapCompleted(false);
const numericAmount = Number.isFinite(Number(amount))
? Number(amount)
: this.modalZapAmountValue;
const roundedAmount = Math.max(0, Math.round(numericAmount || 0));
this.modalZapAmountValue = roundedAmount;
const trimmedComment =
typeof comment === "string" ? comment.trim() : this.modalZapCommentValue;
this.modalZapCommentValue = trimmedComment;
if (!roundedAmount) {
const message = "Enter a zap amount greater than zero.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
return;
}
const walletSettings = this.getWalletSettingsOrPrompt();
if (!walletSettings) {
return;
}
this.modalZapInFlight = true;
this.videoModal?.setZapPending(true);
this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "warning");
this.videoModal?.clearZapReceipts();
try {
if (
Array.isArray(this.modalZapRetryState?.shares) &&
this.modalZapRetryState.shares.length
) {
const retrySuccess = await this.executeRetry({
walletSettings,
comment: trimmedComment,
});
if (retrySuccess) {
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapCompleted(true);
}
this.applyDefaultAmount();
return;
}
const attempt = await this.runZapAttempt({
amount: roundedAmount,
walletSettings,
comment: trimmedComment,
});
if (!attempt) {
this.videoModal?.setZapStatus("", "neutral");
return;
}
const { context, result } = attempt;
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
this.videoModal?.renderZapReceipts(receipts, { partial: false });
const creatorShare = context.shares.creatorShare;
const platformShare = context.shares.platformShare;
const summary = platformShare
? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).`
: `Sent ${context.shares.total} sats to the creator.`;
this.videoModal?.setZapStatus(summary, "success");
this.notifySuccess("Zap sent successfully!");
this.resetRetryState();
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapCompleted(true);
this.applyDefaultAmount();
} catch (error) {
this.handleZapError({ error, amount: roundedAmount, comment: trimmedComment, walletSettings });
} finally {
this.modalZapInFlight = false;
this.videoModal?.setZapPending(false);
}
}
handleWalletLink() {
if (this.requestWalletPane) {
Promise.resolve()
.then(() => this.requestWalletPane())
.catch((error) => {
console.error("Failed to open wallet pane:", error);
});
}
}
/** Internal helpers **/
resetFeedback() {
if (!this.videoModal) {
return;
}
this.videoModal.setZapCompleted(false);
this.videoModal.setZapStatus("", "neutral");
this.videoModal.clearZapReceipts();
this.videoModal.setZapRetryPending(false);
this.videoModal.setZapPending(false);
}
applyDefaultAmount() {
const settings = this.getActiveNwcSettings();
let defaultAmount = 0;
if (Number.isFinite(settings?.defaultZap) && settings.defaultZap > 0) {
defaultAmount = Math.max(0, Math.round(settings.defaultZap));
}
this.modalZapAmountValue = defaultAmount;
if (this.videoModal) {
this.videoModal.setZapAmount(defaultAmount || "");
}
this.updateSplitSummary();
}
updateSplitSummary() {
if (!this.videoModal) {
return;
}
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
if (!amount) {
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
return;
}
const shares = calculateZapShares(amount);
const parts = [];
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
const creatorEntry = getCachedLightningEntry(lightningAddress);
const creatorMin = formatMinRequirement(creatorEntry?.metadata);
let creatorText = `Creator: ${shares.creatorShare} sats`;
if (Number.isFinite(creatorMin) && creatorMin > 0) {
creatorText += ` (min ${creatorMin})`;
}
parts.push(creatorText);
if (shares.platformShare > 0) {
const platformEntry = getCachedLightningEntry(
getCachedPlatformLightningAddress()
);
const platformMin = formatMinRequirement(platformEntry?.metadata);
let platformText = `Platform: ${shares.platformShare} sats`;
if (Number.isFinite(platformMin) && platformMin > 0) {
platformText += ` (min ${platformMin})`;
}
parts.push(platformText);
}
this.videoModal.setZapSplitSummary(parts.join(" • "));
}
async preloadLightningMetadata() {
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
if (lightningAddress) {
try {
await fetchLightningMetadata(lightningAddress);
} catch (error) {
if (isDevMode) {
console.warn("[zap] Failed to preload creator metadata:", error);
}
}
}
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
if (!amount) {
return;
}
const shares = calculateZapShares(amount);
if (shares.platformShare > 0) {
let platformAddress = getCachedPlatformLightningAddress();
if (!platformAddress) {
try {
platformAddress = await getPlatformLightningAddress({
forceRefresh: false,
});
setCachedPlatformLightningAddress(platformAddress || "");
} catch (error) {
if (isDevMode) {
console.warn("[zap] Failed to load platform Lightning address:", error);
}
}
}
if (platformAddress) {
try {
await fetchLightningMetadata(platformAddress);
} catch (error) {
if (isDevMode) {
console.warn("[zap] Failed to preload platform metadata:", error);
}
}
}
}
}
getWalletSettingsOrPrompt() {
const settings = this.getActiveNwcSettings();
const normalizedUri =
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
if (!normalizedUri) {
this.notifyError("Connect a Lightning wallet to send zaps.");
if (this.requestWalletPane) {
this.handleWalletLink();
}
return null;
}
return {
...settings,
nwcUri: normalizedUri,
};
}
createDependencies({ creatorEntry, platformEntry, shares, shareTracker }) {
const summarizeTracker = (tracker) =>
Array.isArray(tracker)
? tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount:
Number.isFinite(entry?.amount) && entry.amount >= 0
? entry.amount
: undefined,
address: entry?.address || undefined,
hasPayment: Boolean(entry?.payment),
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
}))
: undefined;
const logZapError = (stage, details, error) => {
const summary = {
stage,
shareType: details?.shareType,
address: details?.address,
amount:
Number.isFinite(details?.amount) && details.amount >= 0
? details.amount
: undefined,
overrideFee:
typeof details?.overrideFee === "number"
? details.overrideFee
: undefined,
commentLength:
typeof details?.comment === "string"
? details.comment.length
: undefined,
wallet:
details?.walletSettings
? {
hasUri: Boolean(details.walletSettings.nwcUri),
type:
details.walletSettings.type ||
details.walletSettings.name ||
details.walletSettings.client ||
undefined,
}
: undefined,
shares: details?.context?.shares
? {
total: details.context.shares.total,
creator: details.context.shares.creatorShare,
platform: details.context.shares.platformShare,
}
: undefined,
tracker: summarizeTracker(details?.tracker),
retryAttempt:
Number.isInteger(details?.retryAttempt) && details.retryAttempt >= 0
? details.retryAttempt
: undefined,
};
console.error("[zap] Modal zap failure", summary, error);
};
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
const creatorKey = normalizeLightningAddressKey(
creatorEntry?.address || lightningAddress
);
const platformKey = normalizeLightningAddressKey(
platformEntry?.address || getCachedPlatformLightningAddress()
);
const ensureWalletFn =
typeof this.payments?.ensureWallet === "function"
? (options) => this.payments.ensureWallet(options)
: (options) => ensureWalletDefault(options);
const sendPaymentFn =
typeof this.payments?.sendPayment === "function"
? (bolt11, params) => this.payments.sendPayment(bolt11, params)
: (bolt11, params) => sendPaymentDefault(bolt11, params);
let activeShare = null;
return {
lnurl: {
resolveLightningAddress: (value) => {
const normalized = normalizeLightningAddressKey(value);
if (normalized && normalized === creatorKey) {
activeShare = "creator";
if (creatorEntry?.resolved) {
return { ...creatorEntry.resolved };
}
} else if (normalized && normalized === platformKey) {
activeShare = "platform";
if (platformEntry?.resolved) {
return { ...platformEntry.resolved };
}
} else {
activeShare = null;
}
const resolved = resolveLightningAddress(value);
if (normalized) {
rememberLightningMetadata({
key: normalized,
address: resolved.address || value,
resolved,
fetchedAt: Date.now(),
});
}
return resolved;
},
fetchPayServiceData: async (url) => {
const cached = getCachedMetadataByUrl(url);
if (cached?.metadata && isMetadataEntryFresh(cached)) {
return cached.metadata;
}
const metadata = await fetchPayServiceData(url);
if (cached) {
rememberLightningMetadata({
...cached,
metadata,
fetchedAt: Date.now(),
});
}
return metadata;
},
validateInvoiceAmount,
requestInvoice,
},
wallet: {
ensureWallet: async (options) => {
try {
return await ensureWalletFn(options);
} catch (error) {
logZapError(
"wallet.ensureWallet",
{
tracker: shareTracker,
context: { shares },
walletSettings: options?.settings,
},
error
);
throw error;
}
},
sendPayment: async (bolt11, params) => {
try {
const payment = await sendPaymentFn(bolt11, params);
if (Array.isArray(shareTracker)) {
const shareType = activeShare || "unknown";
const amount =
shareType === "platform"
? shares.platformShare
: shares.creatorShare;
const address =
shareType === "platform"
? platformEntry?.address || getCachedPlatformLightningAddress()
: creatorEntry?.address || lightningAddress;
shareTracker.push({
type: shareType,
status: "success",
amount,
address,
payment,
});
}
return payment;
} catch (error) {
if (Array.isArray(shareTracker)) {
const shareType = activeShare || "unknown";
const amount =
shareType === "platform"
? shares.platformShare
: shares.creatorShare;
const address =
shareType === "platform"
? platformEntry?.address || getCachedPlatformLightningAddress()
: creatorEntry?.address || lightningAddress;
shareTracker.push({
type: shareType,
status: "error",
amount,
address,
error,
});
}
logZapError(
"wallet.sendPayment",
{
shareType: activeShare || "unknown",
amount,
address,
tracker: shareTracker,
context: { shares },
},
error
);
throw error;
} finally {
activeShare = null;
}
},
},
platformAddress: {
getPlatformLightningAddress: async () => {
if (platformEntry?.address) {
return platformEntry.address;
}
const cachedAddress = getCachedPlatformLightningAddress();
if (cachedAddress) {
return cachedAddress;
}
const fallback = await getPlatformLightningAddress({
forceRefresh: false,
});
setCachedPlatformLightningAddress(fallback || "");
return fallback;
},
},
};
}
async prepareLightningContext({ amount, overrideFee = null }) {
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
if (!lightningAddress) {
throw new Error("This creator has not configured a Lightning address yet.");
}
const shares = calculateZapShares(amount, overrideFee);
if (!shares.total) {
throw new Error("Enter a zap amount greater than zero.");
}
const creatorEntry = await fetchLightningMetadata(lightningAddress);
if (shares.creatorShare > 0) {
try {
validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare);
} catch (error) {
const detail = error?.message || "Unable to validate creator share.";
throw new Error(`Creator share error: ${detail}`);
}
}
let platformEntry = null;
let platformAddress = "";
if (shares.platformShare > 0) {
platformAddress = getCachedPlatformLightningAddress();
if (!platformAddress) {
platformAddress = await getPlatformLightningAddress({
forceRefresh: false,
});
setCachedPlatformLightningAddress(platformAddress || "");
}
if (!platformAddress) {
throw new Error("Platform Lightning address is unavailable.");
}
platformEntry = await fetchLightningMetadata(platformAddress);
try {
validateInvoiceAmount(platformEntry.metadata, shares.platformShare);
} catch (error) {
const detail = error?.message || "Unable to validate platform share.";
throw new Error(`Platform share error: ${detail}`);
}
}
this.updateSplitSummary();
return {
shares,
creatorEntry,
platformEntry,
platformAddress,
};
}
getActiveVideoEvent() {
const video = this.getCurrentVideo() || {};
const lightningAddress = video.lightningAddress || "";
return {
kind: typeof video.kind === "number" ? video.kind : 0,
id: typeof video.id === "string" ? video.id : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
tags: Array.isArray(video.tags) ? [...video.tags] : [],
content: typeof video.content === "string" ? video.content : "",
created_at: Number.isFinite(video.created_at)
? video.created_at
: Math.floor(Date.now() / 1000),
lightningAddress,
};
}
async runZapAttempt({ amount, overrideFee = null, walletSettings, comment }) {
const settings = walletSettings || this.getWalletSettingsOrPrompt();
if (!settings) {
return null;
}
let context;
try {
context = await this.prepareLightningContext({
amount,
overrideFee,
});
} catch (error) {
console.error(
"[zap] Modal lightning context failed",
{
amount,
overrideFee,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(settings?.nwcUri),
type:
settings?.type || settings?.name || settings?.client || undefined,
},
},
error
);
throw error;
}
const shareTracker = [];
const dependencies = this.createDependencies({
...context,
shareTracker,
});
const videoEvent = this.getActiveVideoEvent();
let previousOverride;
const hasGlobal = typeof globalThis !== "undefined";
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee;
}
try {
const result = await this.splitAndZap(
{
videoEvent,
amountSats: context.shares.total,
comment: typeof comment === "string" ? comment : "",
walletSettings: settings,
},
dependencies
);
return { context, result, shareTracker };
} catch (error) {
if (Array.isArray(shareTracker) && shareTracker.length) {
error.__zapShareTracker = shareTracker;
}
console.error(
"[zap] splitAndZap failed",
{
amount,
overrideFee,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(settings?.nwcUri),
type:
settings?.type || settings?.name || settings?.client || undefined,
},
shares: {
total: context?.shares?.total,
creator: context?.shares?.creatorShare,
platform: context?.shares?.platformShare,
},
tracker: Array.isArray(shareTracker)
? shareTracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
}))
: undefined,
},
error
);
throw error;
} finally {
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
if (typeof previousOverride === "number") {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
} else if (
globalThis &&
Object.prototype.hasOwnProperty.call(globalThis, "__BITVID_PLATFORM_FEE_OVERRIDE__")
) {
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
}
}
}
async executeRetry({ walletSettings, comment }) {
const retryState = this.modalZapRetryState;
const shares = Array.isArray(retryState?.shares) ? retryState.shares : [];
if (!shares.length) {
this.resetRetryState();
return null;
}
const summary = shares
.map((share) => {
const label =
share.type === "platform"
? "Platform"
: share.type === "creator"
? "Creator"
: "Lightning";
return `${label} ${share.amount} sats`;
})
.join(", ");
this.videoModal?.setZapStatus(
`Retrying failed share(s): ${summary}`,
"warning"
);
this.videoModal?.clearZapReceipts();
const aggregatedReceipts = [];
const aggregatedTracker = [];
for (const share of shares) {
const overrideFee = share.type === "platform" ? 100 : 0;
try {
const attempt = await this.runZapAttempt({
amount: share.amount,
overrideFee,
walletSettings,
comment: retryState?.comment || comment || "",
});
if (!attempt) {
this.videoModal?.setZapStatus("", "neutral");
return null;
}
if (Array.isArray(attempt?.result?.receipts)) {
aggregatedReceipts.push(...attempt.result.receipts);
} else if (Array.isArray(attempt?.shareTracker)) {
aggregatedTracker.push(...attempt.shareTracker);
}
} catch (error) {
const tracker = Array.isArray(error?.__zapShareTracker)
? error.__zapShareTracker
: [];
console.error(
"[zap] Modal zap retry failed",
{
shareType: share?.type || "unknown",
amount: share?.amount,
address: share?.address,
commentLength:
typeof (retryState?.comment || comment) === "string"
? (retryState?.comment || comment).length
: undefined,
tracker: tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
})),
},
error
);
if (tracker.length) {
aggregatedTracker.push(...tracker);
}
throw error;
}
}
if (aggregatedReceipts.length) {
this.videoModal?.renderZapReceipts(aggregatedReceipts, { partial: false });
} else if (aggregatedTracker.length) {
this.videoModal?.renderZapReceipts(aggregatedTracker, { partial: true });
}
const total = shares.reduce((sum, share) => {
const value = Math.max(0, Math.round(Number(share.amount) || 0));
return sum + value;
}, 0);
const successMessage =
total > 0
? `Retried ${total} sats successfully.`
: "Retried zap shares successfully.";
this.videoModal?.setZapStatus(successMessage, "success");
this.notifySuccess("Zap shares retried successfully!");
this.resetRetryState();
return true;
}
markRetryPending(shares, comment = "") {
const validShares = Array.isArray(shares)
? shares.filter((share) => share && share.amount > 0)
: [];
if (!validShares.length) {
this.resetRetryState();
return;
}
this.modalZapRetryState = {
shares: validShares,
comment: typeof comment === "string" ? comment : this.modalZapCommentValue,
createdAt: Date.now(),
};
const summary = validShares
.map((share) => {
const label =
share.type === "platform"
? "Platform"
: share.type === "creator"
? "Creator"
: "Lightning";
return `${label} ${share.amount} sats`;
})
.join(", ");
this.videoModal?.setZapRetryPending(true, { summary });
}
resetRetryState() {
this.modalZapRetryState = null;
this.videoModal?.setZapRetryPending(false);
}
handleZapError({ error, amount, comment, walletSettings }) {
const tracker = Array.isArray(error?.__zapShareTracker)
? error.__zapShareTracker
: [];
console.error(
"[zap] Modal zap attempt failed",
{
amount,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(walletSettings?.nwcUri),
type:
walletSettings?.type ||
walletSettings?.name ||
walletSettings?.client ||
undefined,
},
tracker: tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
})),
retryPending: Array.isArray(this.modalZapRetryState?.shares)
? this.modalZapRetryState.shares.length
: 0,
},
error
);
if (tracker.length) {
this.videoModal?.renderZapReceipts(tracker, { partial: true });
}
const failureShares = tracker.filter(
(entry) => entry && entry.status !== "success" && entry.amount > 0
);
if (failureShares.length) {
this.markRetryPending(failureShares, comment);
const summary = failureShares
.map((share) => {
const label =
share.type === "platform"
? "Platform"
: share.type === "creator"
? "Creator"
: "Lightning";
return `${label} ${share.amount} sats`;
})
.join(", ");
const tone = tracker.length > failureShares.length ? "warning" : "error";
const statusMessage =
tracker.length > failureShares.length
? `Partial zap failure. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(error?.message || statusMessage);
} else {
this.resetRetryState();
const message = error?.message || "Zap failed. Please try again.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
}
}
notifySuccess(message) {
if (this.callbacks.onSuccess) {
this.callbacks.onSuccess(message);
}
}
notifyError(message) {
if (this.callbacks.onError) {
this.callbacks.onError(message);
}
}
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more