Compare commits

..
84 Commits
Author SHA1 Message Date
thePR0M3TH3AN cdff3efe23 Merge pull request #191 from PR0M3TH3AN/unstable
Added watch list.
2025-09-30 22:12:37 -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
19 changed files with 7574 additions and 107 deletions

No files matched your search

+7
View File
@@ -107,6 +107,13 @@ latest styles.
- **Magnet helpers**:
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
### Relay compatibility
Bitvid now requests per-video discussion counts using the NIP-45 `COUNT` frame. The bundled client opens each relay via
`this.pool.ensureRelay(url)` and streams a raw `COUNT` message, so your relay stack must understand that verb (nostr-tools ≥ 1.8
or any relay advertising NIP-45 support). Relays that do not implement `COUNT` are skipped gracefully—the UI keeps the count
placeholder at “—” and development builds log a warning—so mixed deployments remain usable while you phase in compatible relays.
### Adding Features
1. **Fork the repository** and create a new branch for your feature.
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
width="100%"
height="100%"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
>
<g transform="matrix(1,0,0,1,12,12)">
<g transform="matrix(1,0,0,1,-12,-12)">
<path
d="M12,2C6.486,2 2,6.486 2,12C2,17.514 6.486,22 12,22C17.514,22 22,17.514 22,12C22,6.486 17.514,2 12,2ZM12,4C16.411,4 20,7.589 20,12C20,16.411 16.411,20 12,20C7.589,20 4,16.411 4,12C4,7.589 7.589,4 12,4ZM11,7C10.448,7 10,7.448 10,8L10,13C10,13.266 10.105,13.52 10.293,13.707L13.293,16.707C13.683,17.098 14.317,17.098 14.707,16.707C15.098,16.317 15.098,15.683 14.707,15.293L12,12.586L12,8C12,7.448 11.552,7 11,7Z"
style="fill:rgb(92,111,138);fill-rule:nonzero;"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+129 -19
View File
@@ -88,6 +88,15 @@
>
Blocked
</button>
<button
id="profileNavHistory"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneHistory"
aria-selected="false"
>
Watch history
</button>
<button
id="profileNavAdmin"
type="button"
@@ -107,27 +116,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
@@ -209,6 +281,44 @@
</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 class="space-y-4">
<div
id="profileHistoryLoading"
class="flex items-center justify-center gap-3 py-6"
>
<div class="h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<p
id="profileHistoryStatus"
class="text-sm text-gray-300 hidden"
aria-live="polite"
role="status"
></p>
</div>
<div
id="profileHistoryEmpty"
class="hidden rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
>
You haven’t watched any videos yet.
</div>
<div id="profileHistoryScroll" class="relative max-h-[420px] overflow-y-auto pr-1">
<div
id="profileHistoryGrid"
class="watch-history-grid watch-history-grid--modal hidden"
role="list"
></div>
<div id="profileHistorySentinel" class="h-1 w-full"></div>
</div>
</div>
</section>
<section
id="profilePaneAdmin"
class="profile-pane hidden space-y-6"
+11
View File
@@ -34,6 +34,17 @@
/>
<span class="sidebar-text">Explore</span>
</a>
<a
href="#view=history"
class="flex items-center py-2 px-4 hover:bg-gray-700 rounded font-semibold"
>
<img
src="assets/svg/history-icon.svg"
alt="History"
class="w-6 h-6 mr-3 flex-shrink-0"
/>
<span class="sidebar-text">History</span>
</a>
<a
id="subscriptionsLink"
href="#view=subscriptions"
+69
View File
@@ -44,3 +44,72 @@ 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 30078 so that watch events, view logs,
* and media metadata stay in the same family of documents. 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 = 30078;
/**
* 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";
/**
* 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 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 = 12;
/**
* 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;
+615
View File
@@ -104,6 +104,31 @@ header img {
padding: 1.5rem 0;
}
/* Dedicated watch-history layout stacks cards vertically */
.watch-history-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
padding: 1.5rem 0;
}
/* Profile modal uses the same vertical stack but without outer padding */
.watch-history-grid.watch-history-grid--modal {
padding: 0;
gap: 1.25rem;
}
@media (max-width: 640px) {
.watch-history-grid {
gap: 1.25rem;
}
.watch-history-grid.watch-history-grid--modal {
gap: 1.125rem;
}
}
/* Video Cards */
.video-card {
background-color: var(--color-card);
@@ -131,6 +156,249 @@ header img {
animation: video-card-fade-in 220ms ease-out forwards;
}
/* Watch history cards */
.watch-history-card {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: stretch;
background-color: var(--color-card);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 0.0625rem solid transparent;
box-shadow: var(--shadow-md);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.watch-history-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.watch-history-card--private {
border-color: rgba(234, 179, 8, 0.75);
}
.watch-history-card__primary {
display: flex;
gap: 1rem;
flex: 1 1 18rem;
min-width: 0;
}
.watch-history-card__thumbnail {
display: block;
flex: 0 0 14rem;
max-width: 14rem;
text-decoration: none;
}
.watch-history-card__thumbnailInner {
width: 100%;
height: 100%;
border-radius: 0.5rem;
overflow: hidden;
background-color: #0f172a;
}
.watch-history-card__thumbnailInner img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.watch-history-card__details {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
min-width: 0;
}
.watch-history-card__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
cursor: pointer;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watch-history-card__title:hover,
.watch-history-card__title:focus {
color: #60a5fa;
}
.watch-history-card__created {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
}
.watch-history-card__meta {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;
flex: 0 0 16rem;
min-width: 14rem;
}
.watch-history-card__watched {
margin: 0;
font-size: 0.875rem;
color: #d1d5db;
}
.watch-history-card__creator {
display: flex;
align-items: center;
gap: 0.75rem;
}
.watch-history-card__creatorAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #1f2937;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__creatorAvatar:hover,
.watch-history-card__creatorAvatar:focus {
box-shadow: 0 0 0 0.125rem rgba(59, 130, 246, 0.5);
outline: none;
}
.watch-history-card__creatorAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 9999px;
}
.watch-history-card__creatorName {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #e5e7eb;
text-align: left;
cursor: pointer;
}
.watch-history-card__creatorName:hover,
.watch-history-card__creatorName:focus {
color: #60a5fa;
outline: none;
}
.watch-history-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.watch-history-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 0.0625rem solid transparent;
background-color: #1f2937;
color: #e5e7eb;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease,
border-color 0.2s ease, box-shadow 0.2s ease;
}
.watch-history-card__action:hover,
.watch-history-card__action:focus {
background-color: #111827;
color: #ffffff;
border-color: #374151;
outline: none;
}
.watch-history-card__action--primary {
background-color: #2563eb;
color: #ffffff;
}
.watch-history-card__action--primary:hover,
.watch-history-card__action--primary:focus {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.watch-history-card__action--danger {
background-color: #7f1d1d;
color: #ffffff;
}
.watch-history-card__action--danger:hover,
.watch-history-card__action--danger:focus {
background-color: #991b1b;
border-color: #991b1b;
}
@media (max-width: 1024px) {
.watch-history-card__meta {
flex: 1 1 18rem;
}
}
@media (max-width: 768px) {
.watch-history-card {
gap: 1.25rem;
padding: 1rem;
}
.watch-history-card__thumbnail {
flex-basis: 11rem;
max-width: 11rem;
}
}
@media (max-width: 640px) {
.watch-history-card {
flex-direction: column;
}
.watch-history-card__primary,
.watch-history-card__meta {
flex: 1 1 auto;
min-width: 0;
}
.watch-history-card__thumbnail {
width: 100%;
max-width: 100%;
}
.watch-history-card__meta {
gap: 0.75rem;
}
}
@keyframes video-card-fade-in {
from {
opacity: 0;
@@ -269,6 +537,290 @@ header img {
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
}
/* --------------------------------------------------------------------------
Profile modal account pane
--------------------------------------------------------------------------
The modal previously mixed Tailwind-style utility classes with our global
stylesheet. Those utilities are not always present in the production build,
so important layout rules (borders, gaps, flex behaviour) were getting
dropped. This block consolidates the account tab styling in one predictable
place so future tweaks only require CSS updates here instead of scattered
inline class lists.
--------------------------------------------------------------------------- */
#profileModal .profile-pane {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/*
Ensure the global `.hidden` helper keeps working for the tab panes. Our
profile-pane declaration above sets a `display` value with higher
specificity than `.hidden`, so we explicitly restore the hidden state when
both classes are present.
*/
#profileModal .profile-pane.hidden {
display: none;
}
#profileModal .profile-switcher {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: clamp(1.25rem, 2vw, 1.5rem);
border-radius: 1.25rem;
border: 0.0625rem solid rgba(148, 163, 184, 0.28);
background: linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(15, 23, 42, 0.72));
box-shadow: 0 1.75rem 3.5rem rgba(8, 11, 30, 0.45);
}
#profileModal .profile-switcher__header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
#profileModal .profile-switcher__intro {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
#profileModal .profile-switcher__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
#profileModal .profile-switcher__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
}
#profileModal .profile-switcher__badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.85rem;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
border-radius: 9999px;
background: rgba(59, 130, 246, 0.18);
color: rgba(191, 219, 254, 0.95);
border: 0.0625rem solid rgba(96, 165, 250, 0.25);
}
#profileModal .profile-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
border-radius: 1rem;
border: 0.0625rem solid rgba(148, 163, 184, 0.24);
background: rgba(15, 23, 42, 0.72);
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
outline: none;
}
#profileModal .profile-card:hover {
border-color: rgba(148, 163, 184, 0.45);
}
#profileModal .profile-card:focus-visible {
border-color: rgba(96, 165, 250, 0.6);
box-shadow: 0 0 0 0.1875rem rgba(37, 99, 235, 0.25);
}
#profileModal .profile-card--active {
border-color: rgba(59, 130, 246, 0.55);
background: linear-gradient(160deg, rgba(37, 99, 235, 0.22), rgba(15, 23, 42, 0.78));
box-shadow: 0 1.35rem 2.8rem rgba(37, 99, 235, 0.22);
}
#profileModal .profile-card__avatar {
flex: 0 0 auto;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
overflow: hidden;
border: 0.1875rem solid rgba(148, 163, 184, 0.3);
background: rgba(15, 23, 42, 0.95);
}
#profileModal .profile-card__avatar img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
}
#profileModal .profile-card--active .profile-card__avatar {
border-color: rgba(96, 165, 250, 0.7);
box-shadow: 0 0 0 0.1875rem rgba(59, 130, 246, 0.18);
}
#profileModal .profile-card__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#profileModal .profile-card__topline {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-start;
}
#profileModal .profile-card__label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(191, 219, 254, 0.9);
}
#profileModal .profile-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.8rem;
border-radius: 9999px;
border: 0.0625rem solid rgba(59, 130, 246, 0.4);
background: rgba(59, 130, 246, 0.14);
color: rgba(191, 219, 254, 0.95);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
#profileModal .profile-card__action:hover,
#profileModal .profile-card__action:focus-visible {
background: rgba(59, 130, 246, 0.24);
border-color: rgba(59, 130, 246, 0.6);
color: #ffffff;
}
#profileModal .profile-card__name {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#profileModal .profile-card__npub {
font-size: 0.75rem;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: rgba(148, 163, 184, 0.85);
word-break: break-all;
}
#profileModal .profile-switcher__list {
display: flex;
flex-direction: column;
gap: 1rem;
}
#profileModal .profile-switcher__add {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 1rem;
border: 0.125rem dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.55);
color: rgba(226, 232, 240, 0.85);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
}
#profileModal .profile-switcher__add:hover,
#profileModal .profile-switcher__add:focus-visible {
border-color: rgba(96, 165, 250, 0.65);
background: rgba(59, 130, 246, 0.18);
color: #ffffff;
outline: none;
}
#profileModal .profile-switcher__addAvatar {
flex: 0 0 auto;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
border: 0.125rem dashed currentColor;
color: inherit;
background: rgba(15, 23, 42, 0.75);
}
#profileModal .profile-switcher__addAvatar svg {
width: 1.25rem;
height: 1.25rem;
}
#profileModal .profile-switcher__addCopy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
#profileModal .profile-switcher__addTitle {
font-size: 0.9rem;
font-weight: 600;
color: inherit;
}
#profileModal .profile-switcher__addHint {
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.85);
}
#profileModal .profile-switcher__helper {
margin: 0;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.7);
}
@media (min-width: 640px) {
#profileModal .profile-switcher__header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
#profileModal .profile-card {
flex-direction: row;
align-items: center;
}
#profileModal .profile-card__topline {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
}
/* If you ever want to show it, add ".flex" class dynamically */
#playerModal.flex {
display: flex;
@@ -535,6 +1087,27 @@ button:not(.icon-button):focus {
}
/* Notifications */
#notificationPortal {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
left: 50%;
transform: translateX(-50%);
z-index: 400; /* Keep notifications above the highest modal overlays */
width: min(90vw, 32rem);
display: flex;
flex-direction: column;
gap: 0.75rem;
pointer-events: none;
}
#notificationPortal > * {
width: 100%;
margin-bottom: 0;
pointer-events: auto;
border-radius: 0.75rem;
box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, 0.3);
}
/* Base styling without a forced display */
#errorContainer,
#successContainer {
@@ -550,6 +1123,48 @@ button:not(.icon-button):focus {
display: flex;
}
.watch-history-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-spinner--inline {
animation: spin 1s linear infinite;
border: 0.125rem solid rgba(96, 165, 250, 0.35);
border-radius: 9999px;
border-top-color: #60a5fa;
display: inline-block;
height: 1rem;
width: 1rem;
}
.status-banner {
align-items: center;
background-color: rgba(31, 41, 55, 0.9);
border-radius: 0.5rem;
color: #e5e7eb;
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
}
.status-banner .status-spinner {
animation: spin 1s linear infinite;
border: 0.125rem solid rgba(229, 231, 235, 0.35);
border-radius: 9999px;
border-top-color: #f9fafb;
height: 1.25rem;
width: 1.25rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 640px) {
#videoList {
+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.
+3
View File
@@ -17,5 +17,8 @@ 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.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+54 -11
View File
@@ -149,17 +149,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>
@@ -181,8 +200,32 @@
<!-- Additional Scripts -->
<script src="js/libs/nostr.bundle.js"></script>
<script type="module">
import { nip19, SimplePool } from "https://esm.sh/[email protected]";
window.NostrTools = { nip19, SimplePool };
import * as nostrTools from "https://esm.sh/[email protected]";
const existing =
typeof window !== "undefined" &&
window.NostrTools &&
typeof window.NostrTools === "object"
? window.NostrTools
: {};
const merged = { ...existing, ...nostrTools };
if (
!merged.generatePrivateKey &&
typeof merged.generateSecretKey === "function"
) {
merged.generatePrivateKey = merged.generateSecretKey.bind(merged);
}
if (
!merged.generateSecretKey &&
typeof merged.generatePrivateKey === "function"
) {
merged.generateSecretKey = merged.generatePrivateKey.bind(merged);
}
window.NostrTools = merged;
</script>
<script type="module" src="js/config.js"></script>
<script type="module" src="js/lists.js"></script>
+1514 -52
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
app.handleUrlHealthBadge({ video, url, badgeEl });
});
window.app.videoList = container;
window.app.attachVideoListHandler();
// Lazy-load images
+19
View File
@@ -4,6 +4,13 @@ import {
ADMIN_SUPER_NPUB,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
DEFAULT_WHITELIST_MODE_ENABLED,
WATCH_HISTORY_KIND,
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
} from "../config/instance-config.js";
export const isDevMode = true; // Set to false for production
@@ -42,3 +49,15 @@ 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_MAX_ITEMS };
export { WATCH_HISTORY_BATCH_RESOLVE };
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
export { WATCH_HISTORY_CACHE_TTL_MS };
+1346
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -378,7 +378,6 @@ function handleHashChange() {
if (typeof initFn === "function") {
initFn();
}
recordView("most-recent-videos");
});
});
return;
+2929 -19
View File
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -614,10 +614,7 @@ class SubscriptionsManager {
window.app.handleUrlHealthBadge({ video, url, badgeEl });
});
if (window.app) {
window.app.videoList = container;
window.app.attachVideoListHandler?.();
}
window.app?.attachVideoListHandler?.();
// Lazy-load
const lazyEls = container.querySelectorAll("[data-lazy]");
+10
View File
@@ -67,6 +67,16 @@ export const viewInitRegistry = {
explore: () => {
console.log("Explore view loaded.");
},
history: async () => {
try {
const module = await import("./historyView.js");
if (typeof module.initHistoryView === "function") {
await module.initHistoryView();
}
} catch (error) {
console.error("Failed to initialize history view:", error);
}
},
/**
* Subscriptions view:
+746
View File
@@ -0,0 +1,746 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const {
WATCH_HISTORY_LIST_IDENTIFIER,
WATCH_HISTORY_KIND,
} = await import("../js/config.js");
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
globalThis.window.NostrTools.getEventHash = (event) => {
const payload = `${event.kind}:${event.pubkey}:${event.created_at}`;
return `hash-${Buffer.from(payload).toString("hex")}`;
};
globalThis.window.NostrTools.signEvent = (event, privateKey) =>
`sig-${privateKey.slice(0, 8)}-${event.created_at}`;
delete globalThis.window.NostrTools.nip04;
delete globalThis.window.nostr;
const { nostrClient } = await import("../js/nostr.js");
function createPublishingClient(actorPubkey) {
const client = new nostrClient.constructor();
const publishedEvents = [];
const payloads = [];
client.pool = {
publish(urls, event) {
publishedEvents.push(event);
return {
on(eventName, handler) {
if (eventName === "ok" || eventName === "seen") {
setTimeout(handler, 0);
}
},
};
},
list: async () => [],
};
client.relays = ["wss://unit.test"];
client.pubkey = "";
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
client.ensureSessionActor = async () => actorPubkey;
client.encryptWatchHistoryPayload = async (_actor, payload) => {
payloads.push(payload);
return { ok: true, ciphertext: JSON.stringify(payload) };
};
client.persistWatchHistoryEntry = () => {};
client.cancelWatchHistoryRepublish = () => {};
client.scheduleWatchHistoryRepublish = () => {};
return { client, publishedEvents, payloads };
}
function createDecryptClient(actorPubkey) {
const client = new nostrClient.constructor();
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
client.ensureSessionActor = async () => actorPubkey;
return client;
}
const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { client: publishingClient, publishedEvents, payloads } =
createPublishingClient(ACTOR);
publishingClient.watchHistoryPayloadMaxBytes = 400;
publishingClient.watchHistoryFetchEventLimit = 10;
const baseWatchedAt = 1_700_000_000_000;
const longPointers = Array.from({ length: 8 }, (_, index) => {
const relay = index % 3 === 0 ? "wss://relay.unit" : null;
const watchedAt = baseWatchedAt + index * 60_000;
if (index % 2 === 0) {
return {
type: "e",
value: `event-${index}-${"x".repeat(90)}`,
relay,
watchedAt,
};
}
return {
type: "a",
value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`,
relay,
watchedAt,
};
});
const publishResult = await publishingClient.publishWatchHistorySnapshot(
ACTOR,
longPointers
);
assert.equal(publishResult.ok, true, "publish should report success");
assert(publishResult.events.length > 1, "snapshot should chunk into multiple events");
assert.equal(
publishResult.events.length,
payloads.length,
"each chunk should produce an encryption payload"
);
payloads.forEach((payload) => {
const size = JSON.stringify(payload).length;
assert(
size <= publishingClient.watchHistoryPayloadMaxBytes,
`payload size ${size} should respect cap`
);
assert.equal(payload.version, 2, "chunk payload should use version 2");
});
const snapshotIds = new Set(
publishResult.events.map((event) =>
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
)
);
assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id");
const headTag = publishResult.event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "head"
);
assert(headTag, "head chunk should include head tag");
const chunkIdentifiers = publishResult.events.map((event) =>
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
);
assert.equal(
chunkIdentifiers.length,
publishResult.events.length,
"each chunk should expose a d tag"
);
assert.equal(
chunkIdentifiers[0],
WATCH_HISTORY_LIST_IDENTIFIER,
"head chunk should retain canonical watch history identifier"
);
const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
assert.equal(
uniqueChunkIdentifiers.size,
publishResult.events.length,
"each chunk should have a unique d tag"
);
chunkIdentifiers.slice(1).forEach((identifier, index) => {
assert(
identifier && identifier.startsWith("watch-history:"),
`chunk ${index + 1} should use a watch-history namespace identifier`
);
assert.notEqual(
identifier,
WATCH_HISTORY_LIST_IDENTIFIER,
"only the head chunk should use the canonical identifier"
);
});
const headATags = publishResult.event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
.map((tag) => tag[1]);
const expectedChunkAddresses = publishResult.events.map((event) => {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
assert(identifier, "chunk should include identifier tag");
return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`;
});
expectedChunkAddresses.forEach((address) => {
assert(
headATags.includes(address),
`head chunk should reference ${address}`
);
});
delete globalThis.window.nostr;
if (!globalThis.window.NostrTools.nip04) {
globalThis.window.NostrTools.nip04 = {};
}
globalThis.window.NostrTools.nip04.decrypt = async (
_priv,
_pub,
ciphertext
) => ciphertext;
const decryptClient = createDecryptClient(ACTOR);
decryptClient.relays = ["wss://unit.test"];
decryptClient.watchHistoryFetchEventLimit = 10;
decryptClient.pool = {
list: async () => publishedEvents,
};
const fetched = await decryptClient.fetchWatchHistory(ACTOR);
assert.equal(
fetched.items.length,
longPointers.length,
"fetch should reassemble all chunked items"
);
assert.deepEqual(
fetched.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
longPointers.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
"chunked fetch should preserve pointer order and timestamps"
);
if (typeof localStorage !== "undefined") {
localStorage.clear();
}
const headEvent = publishResult.event;
const chunkEvents = publishResult.events.filter(
(event) => event.id !== headEvent.id
);
const snapshotId = headEvent.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
)?.[1];
const freshClient = createDecryptClient(ACTOR);
freshClient.relays = ["wss://unit.test"];
freshClient.watchHistoryFetchEventLimit = 10;
const observedFilters = [];
freshClient.pool = {
list: async (_relays, filters) => {
observedFilters.push(filters);
const firstFilter = filters[0] || {};
const dFilter = Array.isArray(firstFilter["#d"])
? firstFilter["#d"]
: [];
if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
return [headEvent];
}
const requestedIdentifiers = new Set();
const requestedSnapshots = new Set();
for (const filter of filters) {
if (Array.isArray(filter?.["#d"])) {
for (const identifier of filter["#d"]) {
requestedIdentifiers.add(identifier);
}
}
if (Array.isArray(filter?.["#snapshot"])) {
for (const value of filter["#snapshot"]) {
requestedSnapshots.add(value);
}
}
}
const responses = [];
for (const event of publishResult.events) {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
const eventSnapshot = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
)?.[1];
if (identifier && requestedIdentifiers.has(identifier)) {
responses.push(event);
continue;
}
if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) {
responses.push(event);
}
}
return responses;
},
};
const rebuilt = await freshClient.fetchWatchHistory(ACTOR);
assert.equal(
rebuilt.items.length,
longPointers.length,
"fresh client should rebuild full watch history list"
);
assert.deepEqual(
rebuilt.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
longPointers.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
watchedAt: item.watchedAt,
})),
"rebuilt snapshot should preserve timestamps"
);
assert(
observedFilters.length >= 2,
"fetch should include a follow-up query for chunk events"
);
const followUpFilters = observedFilters[observedFilters.length - 1];
const identifiersRequested = new Set();
let snapshotRequested = false;
for (const filter of followUpFilters) {
if (Array.isArray(filter?.["#d"])) {
for (const identifier of filter["#d"]) {
identifiersRequested.add(identifier);
}
}
if (Array.isArray(filter?.["#snapshot"])) {
snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId);
}
}
assert(
chunkEvents.every((event) => {
const identifier = event.tags.find(
(tag) => Array.isArray(tag) && tag[0] === "d"
)?.[1];
return (
identifiersRequested.has(identifier) ||
(snapshotId && snapshotRequested)
);
}),
"follow-up query should target chunk identifiers or snapshot"
);
const MONOTONIC_ACTOR = `${ACTOR}-monotonic`;
const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } =
createPublishingClient(MONOTONIC_ACTOR);
const monotonicOriginalDateNow = Date.now;
try {
const monotonicBaseSeconds = 1_700_123_456;
Date.now = () => monotonicBaseSeconds * 1000;
const firstMonotonicPointers = [
{ type: "e", value: "event-monotonic-1", relay: null },
{ type: "e", value: "event-monotonic-2", relay: null },
];
const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
MONOTONIC_ACTOR,
firstMonotonicPointers
);
const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry(
firstSnapshot.event,
firstMonotonicPointers,
Date.now()
);
monotonicPublishingClient.watchHistoryCache.set(
MONOTONIC_ACTOR,
baselineEntry
);
const secondMonotonicPointers = [
{ type: "e", value: "event-monotonic-3", relay: null },
{ type: "e", value: "event-monotonic-1", relay: null },
];
const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
MONOTONIC_ACTOR,
secondMonotonicPointers,
baselineEntry
);
assert(
secondSnapshot.event.created_at > firstSnapshot.event.created_at,
"new snapshot should advance created_at when reusing the same second"
);
const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR);
monotonicDecryptClient.pool = { list: async () => monotonicEvents };
const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory(
MONOTONIC_ACTOR
);
assert.deepEqual(
monotonicFetched.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay || null,
})),
[
{ type: "e", value: "event-monotonic-3", relay: null },
{ type: "e", value: "event-monotonic-1", relay: null },
],
"newer snapshot should win pointer ordering"
);
} finally {
Date.now = monotonicOriginalDateNow;
}
localStorage.clear();
const UPDATE_ACTOR = `${ACTOR}-update`;
const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
const updateOriginalDateNow = Date.now;
try {
const updateTimestamp = 1_701_111_111_111;
Date.now = () => updateTimestamp;
const updateResult = await updateClient.updateWatchHistoryList({
type: "e",
value: "update-pointer",
relay: null,
});
assert.equal(
updateResult.ok,
true,
"updateWatchHistoryList should publish when adding a new pointer"
);
const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
assert(updateEntry, "updateWatchHistoryList should cache the new entry");
assert.equal(
updateEntry.items[0].watchedAt,
updateTimestamp,
"cached entry should record watchedAt from Date.now()"
);
assert.equal(
updateResult.items[0].watchedAt,
updateTimestamp,
"returned publish result should include the watchedAt timestamp"
);
} finally {
Date.now = updateOriginalDateNow;
}
const REMOVE_ACTOR = `${ACTOR}-remove`;
const {
client: removalClient,
payloads: removalPayloads,
} = createPublishingClient(REMOVE_ACTOR);
const removalPointers = [
{
type: "e",
value: "remove-first",
relay: null,
watchedAt: 1_701_222_000_000,
},
{
type: "e",
value: "remove-second",
relay: null,
watchedAt: 1_701_222_100_000,
},
];
await removalClient.publishWatchHistorySnapshot(
REMOVE_ACTOR,
removalPointers
);
const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
assert.equal(
removalResult.removedKey,
removalKey,
"removeWatchHistoryItem should echo the removed pointer key"
);
const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
assert.equal(
latestRemovalPayload.items.length,
1,
"removal republish should trim the pointer list"
);
assert.equal(
latestRemovalPayload.items[0].value,
removalPointers[0].value,
"republished payload should only include remaining pointer"
);
assert.equal(
latestRemovalPayload.items[0].watchedAt,
removalPointers[0].watchedAt,
"republished payload should preserve watchedAt"
);
const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
assert.equal(
removalEntry.items.length,
1,
"watch history cache should drop the removed pointer"
);
assert.equal(
removalEntry.items[0].value,
removalPointers[0].value,
"remaining cached pointer should match the survivor"
);
assert.equal(
removalEntry.items[0].watchedAt,
removalPointers[0].watchedAt,
"remaining cached pointer should retain watchedAt"
);
assert.equal(
removalResult.items.map((item) => item.value).join(","),
removalPointers.slice(0, 1).map((item) => item.value).join(","),
"removeWatchHistoryItem result should reflect updated items"
);
assert.equal(
removalResult.items[0].watchedAt,
removalPointers[0].watchedAt,
"removeWatchHistoryItem result should preserve watchedAt"
);
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
const videoPointer = {
type: "a",
value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
relay: null,
};
const pointerClient = createDecryptClient(ACTOR);
const pointerEntry = pointerClient.createWatchHistoryEntry(
null,
[videoPointer],
Date.now()
);
pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
pointerClient.fetchWatchHistory = async () => {};
const observedResolveFilters = [];
const videoEvent = {
id: "video-pointer-event",
kind: WATCH_HISTORY_KIND,
pubkey: VIDEO_POINTER_AUTHOR,
created_at: 1_700_555_000,
tags: [["d", VIDEO_POINTER_IDENTIFIER]],
content: JSON.stringify({
version: 3,
title: "Pointer Video",
url: "https://cdn.example.com/video.mp4",
}),
};
pointerClient.pool = {
list: async (_relays, filters) => {
observedResolveFilters.push(filters);
return [videoEvent];
},
};
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
assert.equal(
resolvedFromPointer.length,
1,
"resolveWatchHistory should return results for 'a' style video pointers"
);
assert.equal(
resolvedFromPointer[0].id,
videoEvent.id,
"resolved video should match the pointer event id"
);
assert.equal(
resolvedFromPointer[0].title,
"Pointer Video",
"resolved video should parse title from the event payload"
);
assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
assert.equal(
resolvedFromPointer[0].watchHistory.key,
pointerKey,
"watchHistory metadata should expose the pointer key"
);
assert.equal(
resolvedFromPointer[0].watchHistory.pointer.value,
videoPointer.value,
"watchHistory metadata should include the pointer payload"
);
assert.equal(
resolvedFromPointer[0].watchHistory.pointer.watchedAt,
pointerEntry.items[0].watchedAt,
"watchHistory metadata should preserve watchedAt"
);
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
assert(
cachedEntry.resolvedVideos.has(pointerKey),
"resolved cache should retain the video"
);
assert.equal(
cachedEntry.resolvedVideos.get(pointerKey)?.id,
videoEvent.id,
"resolved cache should store the converted video result"
);
assert(
cachedEntry.delivered.has(pointerKey),
"delivery set should mark the pointer as delivered"
);
assert(
observedResolveFilters.some((filters) =>
filters.some((filter) =>
Array.isArray(filter?.["#d"]) &&
filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
)
),
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
);
const secondResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
secondResolve.length,
0,
"subsequent resolve calls should avoid refetching already delivered videos"
);
pointerClient.resetWatchHistoryProgress(ACTOR);
assert.equal(
cachedEntry.delivered.size,
0,
"resetWatchHistoryProgress should clear delivered pointers"
);
pointerClient.pool = {
list: async () => {
throw new Error("cached resolve should not trigger network fetch");
},
};
const cachedResolve = await pointerClient.resolveWatchHistory(1);
assert.equal(
cachedResolve.length,
1,
"reset should allow cached videos to be delivered again"
);
assert.equal(
cachedResolve[0].id,
videoEvent.id,
"cached resolve should reuse the stored video payload"
);
assert(
cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
"cached resolve should retain watchHistory metadata"
);
const originalDateNow = Date.now;
try {
const baseTime = 1_700_000_000_000;
const ttlActor = `${ACTOR}-ttl`;
const pointer = { type: "e", value: "event-ttl", relay: null };
const cachingClient = createDecryptClient(ttlActor);
cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000;
Date.now = () => baseTime;
const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime);
cachingClient.watchHistoryCache.set(ttlActor, entry);
cachingClient.persistWatchHistoryEntry(ttlActor, entry);
const reloadClient = createDecryptClient(ttlActor);
reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs;
Date.now = () => baseTime + 60 * 60 * 1000;
const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor);
assert.equal(
cachedSnapshot.items.length,
1,
"cached snapshot should survive within extended TTL"
);
assert.equal(
cachedSnapshot.items[0].value,
pointer.value,
"cached snapshot should preserve pointer"
);
localStorage.clear();
const expiryClient = createDecryptClient(ttlActor);
expiryClient.watchHistoryCacheTtlMs = 1_000;
Date.now = () => baseTime;
const expiringEntry = expiryClient.createWatchHistoryEntry(
null,
[pointer],
baseTime
);
expiryClient.watchHistoryCache.set(ttlActor, expiringEntry);
expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry);
Date.now = () => baseTime + 1_500;
const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor);
assert.equal(
expiredSnapshot.items.length,
0,
"expired snapshot should be dropped"
);
const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor);
assert.equal(
cachedFallback?.items?.length ?? 0,
0,
"expired snapshot should not preserve stale items in memory"
);
const storage = expiryClient.getWatchHistoryStorage();
assert.equal(
Object.prototype.hasOwnProperty.call(storage.actors, ttlActor),
false,
"expired snapshot should be removed from localStorage"
);
} finally {
Date.now = originalDateNow;
}
console.log("watch history tests passed");
+26
View File
@@ -0,0 +1,26 @@
<!-- views/history.html -->
<section id="watchHistoryView" class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-semibold text-white">Watch History</h2>
</div>
<div
id="watchHistoryLoading"
class="flex flex-col items-center justify-center py-12 space-y-4"
>
<div
class="h-10 w-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
aria-hidden="true"
></div>
<p
id="watchHistoryStatus"
class="text-gray-300 text-center hidden"
aria-live="polite"
></p>
</div>
<div id="watchHistoryGrid" class="watch-history-grid hidden"></div>
<p id="watchHistoryEmpty" class="hidden text-gray-400 text-center py-12">
Your watch history is empty. Watch some videos to populate this list.
</p>
<div id="watchHistorySentinel" class="h-1 w-full"></div>
<script type="module" src="js/historyView.js"></script>
</section>