mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdff3efe23 | ||
|
|
b7f24c1070 | ||
|
|
9682875ed9 | ||
|
|
6b2c052899 | ||
|
|
d32994e50f | ||
|
|
859ca9a59a | ||
|
|
177cd191e3 | ||
|
|
31e6bacd66 | ||
|
|
6b6c97a52d | ||
|
|
f1dc7d012e | ||
|
|
7af3a536c3 | ||
|
|
fdbb24049d | ||
|
|
ad3f9cb200 | ||
|
|
5f88e85835 | ||
|
|
6039ad2fd7 | ||
|
|
5e81bc9aef | ||
|
|
e127c6f58e | ||
|
|
8d7d7a86cf | ||
|
|
fd62159e61 | ||
|
|
46a8491bbc | ||
|
|
ff14d10d9c | ||
|
|
65c402f845 | ||
|
|
97bf2e2852 | ||
|
|
8c9f2cbfaa | ||
|
|
0f98e597ed | ||
|
|
65cfad60bb | ||
|
|
32f04b94cb | ||
|
|
61e8cc88fe | ||
|
|
1bcb770556 | ||
|
|
418239f977 | ||
|
|
034c4c3761 | ||
|
|
dfa260c2d0 | ||
|
|
b3274c9e68 | ||
|
|
9aaa2db932 | ||
|
|
712e9207e3 | ||
|
|
b4b2baef32 | ||
|
|
70917449d9 | ||
|
|
2351f9e126 | ||
|
|
befa1ad5ae | ||
|
|
789e0eff26 | ||
|
|
8a6e169471 | ||
|
|
53a8800eca | ||
|
|
1ff80e521a | ||
|
|
1e3da7aebc | ||
|
|
cd96e3a2b4 | ||
|
|
80fcb22f89 | ||
|
|
c9857839f5 | ||
|
|
ab607dc7b8 | ||
|
|
7260dfe1e0 | ||
|
|
b56cd0c95f | ||
|
|
a418fb2195 | ||
|
|
511a14087e | ||
|
|
be43f35ffc | ||
|
|
57d114aa52 | ||
|
|
1d1655d2a7 | ||
|
|
4ebc6f6f4a | ||
|
|
db408f4b06 | ||
|
|
f832695a6a | ||
|
|
213f92f496 | ||
|
|
96e8adc553 | ||
|
|
233c50d238 | ||
|
|
7937410e87 | ||
|
|
b50996b907 | ||
|
|
3f4b56ae90 | ||
|
|
edbc3786eb | ||
|
|
c7419a7f74 | ||
|
|
ee09b723af | ||
|
|
c617fcbfda | ||
|
|
622aa756cc | ||
|
|
c52199b1d0 | ||
|
|
ec83b1222f | ||
|
|
033d3b0288 | ||
|
|
5cfdd79a18 | ||
|
|
ce1f2a8f37 | ||
|
|
2b1d5488d6 | ||
|
|
e50780e4b3 | ||
|
|
c2846f9348 | ||
|
|
4266d0bd18 | ||
|
|
711df9bf07 | ||
|
|
24328c4c52 | ||
|
|
d15c574b13 | ||
|
|
198fd97a80 | ||
|
|
c285bd7e9c | ||
|
|
55774f588e |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -794,7 +794,6 @@ async function loadUserVideos(pubkey) {
|
||||
app.handleUrlHealthBadge({ video, url, badgeEl });
|
||||
});
|
||||
|
||||
window.app.videoList = container;
|
||||
window.app.attachVideoListHandler();
|
||||
|
||||
// Lazy-load images
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -378,7 +378,6 @@ function handleHashChange() {
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
recordView("most-recent-videos");
|
||||
});
|
||||
});
|
||||
return;
|
||||
|
||||
+2929
-19
File diff suppressed because it is too large.
Load diff
+1
-4
@@ -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]");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user